(오픈소스 분석) Radix useControllableState

·

5 min read

@radix-ui/react-use-controllable-state 라이브러리 코드를 살펴보고, controlled, uncontrolled에 대해 알아본다.

용도

useControllableState

  1. Consumer가 상태를 제어하고자 하면, Consumer에게 상태 관리를 위임하고

  2. Consumer가 상태를 제어하지 않고자 하면, 내부적으로 상태를 관리하기 위한 커스텀 훅이다.

Consumer 입장에서 1번의 경우를 controlled(제어), 2번을 uncontrolled(비제어)라고 한다.

Radix에서 제공하는 대부분의 UI는 controlled로 사용할 수도 있고, uncontrolled로 사용할 수 있는데, 이 때 useControllableState훅이 사용된다.

controlled vs uncontrolled

예를들어 Radix Dialog를 controlled로 사용하는 경우와 uncontrolled로 사용하는 경우를 살펴보자.

다음 코드는 Dialog를 uncontrolled로 사용하는 예시다.

// Dialog.stories.tsx
export const Styled = () => (
  <Dialog.Root>
    <Dialog.Trigger className={triggerClass()}>open</Dialog.Trigger>
    <Dialog.Portal>
      <Dialog.Overlay className={overlayClass()} />
      <Dialog.Content className={contentDefaultClass()}>
        <Dialog.Title>Booking info</Dialog.Title>
        <Dialog.Description>Please enter the info for your booking below.</Dialog.Description>
        <Dialog.Close className={closeClass()}>close</Dialog.Close>
      </Dialog.Content>
    </Dialog.Portal>
  </Dialog.Root>
);

Dialog Consumer는 Dialog를 동작시키기 위해 상태를 직접 제어할 필요가 없다. Dialog의 open상태는 내부적으로 알아서 관리된다. Trigger를 누르면 Overlay와 Content가 mount되고, 이 상태에서 Content의 외부를 클릭하거나, Esc키를 누르거나, Close컴포넌트를 클릭하면 Overlay와 Content가 unmount된다.

다음 코드는 Dialog를 controlled로 사용하는 예시다. Dialog Consumer가 Root의 open prop을 undefined가 아닌 다른 값으로 전달하는 순간, 직접 Dialog의 open 상태를 true로 전달해야만 Dialog Content가 mount된다.

