- Published on
확장성 있는 디자인 시스템 개발하기: Radix useControllableState
- Authors
- Name
- Tails Azimuth
현재 진행하고 있는 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 Primitives
의 useControllableState
를 분석하고 해당 훅을 사용하였습니다.
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
과 상태 변경시 호출 할 콜백 함수인 onChange
를 useUncontrollableState
훅에 인자로 주입합니다.
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는 어떠한 '값'이 아닌 '주소'를 바라보게 됩니다.
그러면 callbackRef
의 current
의 값이 변경될 때 useMemo
는 callbackRef
의 주소를 바라보기 떄문에 계속해서 최신화된 callback(callbackRef.current에 저장된 데이터)를 참조할 수 있게 되는겁니다.
이렇게 되면 아래 코드에서 각각의 handleChange
의 참조는 절대 변하지 않아 useCallback
과 useEffect
에서 불필요한 리렌더링을 일으키지 않습니다. 이러한 방식으로 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 Primitives
의 useControllableState
사용하여 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;
코드 자체가 어렵지 않아서 주석만으로도 쉽게 이해 할 수 있을 것 같네요 ㅎㅎ
느낀점
최고의 선생님이자 코치이자 멘토는 오픈소스.