React Native 앱에서 바텀시트 모달 내부의 슬라이더(ProgressBar)를 드래그할 때 다음과 같은 문제가 발생했습니다:
onChange 콜백이 연속적으로 호출되는 것이 문제라고 예상하여 디바운스를 적용해보았습니다.
const handleLightChange = useCallback((value) => {
setLocalLight(value);
setTimeout(() => {
setLight(value);
}, 100);
}, [setLight]);
그러나 호출 빈도만 줄일 뿐 리렌더링을 완전히 하지 않는 것이 아니기 때문에 100ms 후 부모 컴포넌트 상태가 업데이트되어 문제를 근본적으로 해결하지 못했습니다.
여러 타이머가 동시에 실행되어 race condition 가능성이 존재한다고 판단했습니다.
const [isDragging, setIsDragging] = useState(false);
const updateValueFromX = useCallback((x) => {
const newValue = calculateValue(x);
setLocalValue(newValue);
if (!isDragging) {
onChange(newValue);
}
}, [isDragging, onChange]);
//...
const panResponder = PanResponder.create({
onPanResponderGrant: (evt) => {
setIsDragging(true);
},
onPanResponderRelease: () => {
setIsDragging(false);
onChange(localValue);
},
});
드래그 중에는 부모 상태 업데이트를 차단하는 데는 성공했지만, 드래그 종료 시 onChange()가 의존성으로 포함되어 있어서 변경될 때 마다 updateValueFromX 함수가 재생성되어 성능 오버헤드가 있습니다.
또한 panResponder의 핸들러가 계속 변경되어 이벤트 처리가 불안정하다고 생각했습니다.
React의 batch 업데이트로 인해 setIsDragging()과 onChange()의 실행 순서가 랜덤해서 이따금씩 데이터 반영이 제대로 되지 않는 문제가 있었습니다.
const handleLightChange = useCallback((value) => {
setLocalLight(value);
requestAnimationFrame(() => {
setLight(value);
});
}, [setLight]);
requestAnimationFrame은 브라우저 렌더링 파이프라인과 동기화되기 때문에 네이티브 엔진과 불일치가 있을 것이라고 예상했습니다.
이전 프레임 결과가 잠깐 보였다가, 다음 프레임 직전 rAF에서 상태가 다시 적용되며 레이아웃 점프가 발생했습니다.
setLocalLight()는 현재 틱, rAF(() => setLight())는 다음 프레임의 별도 콜백이라 서로 다른 배치로 처리되어 두 번의 렌더가 생겼다고 생각했습니다.const handleLightChange = (value) => {
setLocalLight(value);
setLight(value);
};
슬라이더 드래그 시 onChange 콜백이 연속적으로 호출됨
const updateValueFromX = (x) => {
const newValue = calculateValue(x);
onChange(newValue); // 부모에게 즉시 알림 → 리렌더링 트리거
};
작성 완료 시에만 최종 값 전달
이전 시도들에서는 부모 상태를 실시간으로 업데이트하려다 보니
이것을 해결하기 위해 드래그 중에는 오직 로컬 상태만 사용하고, 사용자가 명시적으로 작성 완료 버튼을 누를 때만 부모 상태를 업데이트하는 전략을 선택했습니다.
ProgressBar 컴포넌트
https://react.dev/learn/queueing-a-series-of-state-updates
https://flaviocopes.com/requestanimationframe