공통 컴포넌트를 개발할 때, 특히 input이 있는 경우 자주 고민하는 문제가 있다.
바로, 상태를 어디서 컨트롤하게 할지 고민하게 되는 것이다.
공통 컴포넌트 외부에서 컨트롤을 주입하여 외부에서 사용하도록 하는 방법이 있고, 공통 컴포넌트 내부에서 자체적으로 컨트롤되도록 설정하는 방법이 있다.
전자의 경우는 좀 더 다양한 상황에서 사용할 수 있는 컴포넌트를 개발할 수 있다는 장점이 있다 🐥
후자의 경우는 좀 더 선언적으로 컴포넌트를 생성하여 내부 구현이 어떻게 되었든 간에, 기능을 더 손쉽게 외부에서 가져다 사용할 수 있는 장점이 있다 🐣
근본적으로는 컴포넌트에서 상태 관리의 책임을 어디냐 두느냐, 에 대한 질문도 같이 포함되어 있다고 생각한다.
Controlled VS Uncontrolled
상태 소유 | 부모 컴포넌트 | 내부 useState |
제어 방식 | value와 onChange | defaultValue |
예 | <input value={text} onChange={setText} /> | <input defaultValue="hello" /> |
이 정도가 내가 두 가지 컨셉에 대해서 이해하는 부분이었는데, 한번 시간을 내 두 방법, Controlled 컴포넌트와 UnControlled 컴포넌트에 대해서 더 깊게 생각해보도록 했다.

일단, 토글 컴포넌트를 생성하여 이 두가지 컨셉을 통합하여 사용할 수 있게 해봤다 ☀️
type ToggleProps = {
value?: boolean;
defaultValue?: boolean;
onChange?: (next: boolean) => void;
};
function Toggle({ value, defaultValue = false, onChange }: ToggleProps) {
const [internal, setInternal] = useState(defaultValue);
const isControlled = value !== undefined;
const state = isControlled ? value : internal;
const toggle = () => {
const next = !state;
if (!isControlled) {
setInternal(next);
}
onChange?.(next);
};
return <button onClick={toggle}>{state ? 'ON' : 'OFF'}</button>;
}
위의 컴포넌트에서 상태(value) 및 상태의 컨트롤(onChange)은 외부에서 주입할수도, 주입하지 않을 수도 있다.
주입하는 경우에는, 부모 컴포넌트에서 Toggle을 Control할 수 있다.
주입하지 않고 defaultValue만 주입하는 경우, 해당 컴포넌트는 내부의 상태값으로 컨트롤이 되도록 작동된다.
// Controlled
const [on, setOn] = useState(false);
<Toggle value={on} onChange={setOn} />
// Uncontrolled
<Toggle defaultValue={true} />
여기까지는 기존에 많이 봐왔었던 내용이고, UI나 Form 라이브러리를 사용하면서 반복적으로 사용해왔던 패턴이었다.
여기서 조금 리팩토링을 하면 내부의 상태를 정리하는 State를 훅으로 만들어볼 수 있다고 생각했다.
function useControllableState<T>({
value,
defaultValue,
onChange,
}: {
value?: T;
defaultValue: T;
onChange?: (val: T) => void;
}) {
const [uncontrolled, setUncontrolled] = useState(defaultValue);
const isControlled = value !== undefined;
const state = isControlled ? value : uncontrolled;
const set = useCallback((next: T) => {
if (!isControlled) setUncontrolled(next);
onChange?.(next);
}, [isControlled, onChange]);
return [state, set] as const;
}
이렇게 작성된 훅을 실제 이전 컴포넌트에서 사용해보면, 다음과 같이 짧아진 코드를 확인할 수 있다.
function Toggle(props: ToggleProps) {
const [state, setState] = useControllableState({
value: props.value,
defaultValue: props.defaultValue ?? false,
onChange: props.onChange,
});
return <button onClick={() => setState(!state)}>{state ? 'ON' : 'OFF'}</button>;
}
또한, 이렇게 공통화한 useControllableState 훅은 Toggle빼고 다른 input 컴포넌트에서도 사용할 수 있기에, 확장성이 이전보다 더 생겼다고 볼 수 있다.
다만, 다음과 같은 경우에 이 컴포넌트는 정상작동하지 않을 수 있다.
❌ value만 넘기고 onChange는 안 넘겼을 경우
<Toggle value={true} />
❌ 렌더 중간에 controlled → uncontrolled 전환
<Toggle value={someState} /> → <Toggle />
이렇게 구현해보면서 요즘 다른 라이브러리는 어떻게 작성되었을까 찾아보게 되었다.
찾아본 라이브러리중에 배울만한 내용은 radix-ui의 use-controllable-state.tsx가 있다.
primitives/packages/react/use-controllable-state/src/use-controllable-state.tsx at main · radix-ui/primitives
Radix Primitives is an open-source UI component library for building high-quality, accessible design systems and web apps. Maintained by @workos. - radix-ui/primitives
github.com
해당 라이브러리는 좀 더 고도화된 내용들이 있는데, 배포 환경에서의 최적화나 ref를 이용한 상태값 관리 같은 내용들이다.
이런 디테일들도 참고할 수 있을 것 같다.
'React' 카테고리의 다른 글
🧩 Rspack 기반 Module Federation 튜토리얼 (React 기반 MFA) (0) | 2025.06.19 |
---|---|
MDX 설정하기 in Next.js 15 🍝 (Markdown for thecomponent era) (0) | 2025.05.18 |
React를 사용할 때, HTML을 직접 삽입하기 - dangerouslySetInnerHTML 사용 예제 (0) | 2025.04.20 |
React에서 XState 간단 사용 후기 😊 (0) | 2025.02.13 |
React Compiler를 직접 사용해보기 🎶 (with. next 15) (1) | 2024.12.10 |