Published on

확장성 있는 디자인 시스템 개발하기: Radix useControllableState

Authors

현재 진행하고 있는 FitLink 프로젝트에서 디자인 시스템을 개발하며 어떻게 하면 확장성 있게 개발할 수 있을지 고민하였습니다.

디자이너분께서 다른 디자인 시스템과는 차별화된 디자인을 만들어주셨고, 우리는 그 요구사항에 맞게 Headless UI를 지원하는 Radix Primitives를 사용해 우리의 입 맛에 맞게 커스텀하여 대부분의 디자인 시스템을 구현하였습니다.

하지만 우리의 디자인 시스템 중, Radix Primitives에서 지원하지 않는 컴포넌트들은 어떤식으로 설계를 해야 확장성 있는 컴포넌트로 만들 수 있을지 정말 많은 고민을 하게 되었습니다.

그렇게 수 많은 고민을 하던 중 확장성 있는 개발을 위한 방법 중 하나로, '제어/비제어를 상위 레벨에서 핸들링 할 수 있게 해주면 어떨까?' 라는 고민을 하게 되었고, 그 과정을 지금부터 보도록 하겠습니다.

제어/비제어

우선 이야기에 앞서 제어비제어에 대해 이해해야합니다. 아마 React-Hook-Form 라이브러리를 써보신 분들은 제어/비제어 라는 단어에 익숙 하실 겁니다.

최신 리액트 공식문서를 보면 제어/비제어에 대해 아래와 같이 설명하고 있습니다.

It is common to call a component with some local state “uncontrolled”. For example, the original Panel component with an isActive state variable is uncontrolled because its parent cannot influence whether the panel is active or not.

In contrast, you might say a component is “controlled” when the important information in it is driven by props rather than its own local state. This lets the parent component fully specify its behavior. The final Panel component with the isActive prop is controlled by the Accordion component.

Uncontrolled components are easier to use within their parents because they require less configuration. But they’re less flexible when you want to coordinate them together. Controlled components are maximally flexible, but they require the parent components to fully configure them with props.

In practice, “controlled” and “uncontrolled” aren’t strict technical terms—each component usually has some mix of both local state and props. However, this is a useful way to talk about how components are designed and what capabilities they offer.

When writing a component, consider which information in it should be controlled (via props), and which information should be uncontrolled (via state). But you can always change your mind and refactor later.

위 내용을 조금 간략하게 설명하자면 아래 내용과 같습니다.

  • 비제어형 컴포넌트: 컴포넌트 내부의 로컬 상태로 동작하며 부모 컴포넌트가 해당 컴포넌트의 상태를 직접 제어할 수 없기 때문에 '비제어'라고 불림
  • 제어형 컴포넌트: 부모 컴포넌트가 상태를 props로 전달하여 완전히 제어할 수 있음.

정리하자면 하위 컴포넌트를 상위에서 상태를 통해 제어할 수 있다면 제어 상태, 제어할 수 없다면 비제어 상태라고 라고 정의할 수 있을 것 같습니다(더욱 명확한 정의를 내려주실 분 찾습니다)

위 개념을 FitLink 프로젝트의 디자인 시스템중 하나인 Stepper 컴포넌트를 비제어 방식으로 아주 심플하게 작성하여 예를 들어보겠습니다. 우선 Stepper 컴포넌트가 어떻게 생겼는지 보시고 코드를 보시면 이해가 빠를 것 같아요.

// 비제어 방식
type StepperProps = {
  onChangeStep: (step: number) => void
}