// Dialog.stories.tsx
export const Controlled = () => {
  const [open, setOpen] = React.useState(false);
  return (
    <Dialog.Root open={open} onOpenChange={setOpen}>
      <Dialog.Trigger>{open ? 'close' : 'open'}</Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay className={overlayClass()} />
        <Dialog.Content className={contentDefaultClass()}>
          <Dialog.Title>Title</Dialog.Title>
          <Dialog.Description>Description</Dialog.Description>
          <Dialog.Close>close</Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
};

onOpenChange는 실제 open이 변경될 때 호출되는게 아니라, Dialog가 닫힌 상태에서 Trigger를 누르거나, Dialog가 열린 상태에서 Esc를 누르는 등, uncontrolled케이스에서 open을 변경시키는 인터랙션이 발생하는 경우에 호출된다.

이는 input컴포넌트를 controlled로 사용할 때 onChange핸들러가 동작하는 방식과 유사하다. value값은 리액트의 상태 값으로 바인딩되어있지만, input엘리먼트에 포커스를 준 상태에서 타이핑을 하면 onChange가 호출되는, 바로 그 동작이다.

사용 예시

const Checkbox = (props) => {
  const {
    checked,
    defaultChecked,
    onCheckedChange,
    onClick,
    ...restProps,
  } = props;

  // uncontrolled인 경우 useControllableState는 useState와 동일하며,
  // setState가 외부에서 전달한 onCheckedChange를 호출한다는 점만 다르다.
  // controlled인 경우 state는 외부에서 전달한 checked가 되며,
  // setState는 외부에서 전달한 checked를 바꾸지 않고, 
  // onCheckedChange만 호출한다.
  const [state, setState] = useControllableState({
    // prop이 undefined라면 uncontrolled,
    // prop이 undefined가 아니라면 controlled가 된다.
    prop: checked,
    defaultProp: defaultChecked,
    onChange: onCheckedChange,
  });

  return (
    <button 
      type="button" 
      role="checkbox"
      onClick={composeEventHandlers(props.onClick, (event) => {
        setState((prevChecked) => !prevChecked);
      })}
      {...restProps}
    >      
      {state ? <CheckedIcon /> : <UncheckedIcon />}
    </button>
  )
}

Checkbox 컴포넌트는 두 가지 방식(controlled, uncontrolled)으로 사용이 가능하다. uncontrolled로 사용하려면, checked를 전달하지 않으면 된다(undefined). 그러면 내부적으로 state가 처음엔 undefined로 세팅된다. 체크박스를 클릭하면 onClick핸들러가 호출되는데, 이 때 statetrue로 바뀐다. 다시한번 클릭하면 false로 바뀐다.

// uncontrolled
<Checkbox />

defaultChecked는 전달해도 되고 안해도 되지만, uncontrolled로 사용할 때만 써야 한다. input 엘리먼트를 uncontrolled로 사용할 때 defaultValue를 전달할 수 있는 것과 동일하다.

controlled로 사용하려면, checked를 전달해야 한다. 그리고 View를 변경시키기 위해 checked상태를 업데이트하는건 Consumer의 책임이 된다.

const Page = () => {
const [checked, setChecked] = useState(false);
const onKeyDown = (event) => {
    // 알파벳 A키로 checked상태를 업데이트하는 등 
    // Consumer가 상태 업데이트를 알아서 처리해야한다.
    if (event.key === 'A') {
        setChecked((prevChecked) => !prevChecked);
    }
}

return (
    <Checkbox 
      checked={checked}
      onKeyDown={onKeyDown}
      onCheckedChange={setChecked}
    />
}

controlled로 사용하더라도, uncontrolled로 사용할 때 checked를 변경시킬 수 있는 인터랙션(체크버튼 클릭)이 발생한 경우, 바뀔 것으로 예상되는 nextChecked값으로 onCheckedChange가 호출되는데, 이건 Checkbox를 구현하는 사람이 알아서 정의해야 한다.

구현

useUncontrolledState

useControllableState는 내부적으로 useUncontrolledState라는 훅을 사용하고 있으므로 이걸 먼저 살펴본다.

const [state, setState] = useUncontrolledState({
  defaultProp,
  onChange,
})

이 훅은 useState와 동일하지만, setter를 호출할 때 인자로 전달한 onChange가 트리거된다는 점이 다르다.

내부 구현을 살펴보면 알 수 있는데, useEffect훅의 deps배열에 state를 넣어두고, state가 변경될 때 마다 변경된 값으로 onChange를 호출한다.

const useUncontrolledState = ({ defaultProp, onChange }) => {
    const uncontrolledState = useState(defaultProp);
    const [value] = uncontrolledState;

    // useCallbackRef는 onChange의 참조값을 stable하게 만들어주는 훅이다.
    const handleChange = useCallbackRef(onChange);
    const prevValueRef = useRef(value);

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

useControllableState

Consumer가 상태를 제어하는지(controlled) 아닌지(uncontrolled)에 따라 사용 방법이 다르다.

const [state, setState] = useControllableState({
  prop,
  defaultProp,
  onChange,
})

여기서 propundefined로 전달하면 controlled이고, 아니라면 uncontrolled가 된다. (참고)

또한 controlled인지 uncontrolled인지에 따라 반환하는 state가 달라진다.

const useControllableState = ({
  prop, defaultProp, onChange
}) => {
  const [uncontrolledProp, setUncontrolledProp] = useUncontrolledState({
    defaultProp, onChange,
  });
  const isControlled = prop !== undefined;

  // useControllableState훅이 반환하는 state이다.
  // controlled인지 uncontrolled인지에 따라 다르다.
  // controlled라면, 외부에서 전달받은 prop을 그대로 state로서 반환한다.
  const value = isControlled ? prop : uncontrolledProp
  const setValue = (nextValue) => {
    // controlled라면 직접 state를 업데이트하는게 아니라,
    // 바뀔것으로 기대하는 값을 인자로 하여 onChange만 호출한다.
    if (isControlled) {
      const setter = nextValue;
      const value = typeof nextValue === 'function' ? setter(prop) : nextValue;
      if (value !== prop) handleChange(value as T);
    } else {
      // uncontrolled라면 내부적으로 관리하는 state를 업데이트한다.
      setUncontrolledProp(nextValue);
    }
  }

  return [value, setValue] as const;
}

controlled인 경우, 내부적으로 controlled state를 직접 바꾸는 경우는 없다. 바꿀거라면 무조건 Consumer가 바꿔야 한다.

이 훅의 반환값이 [value, setValue]라서 setValue를 통해 controlled state(value)를 바꿀 수 있을거라고 생각할 수 있는데, setValue는 controlled state를 바꾸는 함수가 아니라, uncontrolled state를 바꾸고 prop으로 전달된 onChange를 변경될 값으로 호출하는 함수이다.

정리하면 다음과 같다.

useControllableStatestatesetState를 반환하는 커스텀훅이다. 인자로 prop, defaultProp, onChange를 전달할 수 있다.

controlled로 사용하는 경우(prop !== undefined), state는 useControllableState의 인자로 전달한 prop이 된다.

uncontrolled로 사용하는 경우(prop === undefined), state는 내부에서 호출하는 useUncontrolledState가 반환하는 상태가 된다.

useControllableState에서 반환된 setState를 호출하는 경우도 controlled일 때와 uncontrolled일 때의 동작이 다르다. controlled라면 인자로 전달한 onChange가 업데이트 될 것으로 기대하는 값을 인자로 하여 호출된다. uncontrolled라면 내부에서 호출하는 useUncontrolledState가 반환하는 setter가 호출되며 인자로 전달한 onChange도 업데이트 된 값을 인자로 하여 함께 호출된다.