문제 상황
이번 프로젝트에선 toss slash의 useOverlay 훅을 사용해 모달과 같은 오버레이 컴포넌트를 관리하고 있었습니다.
문제는 특정 오버레이 컴포넌트는 상태에 따라 렌더되어야 했는데, 문제는 오버레이 컴포넌트가 최초 마운트될 때의 상태를 "고정"하고 있어, 이후에 상태가 업데이트되어도 변경사항을 반영하지 못하는 상황이 발생했습니다.
문제가 되었던 발생했던 SortOptionBottomSheet 경우, 사진과 같이 선택한 sort option의 status에 따라 스타일을 다르게 주었지만 한번 마운트가 된 경우에선 status가 고정되어 있는 문제가 있었습니다.
해당 이슈의 원인은 OverlayProvider 하위 스코프에서 Overlay 컴포넌트가 렌더되기 때문이였습니다.
📍 관련 이슈 https://github.com/toss/slash/issues/222
[BUG]: @toss/use-overlay change inner state of `CreateOverlayElement`'s JSX.Element · Issue #222 · toss/slash
Package Scope Package name: @toss/use-overlay Describe the bug If I use the open method in overlay to open overlay, the states used in the open method are remembered as the values at the time when ...
github.com
<OverlayContext.Provider>
<App/>
<React.Fragment> // Overlay
<Dialog caution={**App's internal status(caution)**}>
<CustomDialog setCaution={**App's internal set status(setCaution)**} />
</Dialog>
</React.Fragment>
</OverlayContext.Provider>
따라서 App(Overlay 컴포넌트를 사용하는 쪽) 내에서 Overlay 컴포넌트가 참조하는 상태가 업데이트되어도, App만 리렌더가 일어나게 됩니다.
문제 해결
1. Recoil 활용
useOverlay훅의 구현에 따라 `Overlay Context` 외부에서 state를 관리가 필요하다고 판단했습니다.
이 문제를 해결하기 위해 전역 상태 관리 라이브러리인 Recoil을 도입했습니다. Recoil의 상태(Atom)를 사용하여 오버레이 컴포넌트가 필요로 하는 동적 상태(e.g. `sortOption`)를 전역에서 관리하고, 해당 상태를 구독하도록 설정했습니다. 이로 인해 상태가 업데이트될 때마다 오버레이 컴포넌트도 최신 상태를 반영하여 리렌더링되도록 할 수 있었습니다.
하지만, 오버레이 컴포넌트 레벨에서 관리하거나, `useRecoilState`를 사용하는 방식으로는 한계가 있었습니다.
의도한대로라면 `TicketCounterBottomSheet`에서 사용자 인터렉션으로 count atom이 업데이트 되고, 상위 컴포넌트인 `TicketType`에서 업데이트된 count를 필드에 추가되어야 했습니다. 하지만 사진과 같이 오버레이(바텀시트) 컴포넌트가 마운트됐을 당시의 초기값이 추가되는 문제가 발생했습니다.
const [counter, setCounter] = useRecoilState(ticketCounterAtom);
const handleAddRadio = () => {
if (fields.length < MAX_ITEMS) {
append({ value: counter }); // counter를 가져와서 사용함
overlay.close()
setCounter(INIT_COUNTER);
} else {
alert("더 이상 추가할 수 없습니다.");
}
};
const openTicketCounter =
() =>
overlay.open(({ isOpen, close }) => (
<TicketCounterBottomSheet
isOpen={isOpen}
close={close}
ticketType={ticketType}
fields={fields}
action={handleAddRadio} // 바텀시트에 handleAddRadio 함수 전달
/>
))
이렇게 상태가 변화해도 최신 상태를 감지하지 못하는 걸 오래된 클로저(stale clouser)라고 합니다.
https://handhand.tistory.com/264
이 문제를 해결하기 위해선 대표적으로 useEffect로 의존성을 추가해 최신 상태를 감지하는 방식과 useRef로 최신 값을 항상 추적하는 방식이 있다고 합니다.
현재 counter는 recoil로 관리되고 있기 때문에 recoil를 활용해서 해결해보고자 했습니다.
2. useRecoilCallback를 사용하여 스냅샷 접근
이 문제를 해결하기 위해 `useRecoilCallback`을 도입했습니다. recoil 공식문서에서 `useRecoilCallback` 훅 설명에 따르면, react 컴포넌트를 구독하지 않은채로 비동기적으로 recoil state에 접근할 수 있음을 알 수 있습니다.
atom 혹은 selector가 업데이트 될 때 리렌더링하기 위해 React 컴포넌트를 구독하지 않고 비동기적으로 Recoil 상태를 읽기 위해 사용하기
특히, 이 훅을 사용하면 콜백 함수가 호출될 때마다 해당 시점의 최신 Recoil 상태에 접근할 수 있습니다. 이는 `snapshot.getPromise` 메소드를 사용하여 구현됩니다. `snapshot.getPromise`는 주어진 Recoil 상태(atom)의 현재 값을 비동기적으로 반환합니다.
const handleAddRadio = useRecoilCallback(
({ snapshot, set }) => async () => {
const counter = await snapshot.getPromise(ticketCounterAtom); // 스냅샷 생성
if (fields.length < MAX_ITEMS) {
append({ value: counter });
overlay.close();
set(ticketCounterAtom, INIT_COUNTER); // Recoil 상태를 직접 업데이트
} else {
alert("더 이상 추가할 수 없습니다.");
}
},
[]
);
이 방식을 사용함으로써, 오버레이 내부에서 액션(`handleAddRadio`)이 트리거될 때마다, 해당 액션 처리 함수(`handleAddRadio`) 내에서 항상 최신의 `counter` 상태를 조회할 수 있게 됐습니다!
이 이슈를 통해 클로저 이슈를 직접적으로 체감하기도 했고, recoil의 다양한 훅을 알 수 있었습니다 😄