function Stepper({ onChangeStep }: StepperProps) {
  const [step, setStep] = useState(0);

  const handleClickDecrease = () => {
    setStep((prev) => prev - 1)
  }

  const handleClickIncrease = () => {
    setStep((prev) => prev + 1)
  }
  
  useEffect(() => {
    onChangeStep(step);
  }, [step])

  return (
    <div className={cn("bg-background-sub4 text-text-primary flex h-[38px] w-[138px] items-center justify-between rounded-[10px] border px-2 py-5", className)}>
      <button className="flex h-6 w-6 items-center justify-center" onClick={handleClickDecrease}>
        <RemoveOutlined aria-label="decrease" />
      </button>
      <div className="text-text-sub5 bg-background-sub5 flex h-[31px] w-[55px] items-center justify-center rounded-[5px]">
        {step}
      </div>
      <button className="flex h-6 w-6 items-center justify-center" onClick={handleClickIncrease}>
        <AddOutlined aria-label="increase" />
      </button>
    </div>
  )
}

위와 같이 작성을 하게되면, 상태는 Stepper 컴포넌트 내부적으로 관리되며 상위 컴포넌트에서는 Stepper 컴포넌트의 상태를 직접 제어할 수 없게 됩니다. 상위에서 할 수 있는 것 이라곤, 핸들러를 주입해서 step이 변화될 때마다 그 값을 핸들링 하는 것 뿐입니다.

이번엔 제어 방식으로 Stepper 컴포넌트를 아주 심플하게 작성하여 예를 들어보겠습니다.

// 제어 방식
type StepperProps = {
  step: number;
  onChangeStep: (step: number) => void
}

function Stepper({ step, onChangeStep }: StepperProps) {
  const handleClickDecrease = () => {
	onChangeStep(step - 1)
  }

  const handleClickIncrease = () => {
	onChangeStep(step + 1)
  }

  return (
    <div className={cn("bg-background-sub4 text-text-primary flex h-[38px] w-[138px] items-center justify-between rounded-[10px] border px-2 py-5", className)}>
      <button className="flex h-6 w-6 items-center justify-center" onClick={handleClickDecrease}>
        <RemoveOutlined aria-label="decrease" />
      </button>
      <div className="text-text-sub5 bg-background-sub5 flex h-[31px] w-[55px] items-center justify-center rounded-[5px]">
        {step}
      </div>
      <button className="flex h-6 w-6 items-center justify-center" onClick={handleClickIncrease}>
        <AddOutlined aria-label="increase" />
      </button>
    </div>
  )
}

위 코드는 상위에서 step이라는 상태와 step을 변경할 onChangeStep 핸들러를 주입받고 있습니다. 이렇게 설계하면 상위에서 상태와 상태의 초기 값을 정의하고, 상태를 변경하기 위한 로직을 작성하여 Stepper 컴포넌트로 내려 줄겁니다.

이러한 방식은 상위에서 하위 컴포넌트를 완전히 제어할 수 있게 됩니다.

이렇게 제어/비제어는 각기 다른 장단점을 가지고 있습니다. 제어 컴포넌트의 경우 더 유연하고 부모 컴포넌트에서 다양한 상태를 쉽게 조정할 수 있지만, 설정할 props가 많아지면서 복잡해질 수 있고, 비제어 컴포넌트의 경우 설정할 것이 적어 더욱 사용하기 쉬워지죠.

Stepper 컴포넌트는 정말 쉬운 단일 동작을 하고 있으며, 저는 이 컴포넌트를 필요에 따라 상위에서 제어하지 않고, Stepper 컴포넌트의 기본 동작에 따른 상태를 활용 하거나, 상위에서 직접 제어하여 다른 컴포넌트와 함께 복합적으로 사용할 수 있도록 설계하고 싶었습니다.

그러기 위해서는 해당 컴포넌트는 상위에서 어떤 Props를 넘겨주냐에 따라 제어 컴포넌트가 되기도, 비제어 컴포넌트가 되기도 해야했습니다.

저는 이에 대한 해답으로 Radix PrimitivesuseControllableState를 분석하고 해당 훅을 사용하였습니다.

useControllableState

해당 훅은 상위에서 상태를 제어하고자 하면 상위에게 상태 관리에 대한 권한을 위임(제어)하고, 상위에서 상태를 제어하지 않으면 하위 컴포넌트 내부적으로 상태를 관리(비제어)하기 위한 커스텀 훅입니다. Radix에서는 대부분의 디자인 시스템에서 해당 훅이 사용되고 있어요.

