@gorhom/bottom-sheet의 onChange, backdropComponent, snapPoints 같은 props에 메모이제이션된 값/함수를 넘겨야 하는 이유와, 그렇지 않았을 경우 발생하는 일을 정리해보자.
| Prop | 처리 | 인라인 전달 시 결과 |
|---|---|---|
snapPoints (배열) | useMemo 또는 모듈 스코프 상수 | 내부 worklet/effect 재실행, snap 위치 재계산 |
onChange / onAnimate / onClose (함수) | useCallback | 내부 reaction 재구독, worklet 재등록, stale closure 위험 |
backdropComponent / handleComponent 등 (컴포넌트) | useCallback | 언마운트 → 리마운트 → 시각적 깜박임 |
animationConfigs (객체) | useMemo | 애니메이션 설정 재적용 |
initialSnapIndex 등 primitive | 불필요 | 참조 비교 무관 |
공식 README의 모든 예제가 useMemo와 useCallback 패턴을 따른다.
JavaScript에서 객체·배열·함수는 참조로 비교된다.
[1] === [1] // false
{ a: 1 } === { a: 1 } // false
(() => {}) === (() => {}) // false
@gorhom/bottom-sheet는 받은 props를 내부에서 useMemo·useCallback·useDerivedValue·useAnimatedReaction의 deps 배열로 재사용한다 (실제 소스 위치는 본문 "prop별 상세 분석" 섹션 참조). deps는 참조 비교(Object.is)로 변경을 감지하므로, 부모가 리렌더될 때마다 값은 동일해도, 새 참조가 들어오면 라이브러리 내부 로직이 매번 재실행된다.
부모 컴포넌트의 리렌더는 흔하다
useState가 바뀌면 리렌더이때 바텀시트는 그대로지만 내부적으로 불필요한 계산·재구독·재마운트가 반복된다.
snapPoints — 배열// ❌ 인라인 배열
<BottomSheet snapPoints={['25%', '50%', '90%']} />;
// ✅ useMemo
const snapPoints = useMemo(() => ['25%', '50%', '90%'], []);
<BottomSheet snapPoints={snapPoints} />;
// ✅✅ 값이 고정이면 컴포넌트 외부의 모듈 스코프 상수가 더 효율적
const SNAP_POINTS = ['25%', '50%', '90%'];
<BottomSheet snapPoints={SNAP_POINTS} />;
라이브러리 실제 소스 (node_modules/@gorhom/bottom-sheet/src/hooks/useAnimatedDetents.ts):
// useAnimatedDetents.ts — 부모가 넘긴 snapPoints를 받아 정규화
export const useAnimatedDetents = (
detents: BottomSheetProps['snapPoints'], // ← 부모의 snapPoints prop
layoutState,
enableDynamicSizing,
maxDynamicContentSize,
detached,
$modal,
bottomInset = 0,
) => {
const state = useDerivedValue<DetentsState>(() => {
const { containerHeight, handleHeight, contentHeight } = layoutState.get();
if (containerHeight === INITIAL_LAYOUT_VALUE) return {};
const _detents = detents ? ('value' in detents ? detents.value : detents) : [];
// worklet 내부에서 직접 사용 — '25%' → 픽셀 변환
let _normalizedDetents = _detents.map(snapPoint =>
normalizeSnapPoint(snapPoint, containerHeight),
) as number[];
// ... (dynamic sizing 계산 등 생략)
return { detents: _normalizedDetents, highestDetentPosition, closedDetentPosition };
}, [
detents, // ⚠️ snapPoints가 useDerivedValue의 deps에 그대로 들어감
layoutState,
enableDynamicSizing,
maxDynamicContentSize,
detached,
$modal,
bottomInset,
]);
return state;
};
그리고 BottomSheet.tsx에서 받은 prop을 그대로 이 훅에 전달한다.
// BottomSheet.tsx:197
const animatedDetentsState = useAnimatedDetents(
_providedSnapPoints, // ← props.snapPoints
animatedLayoutState,
enableDynamicSizing,
// ...
);
추가로 정규화된 detents 변경을 감시하는 useAnimatedReaction이 별도로 등록되어 있다.
// BottomSheet.tsx:1570 — snapPoints 변경 시 시트 위치 재평가
useAnimatedReaction(
() => animatedDetentsState.get().detents,
(result, previous) => {
if (JSON.stringify(result) === JSON.stringify(previous) && isAnimatedOnMount.value) return;
if (!isLayoutCalculated.value) return;
evaluatePosition(ANIMATION_SOURCE.SNAP_POINT_CHANGE);
},
[isLayoutCalculated, isAnimatedOnMount, animatedDetentsState], // ⚠️ deps
);
인라인 전달 시 일어나는 일:
['25%', '50%', '90%'] 새 배열 인스턴스 생성useAnimatedDetents 호출 시 detents 인자가 새 참조useDerivedValue의 deps 배열 비교에서 변경 감지 → worklet 재구성normalizeSnapPoint)가 모든 원소에 대해 재실행 (퍼센트 → 픽셀 변환)useAnimatedReaction의 reaction worklet도 재평가원소 개수나 값이 작다고 비용이 0이 되지는 않는다. snapPoints={[1]}처럼 원소 하나여도 재계산이 트리거되는 메커니즘은 동일하다.
시트가 열려있는 상태에서 부모가 자주 리렌더되면, 미세한 jank가 누적되거나 드물게 시트 위치가 살짝 튈 수 있다.
onChange, onAnimate, onClose — 콜백 함수// ❌ 인라인 함수
<BottomSheet onChange={index => console.log(index)} />;
// ✅ useCallback
const handleChange = useCallback((index: number) => {
console.log(index);
}, []);
<BottomSheet onChange={handleChange} />;
라이브러리 실제 소스 (node_modules/@gorhom/bottom-sheet/src/components/bottomSheet/BottomSheet.tsx:440-468):
// BottomSheet.tsx — props.onChange를 _providedOnChange로 받아 내부에서 useCallback으로 래핑
const handleOnChange = useCallback(
function handleOnChange(index: number, position: number) {
if (__DEV__) {
print({ component: 'BottomSheet', method: 'handleOnChange', /* ... */ });
}
if (!_providedOnChange) return;
const { dynamicDetentIndex } = animatedDetentsState.get();
_providedOnChange(
index,
position,
index === dynamicDetentIndex ? SNAP_POINT_TYPE.DYNAMIC : SNAP_POINT_TYPE.PROVIDED,
);
},
[_providedOnChange, animatedDetentsState], // ⚠️ 부모가 넘긴 onChange가 deps에 그대로
);
이 handleOnChange는 다시 시트 내부 컨텍스트(internalContextVariables)와 애니메이션 콜백 경로에서 참조된다. 즉, 부모가 매 렌더마다 새 함수 참조를 넘기면 handleOnChange 자체가 새로 만들어지고, 그것을 deps로 잡고 있는 후속 useMemo / useEffect / useAnimatedReaction이 줄줄이 재구성된다.
인라인 전달 시 일어나는 일:
handleOnChange의 useCallback deps 변경 → 새 함수 참조stale closure 위험:
const [count, setCount] = useState(0);
// 인라인 함수가 매번 새로 생성되긴 하지만,
// 부모 리렌더가 발생하지 않는 시점에 worklet에 등록된 함수는
// 이전 count 값을 capture한 채로 남아있을 수 있음
<BottomSheet
onChange={index => {
console.log(count); // 항상 최신 count? 보장되지 않음
}}
/>;
useCallback을 적절한 deps와 함께 사용하면 의도한 시점의 값을 안정적으로 캡처할 수 있다.
backdropComponent, handleComponent, backgroundComponent, footerComponent — 가장 치명적// ❌ 인라인 함수형 컴포넌트
<BottomSheet backdropComponent={props => <BottomSheetBackdrop {...props} />} />;
// ✅ useCallback으로 안정화
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} />
),
[],
);
<BottomSheet backdropComponent={renderBackdrop} />;
왜 이게 가장 큰 문제인가?
React Reconciliation의 핵심 규칙:
컴포넌트 타입이 다르면(
prevType !== nextType), React는 이전 트리 전체를 버리고 새로 마운트한다.
함수 컴포넌트는 함수 참조 자체가 컴포넌트 타입이다. 인라인으로 정의하면 매 렌더마다 새 함수가 생성되어, React는 매번 다른 컴포넌트 타입으로 인식한다.
인라인 전달 시 일어나는 일:
부모 리렌더
→ backdropComponent prop이 새 함수 참조
→ React: "이전 컴포넌트 타입과 다르네"
→ 이전 backdrop 인스턴스 언마운트
→ backdrop 내부 state 초기화
→ 진행 중인 페이드 애니메이션 중단
→ 새 backdrop 인스턴스 마운트
→ useAnimatedStyle 처음부터 다시 평가
→ appearsOnIndex 페이드 인 애니메이션 처음부터 재생
시각적 증상 (눈에 명확히 보임):
appearsOnIndex={0} 같은 설정이 있으면 페이드 인이 처음부터 재생되어 결함이 두드러짐이는 라이브러리 GitHub 이슈에서도 자주 보고되는 패턴이며, 원인은 항상 동일하다.
참고: 컴포넌트 prop을 모듈 스코프로 빼는 것도 유효하다.
const renderBackdrop = (props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} />
);
const MyScreen = () => {
return <BottomSheet backdropComponent={renderBackdrop} />;
};
다만 props·closure 의존성이 있으면 모듈 스코프로 빼기 어려우므로, 일반적으로 useCallback 패턴이 사용된다.
animationConfigs — 객체// ❌ 인라인 객체
<BottomSheet animationConfigs={{ duration: 250 }} />;
// ✅ useMemo
const animationConfigs = useMemo(() => ({ duration: 250 }), []);
<BottomSheet animationConfigs={animationConfigs} />;
인라인 객체는 매 렌더마다 새 참조라서, 라이브러리 내부에서 애니메이션 설정 변경으로 감지하고 재적용한다. snap 도중에 부모가 리렌더되면 애니메이션이 불안정해질 수 있다.
// ✅ 숫자, 문자열, boolean은 값 비교
<BottomSheet initialSnapIndex={1} enablePanDownToClose={true} index={0} />
JavaScript에서 primitive는 값으로 비교되므로 1 === 1은 true. 새 참조 문제가 없다.
@gorhom/bottom-sheet는 props 변경에 따라 동적으로 시트 동작을 조정해야 하는 라이브러리다. 예를 들어
snapPoints가 바뀌면 → 실제로 snap 위치를 재계산해야 함onChange가 바뀌면 → 새 콜백을 사용해야 함backdropComponent가 바뀌면 → 다른 백드롭을 렌더링해야 함따라서 라이브러리 내부에서 이 props를 deps로 감시하는 것은 올바른 설계다. 문제는 사용자 측에서 의도하지 않게 매번 새 참조를 넘기는 것이다.
라이브러리 관점: "props가 바뀌었으니 반응해야지" (정상 동작)
사용자 관점: "값은 그대로인데 참조만 새로 만들었을 뿐인데 왜 매번 반응하지?" (실수)
이 간극을 메우는 것이 useMemo / useCallback이다.
"모달이나 시트는 수명이 짧으니까 최적화 불필요"라는 생각은 위험하다.
const MyScreen = () => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
return (
<>
<TextInput
value={searchQuery}
onChangeText={setSearchQuery} // 타이핑마다 MyScreen 리렌더
/>
<BottomSheetModal
onChange={idx => console.log(idx)} // ❌ 타이핑마다 새 참조
backdropComponent={p => <Backdrop {...p} />} // ❌ 타이핑마다 새 참조
/>
</>
);
};
TextInput 타이핑 한 번마다 MyScreen이 리렌더되고, 그때마다 시트의 backdropComponent가 언마운트/리마운트된다. 시트가 열려있는 상태라면 백드롭이 매 타이핑마다 깜박인다.
수명이 짧은 컴포넌트라도 콜백이 어디로 흘러가는지 먼저 확인해야 한다.
직접 console.log로 확인할 수 있다.
// ❌ 인라인일 때 — 매 렌더마다 출력됨
<BottomSheet
backdropComponent={props => {
console.log('backdrop function created');
return <BottomSheetBackdrop {...props} />;
}}
/>;
// ✅ useCallback일 때 — 최초 1회만 출력
const renderBackdrop = useCallback(props => {
console.log('backdrop function created');
return <BottomSheetBackdrop {...props} />;
}, []);
BottomSheetBackdrop 내부에 useEffect(() => { console.log('mounted'); return () => console.log('unmounted'); }, [])를 추가하면, 인라인일 때 부모 리렌더마다 mount/unmount 로그가 찍히는 것을 확인할 수 있다.
@gorhom/bottom-sheet의 권장 메모이제이션 패턴은 단순한 "최적화"가 아니라 올바른 동작을 위한 필수 조건이다. 특히:
snapPoints : 인라인 시 내부 worklet 재실행 (성능 저하)공식 README의 모든 예제가 useMemo / useCallback을 사용하는 이유다. 단발성 UI나 래퍼 컴포넌트라도 예외 없이 적용한다.
import BottomSheet, { BottomSheetBackdrop, BottomSheetBackdropProps } from '@gorhom/bottom-sheet';
import { useCallback, useMemo, useRef } from 'react';
// 값이 정적이면 모듈 스코프 상수
const SNAP_POINTS = ['25%', '50%', '90%'];
const MyScreen = () => {
const bottomSheetRef = useRef<BottomSheet>(null);
const handleSheetChange = useCallback((index: number) => {
// ...
}, []);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} />
),
[],
);
return (
<BottomSheet
ref={bottomSheetRef}
snapPoints={SNAP_POINTS}
onChange={handleSheetChange}
backdropComponent={renderBackdrop}
/>
);
};
@gorhom/bottom-sheet@5.x 기준. 위 본문의 내용은 모두 아래 위치에서 직접 확인 가능하다.
| 이론 | 파일 | 라인 |
|---|---|---|
snapPoints가 useDerivedValue의 deps에 들어감 | src/hooks/useAnimatedDetents.ts | detents가 deps 배열에 존재 (useDerivedValue 호출의 두 번째 인자) |
부모의 snapPoints가 그대로 훅에 전달됨 | src/components/bottomSheet/BottomSheet.tsx | useAnimatedDetents(_providedSnapPoints, ...) 호출부 (≈ L197) |
부모의 onChange가 내부 useCallback의 deps에 들어감 | src/components/bottomSheet/BottomSheet.tsx | handleOnChange = useCallback(..., [_providedOnChange, animatedDetentsState]) (≈ L440–L468) |
snapPoints 변경 반응 useAnimatedReaction | src/components/bottomSheet/BottomSheet.tsx | OnSnapPointChange 리액션 (≈ L1570–L1605) |
라인 번호는 라이브러리 버전·patch에 따라 약간 달라질 수 있다. 심볼명(
_providedSnapPoints,_providedOnChange,useAnimatedDetents,evaluatePosition,SNAP_POINT_CHANGE)으로 grep하면 변경된 위치에서도 즉시 찾을 수 있다.