Featuring Design System
Utils

useRender

컴포넌트의 렌더링 요소를 render prop으로 교체할 수 있게 해주는 Hook. base-ui의 useRender를 기반으로 ref 병합, 이벤트 핸들러 체이닝, state → data-* 자동 변환을 제공합니다.

개요

useRenderbase-ui에서 제공하는 Hook으로, 컴포넌트의 기본 HTML 요소를 render prop을 통해 다른 요소나 컴포넌트로 교체할 수 있게 해줍니다.

Featuring 디자인 시스템은 이 Hook을 직접 re-export하여 소비자도 커스텀 컴포넌트를 만들 때 동일한 패턴을 사용할 수 있습니다.

import { useRender, mergeProps } from '@featuring-corp/components';

왜 useRender인가?

기존 방식의 문제점

기존에는 일반 함수(renderElement, renderComponent)로 렌더링을 처리했습니다. 이 방식에는 다음과 같은 리스크가 있었습니다:

문제설명심각도
Ref 유실render={<nav ref={myRef} />} 사용 시 component의 forwardedRef가 유실됨Critical
이벤트 핸들러 덮어쓰기render={<a onClick={track} />}로 요소 교체 시 내부 onClick이 사라짐High
Hook 사용 불가일반 함수 내부에서 useMergedRefs 등 Hook을 호출할 수 없음High
className/style 단순 스프레드{...props, ...existingProps}로 마지막 값만 적용됨Medium

useRender가 해결하는 것

  • Ref 병합: useMergedRefs로 forwarded ref + 내부 ref + render element ref를 모두 안전하게 병합
  • 이벤트 핸들러 체이닝: mergeProps를 통해 내부/외부 핸들러가 모두 실행됨 (우측 우선)
  • className 연결: 여러 출처의 className이 자동으로 연결됨
  • style 병합: 여러 출처의 style이 우선순위에 따라 병합됨
  • state → data-* 자동 변환: state 객체가 자동으로 data-disabled, data-open 등의 속성으로 변환됨

Usage

기본 사용법 — 커스텀 컴포넌트 만들기

import { useRender, mergeProps } from '@featuring-corp/components';
import type { useRender as UseRender } from '@featuring-corp/components';

interface TextProps extends UseRender.ComponentProps<'p'> {}

function Text({ render, ...otherProps }: TextProps) {
  return useRender({
    defaultTagName: 'p',
    render,
    props: mergeProps<'p'>({ className: 'my-text' }, otherProps),
  });
}

// 사용
<Text>기본 p 태그로 렌더링</Text>
<Text render={<span />}>span으로 렌더링</Text>
<Text render={<strong />}>strong으로 렌더링</Text>

Ref 병합

내부 ref와 외부 ref를 동시에 사용해야 할 때:

import React, { forwardRef, useRef } from 'react';
import { useRender, mergeProps } from '@featuring-corp/components';

const FocusableText = forwardRef<HTMLElement, TextProps>(
  ({ render, ...props }, forwardedRef) => {
    const internalRef = useRef<HTMLElement>(null);

    return useRender({
      defaultTagName: 'p',
      render,
      ref: [forwardedRef, internalRef], // 두 ref 모두 안전하게 병합
      props,
    });
  }
);

State와 data-* 속성

state 객체를 전달하면 자동으로 data-* 속성으로 변환됩니다:

import { useRender, mergeProps } from '@featuring-corp/components';

interface ToggleState {
  active: boolean;
  disabled: boolean;
}

function Toggle({ render, active, disabled, ...props }: ToggleProps) {
  const state: ToggleState = { active, disabled };

  return useRender({
    defaultTagName: 'button',
    render,
    state,
    props: mergeProps<'button'>({ className: 'toggle' }, props),
  });
}

// 렌더링 결과:
// <button class="toggle" data-active="" data-disabled="">...</button>
//
// CSS에서 활용:
// .toggle[data-active] { background: blue; }
// .toggle[data-disabled] { opacity: 0.5; }

render prop 콜백 (state 접근)

render에 함수를 전달하면 props와 state에 접근할 수 있습니다:

<Toggle
  active={isActive}
  render={(props, state) => (
    <button {...props}>
      {state.active ? '활성' : '비활성'}
    </button>
  )}
/>

useRenderComponent

useRenderComponent는 Featuring 디자인 시스템 전용 Hook으로, useRender + mergeProps + rainbowSprinkles를 통합합니다. 레이아웃 컴포넌트(Box, Flex 등)의 내부에서 사용됩니다.

import { useRenderComponent } from '@featuring-corp/components';

Props 설명

Prop

Type

Props 병합 우선순위

useRenderComponent 내부에서 mergeProps를 통해 4개 레이어의 props가 병합됩니다:

우선순위 (낮음 → 높음):

1. baseClassName + sprinklesStyle  → 컴포넌트 기본 스타일
2. sprinklesClassName              → 토큰 기반 atomic CSS 클래스
3. extraProps + nativeProps         → 내부 props + 남은 HTML props (onClick 등)
4. className + style + children     → 소비자가 전달한 값 (최고 우선순위)

이것은 다음을 보장합니다:

  • 소비자의 className이 항상 적용됨
  • 소비자의 style이 sprinkles 스타일을 override할 수 있음
  • 이벤트 핸들러는 덮어쓰지 않고 체이닝

사용 예시 — 커스텀 레이아웃 컴포넌트

import React, { forwardRef } from 'react';
import { useRenderComponent } from '@featuring-corp/components';
import type { RainbowSprinkles } from '@featuring-corp/components';
import { cardRecipe } from './Card.css';

interface CardState {
  elevated: boolean;
}

interface CardProps
  extends Omit<React.ComponentPropsWithoutRef<'div'>, 'className' | 'style'> {
  $css?: RainbowSprinkles;
  elevated?: boolean;
  render?: RenderProp<CardState>;
  className?: ClassNameProp<CardState>;
  style?: StyleProp<CardState>;
}

export const Card = forwardRef<HTMLElement, CardProps>(
  ({ render, className, style, elevated = false, $css, children, ...restProps }, ref) => {
    return useRenderComponent({
      ref,
      defaultTagName: 'div',
      state: { elevated },
      render,
      className,
      style,
      baseClassName: cardRecipe({ elevated }),
      children,
      restProps,
      cssProps: $css,
    });
  }
);

// 사용
<Card elevated $css={{ padding: '$spacing-400', bgColor: '$background-1' }}>
  카드 내용
</Card>

// render prop으로 요소 교체
<Card render={<article />} elevated>
  article로 렌더링되는 카드
</Card>

// state 기반 className
<Card
  elevated={isHovered}
  className={(state) => state.elevated ? 'card-elevated' : 'card-flat'}
>
  상태 기반 스타일
</Card>

API Reference

useRender

base-ui 공식 문서 참고

Prop

Type