Radix Primitives의 디자인 시스템 API를 보면 대부분 아래와 같이 제공을 하고 있습니다.

  • defaultChecked의 경우 비제어 컴포넌트로 사용할 때 쓰이며, 비제어 컴포넌트 방식에서 기본값을 정의할 수 있습니다. 물론 옵셔널한 타입이기 때문에 주입하지 않아도 됩니다.
  • checked의 경우 제어 컴포넌트로 사용할 때 쓰이며, onCheckChange와 함게 사용해야 합니다.
  • onCheckedChange의 경우 제어 비제어에서 모두 사용할 수 있으며, 스위치 컴포넌트의 상태가 변경될 때 호출되는 이벤트 핸들러라고 보시면 됩니다.

Radix Primitives는 어떻게 이러한 인터페이스를 설계할 수 있었는지 Switch 컴포넌트의 내부 동작 일부를 살펴보도록 하겠습니다.

/*
 checked, defaultChecked, onCheckedChange 와 useControllableState의 흐름만 집중해서 보시면 될 것 같아요
 */

const Switch = React.forwardRef<SwitchElement, SwitchProps>(
  (props: ScopedProps<SwitchProps>, forwardedRef) => {
    const {
      __scopeSwitch,
      name,
      checked: checkedProp,
      defaultChecked,
      required,
      disabled,
      value = 'on',
      onCheckedChange,
      form,
      ...switchProps
    } = props;
    const [button, setButton] = React.useState<HTMLButtonElement | null>(null);
    const composedRefs = useComposedRefs(forwardedRef, (node) => setButton(node));
    const hasConsumerStoppedPropagationRef = React.useRef(false);
    const isFormControl = button ? form || !!button.closest('form') : true;
    
    // Props로 받아온 checked, defaultChecked, onCheckedChange를 useControllableState의 인자로 주입
    const [checked = false, setChecked] = useControllableState({
      prop: checkedProp,
      defaultProp: defaultChecked,
      onChange: onCheckedChange,
    });

    // useControllableState에서 반환한 state와 setter 함수를 각각 SwitchProvider와 Primitive.button의 onClick 메서드 내부에 주입
    return (
      <SwitchProvider scope={__scopeSwitch} checked={checked} disabled={disabled}>
        <Primitive.button
          type="button"
          role="switch"
          aria-checked={checked}
          aria-required={required}
          data-state={getState(checked)}
          data-disabled={disabled ? '' : undefined}
          disabled={disabled}
          value={value}
          {...switchProps}
          ref={composedRefs}
          onClick={composeEventHandlers(props.onClick, (event) => {
            setChecked((prevChecked) => !prevChecked);
            if (isFormControl) {
              hasConsumerStoppedPropagationRef.current = event.isPropagationStopped();
              if (!hasConsumerStoppedPropagationRef.current) event.stopPropagation();
            }
          })}
        />
        {isFormControl && (
          <BubbleInput
            control={button}
            bubbles={!hasConsumerStoppedPropagationRef.current}
            name={name}
            value={value}
            checked={checked}
            required={required}
            disabled={disabled}
            form={form}
            style={{ transform: 'translateX(-100%)' }}
          />
        )}
      </SwitchProvider>
    );
  }
);

Switch.displayName = SWITCH_NAME;

Switch 컴포넌트는 여러 Props 중 checked, defaultChecked, onCheckedChange을 주입 받고 있으며 해당 props는 useControllableState의 내부 인자로 주입되게 됩니다. 그리고 useControllableState는 state와 setter 함수를 리턴시키며 이 두 리턴 값을 SwitchProvider와 내부의 Primitive.button onClick 메서드에 각각 주입하고 있습니다.

그렇다면 useControllableState는 어떻게 동작하길래 다시 state와 setter 함수를 리턴하는지 내부 동작을 살펴 보겠습니다.

import * as React from "react";

