디자인 시스템의 Slider, RangedSlider, MultiRangedSlider를 리팩토링하면서 단순히 “값을 props로 받고 onChange로 올리면 controlled component지”라고 생각했다.
그런데 실제로 thumb를 움직이자 컴포넌트가 무한 렌더링에 빠졌다. 😵💫
문제는 controlled component 자체가 아니라, 값 동기화 방향을 구분하지 않은 것이었다.
React에서 input은 value를 props로 넘기면 controlled input이 된다. 값의 source of truth는 컴포넌트 내부가 아니라 부모 state다. React 공식 문서에서도 <input>의 value를 제어하면 React state와 동기화되는 controlled input이 된다고 설명한다.
Slider도 마찬가지다.
<Slider
value={value}
handleChangeValue={setValue}
/>
이 구조에서 부모는 value를 내려주고, Slider는 사용자 조작이 발생하면 handleChangeValue로 새 값을 올린다. 여기까지만 보면 문제가 없어 보인다.
초기 구현은 대략 이런 흐름이었다.
const [handle, setHandle] = useState(value);
useEffect(() => {
handleChangeValue(handle);
}, [handle]);
useEffect(() => {
setHandle(value);
}, [value]);
겉으로 보면 자연스럽다.
하지만 이 구조는 양방향 동기화다.
사용자 드래그
-> setHandle
-> useEffect(handle)
-> handleChangeValue
-> 부모 setState
-> value prop 변경
-> useEffect(value)
-> setHandle
-> useEffect(handle)
-> ...
특히 같은 state를 서로 다른 Slider가 공유하거나, min/max/step이 다른 Slider가 같은 value를 바라보면 문제가 더 쉽게 터진다. 한쪽 Slider는 값을 정규화해서 부모에 올리고, 다른 Slider는 다시 자기 기준으로 정규화해서 올린다. 그러면 값이 계속 왕복한다.
React의 useEffect는 렌더링 이후 외부 시스템과 동기화하기 위한 Hook이다. 공식 문서에서도 Effect는 렌더링 결과를 외부 시스템과 맞추는 용도라고 설명한다.
여기서 내가 놓친 점은, 부모와 동기화하는 값 변경과 사용자 조작으로 발생한 값 변경을 같은 effect에서 처리했다는 것이다.
핵심은 이거였다.
props 때문에 내부 state가 바뀐 경우에는 부모 callback을 다시 호출하지 않는다.
사용자 조작으로 내부 state가 바뀐 경우에만 부모 callback을 호출한다.
그래서 ref를 하나 두었다.
const shouldNotifyChangeRef = useRef(false);
const lastEmittedValueRef = useRef(handle);
사용자가 thumb를 드래그하거나 키보드로 값을 바꿀 때만 플래그를 켠다.
const calculateThumbPosition = (...) => {
const nextValue = getSliderValueFromPosition(position, { min, max, step });
if (nextValue === handle) return;
shouldNotifyChangeRef.current = true;
setHandle(nextValue);
};
그리고 handle 변경 effect에서는 이 플래그를 확인한다.
useEffect(() => {
if (!shouldNotifyChangeRef.current) return;
shouldNotifyChangeRef.current = false;
if (lastEmittedValueRef.current === handle) return;
lastEmittedValueRef.current = handle;
handleChangeValue(handle);
}, [handle, min, max]);
반대로 props로 내려온 value를 내부 state에 맞출 때는 부모에게 다시 알리지 않는다.
useEffect(() => {
const normalizedValue = normalizeSliderValue(value, { min, max, step });
setHandle((prev) => {
if (prev === normalizedValue) return prev;
return normalizedValue;
});
}, [value, min, max, step]);
이렇게 하면 데이터 흐름이 명확해진다.
사용자 조작 -> 내부 state 변경 -> 부모 callback 호출
부모 props 변경 -> 내부 state 동기화 -> callback 호출 안 함
RangedSlider는 min, max 두 값을 가진다.
MultiRangedSlider는 values 배열을 handle 위치 배열로 변환한다.
예를 들어 3 range에서는 이런 관계가 있다.
values = [30, 35, 35]
handles = [30, 65]
여기서도 같은 문제가 생겼다.
handles 변경
-> values 계산
-> 부모 onChange
-> values prop 변경
-> handles 재계산
-> 다시 onChange
해결 방식은 동일했다.
setState도 하지 않는다.const isSameValues = (prev: number[], next: number[]) =>
prev.length === next.length && prev.every((value, idx) => value === next[idx]);
배열은 참조가 매번 새로 만들어질 수 있으므로, 단순히 prev === next로는 부족했다. 실제 값 비교가 필요했다.
controlled component는 “부모가 값을 들고 있다”는 의미만으로 끝나지 않는다.
실제로는 값의 변경 방향을 설계하는 일에 가깝다.
이번 Slider 작업에서 배운 기준은 이렇다.
onChange로 올린다.setState를 하지 않는다.useEffect는 “모든 state 변경에 반응하는 곳”이 아니라 “정말 동기화가 필요한 곳”에만 쓴다.처음에는 Slider의 무한 루프가 단순한 dependency array 문제라고 생각했다.
하지만 실제 원인은 dependency가 아니라 동기화 책임이 섞인 구조였다.
UI 컴포넌트는 보기보다 상태 흐름이 복잡하다. 특히 Slider처럼 내부 인터랙션이 있고, 동시에 외부에서 controlled value를 받는 컴포넌트는 더 그렇다.
이번 작업 이후로 controlled component를 만들 때는 먼저 이 질문을 하게 됐다.
이 state 변경은 사용자 조작에서 온 것인가, props 동기화에서 온 것인가?
이 질문 하나가 무한 루프를 막아줬다.
<input> 공식 문서useEffect 공식 문서
슬라이더 고수시군요