import useCallbackRef from "./useCallbackRef";

type UseControllableStateParams<T> = {
  prop?: T | undefined;
  defaultProp?: T | undefined;
  onChange?: (state: T) => void;
};

type SetStateFn<T> = (prevState?: T) => T;

export default function useControllableState<T>({
  prop,
  defaultProp,
  onChange = () => {},
}: UseControllableStateParams<T>) {
  const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({ defaultProp, onChange });
  const isControlled = prop !== undefined;
  const value = isControlled ? prop : uncontrolledProp;
  const handleChange = useCallbackRef(onChange);

  const setValue: React.Dispatch<React.SetStateAction<T | undefined>> = React.useCallback(
    (nextValue) => {
      if (isControlled) {
        const setter = nextValue as SetStateFn<T>;
        const value = typeof nextValue === "function" ? setter(prop) : nextValue;
        if (value !== prop) handleChange(value as T);
      } else {
        setUncontrolledProp(nextValue);
      }
    },
    [isControlled, prop, setUncontrolledProp, handleChange],
  );

  return [value, setValue] as const;
}

function useUncontrolledState<T>({
  defaultProp,
  onChange,
}: Omit<UseControllableStateParams<T>, "prop">) {
  const uncontrolledState = React.useState<T | undefined>(defaultProp);
  const [value] = uncontrolledState;
  const prevValueRef = React.useRef(value);
  const handleChange = useCallbackRef(onChange);

  React.useEffect(() => {
    if (prevValueRef.current !== value) {
      handleChange(value as T);
      prevValueRef.current = value;
    }
  }, [value, prevValueRef, handleChange]);

  return uncontrolledState;
}

위 코드의 인터페이스부터 설명을 하자면

  • prop: 외부에서 제어하는 값
  • defaultProp: 내부에서 사용할 초기값
  • onChange: 상태가 변경될 때 호출되는 콜백 함수

위 세가지로 이루어져 있으며 우선 비제어로 활용되는 defaultProp과 상태 변경시 호출 할 콜백 함수인 onChangeuseUncontrollableState 훅에 인자로 주입합니다.

function useUncontrolledState<T>({
  defaultProp,
  onChange,
}: Omit<UseControllableStateParams<T>, "prop">) {
  const uncontrolledState = React.useState<T | undefined>(defaultProp);
  const [value] = uncontrolledState;
  const prevValueRef = React.useRef(value);
  const handleChange = useCallbackRef(onChange);

  React.useEffect(() => {
    if (prevValueRef.current !== value) {
      handleChange(value as T);
      prevValueRef.current = value;
    }
  }, [value, prevValueRef, handleChange]);

  return uncontrolledState;
}

useUncontrollableState 훅은 비제어 상태 즉, 내부 상태를 관리하는 훅으로, 비제어 컴포넌트의 내부 상태가 변경될 때마다 onChange 콜백 함수를 호출합니다. 이를 통해 상태 변경을 부모 컴포넌트에 전달 할 수 있죠.

이때, useRef를 사용하여 이전 값을 저장하고, 현재 값과 비교하여 값이 변경된 경우에만 onChange 콜백을 호출합니다. 추가로 useCallbackRef를 사용하여 onChange 콜백 함수의 최신 버전을 유지 하는데요, onChange가 외부에서 전달되므로, 이를 참조할 때 최신 버전의 onChange를 사용하기 위해 useCallbackRef를 사용하여 콜백 참조가 최신 상태로 유지되도록 합니다.

그렇다면 useCallbackRef는 어떻게 해서 최신 버전의 onChange를 사용할 수 있도록 돕는 걸가요?

import * as React from "react";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default function useCallbackRef<T extends (...args: any[]) => any>(
  callback: T | undefined,
): T {
  const callbackRef = React.useRef(callback);

  React.useEffect(() => {
    callbackRef.current = callback;
  });

  return React.useMemo(() => ((...args) => callbackRef.current?.(...args)) as T, []);
}

useCallbackRef는 최신 콜백을 항상 참조하면서도, 참조 자체는 변하지 않는 함수를 반환합니다.

ref를 사용하여 최신 콜백을 저장하고, callback의 참조가 변경되어 리렌더링 될 때마다 ref에 최신 콜백을 업데이트합니다. 그리고 useMemo를 통해 메모이제이션 된 함수를 반환하죠.

useMemo는 최초 한 번만 생성됨에도 불구하고 계속해서 최신 callback을 참조 하는데요, 그 이유는 ref를 사용하고 있기 때문입니다.

useMemo내의 콜백 함수가 고정된 참조(callbackRef)를 유지 하고 있지만, callbackRef는 참조형 데이터 이기 때문에 useMemo는 어떠한 '값'이 아닌 '주소'를 바라보게 됩니다.

그러면 callbackRefcurrent의 값이 변경될 때 useMemocallbackRef의 주소를 바라보기 떄문에 계속해서 최신화된 callback(callbackRef.current에 저장된 데이터)를 참조할 수 있게 되는겁니다.

이렇게 되면 아래 코드에서 각각의 handleChange의 참조는 절대 변하지 않아 useCallbackuseEffect에서 불필요한 리렌더링을 일으키지 않습니다. 이러한 방식으로 Radix는 성능 최적화를 이루고 있었더라구요.

// useControllableState의 제어 모드 코드 일부 23번 라인
  const setValue: React.Dispatch<React.SetStateAction<T | undefined>> = React.useCallback(
    (nextValue) => {
      if (isControlled) {
        const setter = nextValue as SetStateFn<T>;
        const value = typeof nextValue === "function" ? setter(prop) : nextValue;
        if (value !== prop) handleChange(value as T);
      } else {
        setUncontrolledProp(nextValue);
      }
    },
    [isControlled, prop, setUncontrolledProp, handleChange],
  );

// useControllableState의 비제어 모드 코드 일부 48번 라인
React.useEffect(() => {
  if (prevValueRef.current !== value) {
    handleChange(value as T);
    prevValueRef.current = value;
  }
}, [value, prevValueRef, handleChange]);

다시 본론으로 돌아가 useUncontrollableState 훅은 상태와 상태를 변환하는 setter 함수를 반환하여 아래 코드에서 [uncontrolledProp, setUncontrolledProp]를 반환합니다.

type UseControllableStateParams<T> = {
  prop?: T | undefined;
  defaultProp?: T | undefined;
  onChange?: (state: T) => void;
};

type SetStateFn<T> = (prevState?: T) => T;

export default function useControllableState<T>({
  prop,
  defaultProp,
  onChange = () => {},
}: UseControllableStateParams<T>) {
  const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({ defaultProp, onChange });
  const isControlled = prop !== undefined;
  const value = isControlled ? prop : uncontrolledProp;
  const handleChange = useCallbackRef(onChange);

  const setValue: React.Dispatch<React.SetStateAction<T | undefined>> = React.useCallback(
    (nextValue) => {
      if (isControlled) {
        const setter = nextValue as SetStateFn<T>;
        const value = typeof nextValue === "function" ? setter(prop) : nextValue;
        if (value !== prop) handleChange(value as T);
      } else {
        setUncontrolledProp(nextValue);
      }
    },
    [isControlled, prop, setUncontrolledProp, handleChange],
  );

  return [value, setValue] as const;
}

이어서 prop이 주입 되었을 경우 isControlled가 true가 되어 value에는 상위에서 주입한 상태가 대입이 되고, handleChange에는 상태가 변경하면 실행될 콜백함수가 대입이 되게 됩니다.

이후 setValue(상태를 업데이트하는 함수 -> controlled와 unControlled를 구분하려 처리)에서는 isControlled(제어상태)를 확인하고, 제어 상태가 맞다면 nextValue 형태(함수 OR Not 함수)에 따라 setter 함수의 실행 결과 또는 nextValue를 value에 주입합니다. 그리고 이전 상태와 다르다면 handleChange를 변경될 값으로 호출해 줍니다.

여기서 한가지 주의 할 점은 상위에서 handleChange는 직접 상태를 변경하는 함수는 아닙니다. handleChange는 자신이 받은 인자를 통해 상위에게 상태 변화를 알리기 위해 onChange에 인자를 넘겨 호출 시켜줄 뿐 실제로 상태를 변경하는 함수는 onChange 함수입니다.

반대로 비제어 상태라면 setUncontrolledProp(setter 함수)를 바로 호출하여 제어 상태와 마찬가지로 변경될 값으로 onChange 함수를 호출합니다.

마지막으로 상태와 setter 함수를 value와 setValue로 리턴해줍니다.

이러한 Radix PrimitivesuseControllableState 사용하여 Stepper 컴포넌트를 사용하는 상위에서 제어/비제어를 유연하게 사용할 수 있도록 확장성 있는 설계를 할 수 있었습니다.

useControllableState를 사용한 Stepper 컴포넌트 구현

Stepper 컴포넌트의 요구사항은 이렇습니다. 

  • -, + 버튼을 통해 value를 증가 시킬 수 있어야 함
  • 증감 범위의 기본 값은 1
  • 증감의 범위를 주입 받을 수 있어야 함
  • 비제어 방식을 통해 내부적으로 변경되는 value를 상위에서 얻을 수 있어야 함
  • 제어 방식을 통해 상위에서 하위에 초기값을 전달 할 수 있고 제어된 상태를 통해 다른 로직도 핸들링 할 수 있어야 함

위 요구사항을 useControllableState를 활용하여 Stepper 컴포넌트에 어떻게 반영했는지 코드로 확인해 보겠습니다.

"use client";

import { AddOutlined, RemoveOutlined } from "@mui/icons-material";

import useControllableState from "../hooks/useControllableState";
import { cn } from "../lib/utils";

type StepperProps = {
  defaultValue?: number; // 비제어 상태의 기본 값 주입
  value?: number; // 제어 상태 주입
  step?: number; // 증감 단위
  onChangeValue: (value: number) => void; // 상태 변경시 호출 될 함수
  className?: string;
};

const DEFAULT_VALUE = 0;
const DEFAULT_STEP = 1;

function Stepper({
  defaultValue = DEFAULT_VALUE,
  value,
  step = DEFAULT_STEP,
  onChangeValue,
  className,
}: StepperProps) {
  // 외부에서 상태를 주입 받았다면 제어 형태로 작동, 상태를 주입 받지 않았다면 비제어 형태로 작동 onChange는 언제나 상태 변경에 따라 상위에서 감지 가능
  const [internalValue = defaultValue, setInternalValue] = useControllableState({
    prop: value,
    onChange: onChangeValue,
    defaultProp: defaultValue,
  });
 
  // step 감소
  const handleClickDecrease = () => {
    setInternalValue(internalValue - step);
  };

  // step 증가
  const handleClickIncrease = () => {
    setInternalValue(internalValue + step);
  };

  return (
    <div
      className={cn(
        "bg-background-sub4 text-text-primary flex h-[38px] w-[138px] items-center justify-between rounded-[10px] border px-2 py-5",
        className,
      )}
    >
      <button className="flex h-6 w-6 items-center justify-center" onClick={handleClickDecrease}>
        <RemoveOutlined aria-label="decrease" />
      </button>
      <div className="text-text-sub5 bg-background-sub5 flex h-[31px] w-[55px] items-center justify-center rounded-[5px]">
        {internalValue}
      </div>
      <button className="flex h-6 w-6 items-center justify-center" onClick={handleClickIncrease}>
        <AddOutlined aria-label="increase" />
      </button>
    </div>
  );
}

export default Stepper;

코드 자체가 어렵지 않아서 주석만으로도 쉽게 이해 할 수 있을 것 같네요 ㅎㅎ

느낀점

최고의 선생님이자 코치이자 멘토는 오픈소스.