June.

2024년 1분기 안에는 꼭...

Portfolio

About

React 18 useSyncExternalStore에 대해서

2023/10/23 updated
짧.

4 min read

React 18 useSyncExternalStore에 대해서

Locales:

ko

en

tl;dr

시중에는 리액트 내부의 훅들, 예를들어 useState, useReducer와 같은 것들을 사용하지 않고 상태관리를 도와주는 라이브러리들이 여럿 있습니다. 대표적으로 redux, recoil, jotai, xtsate, zustand, rect query... 정도가 있습니다.

useSyncExternalStore는 단순하게 생각해서 React에 의존되어 있지 않은 스토어(위에서 나열한 라이브러리들)를 useStateuseEffect를 통해서 변경사항을 추적하고, useState의 setter를 통해서 리액트의 리렌더링과 동기화를 해준다고 생각하면 됩니다.

useSyncExternalStore

공식문서는 이곳에 위치하고 있습니다. 직관적으로 단어들을 분해해서 살펴보면 use + sync external store, 외부 스토어(external store)와 싱크(sync)를 맞추는 훅(use) 입니다.

1const snapshot = useSyncExternalStore( 2 subscribe, 3 getSnapshot, 4 getServerSnapshot 5);

그러니까 리액트에 디펜던시가 없는 상태관리 라이브러리 혹은 리액트 디펜던시가 없어서 렌더링 로직에 조금 문제가 있을 수 있는 외부 스토어를 연결시켜서 리액트의 리렌더링 시스템에 올라탈 수 있도록 해주는 훅입니다.

아래 코드는 shim code로 18버전의 useSyncExternalStore를 리액트 16, 17버전에서 사용하기 위해서 구현해놓은 코드입니다. 만약 18버전의 useSyncExternalStore 코드를 보고 싶으시면 이 곳의 블로그를 참고해주세요

shim 코드 내부는 어떻게 구현되어있을까요? 구현체 코드는 이곳에 위치하고 있습니다.

1// 이 훅에서 사용되는 꼼수(clever hacks)가 일반적으로 작동한다고 가정하지 마세요. 2// 이 쉼(hook)의 목적은 다른 라이브러리의 꼼수 대신 필요를 대체하는 것입니다. 3export function useSyncExternalStore<T>( 4 subscribe: (() => void) => () => void, 5 getSnapshot: () => T, 6 // 참고: 이 쉼(shim)은 `getServerSnapshot`을 사용하지 않습니다. 7 // 왜냐하면 React의 18버전 이전의 버전에서는 수화(hydrating) 중인지 확인하는 방법이 노출되지 않기 때문입니다. 8 // 따라서 쉼을 사용하는 사람들은 이를 직접 추적하고 `getSnapshot`에서 올바른 값을 반환해야 합니다. 9 getServerSnapshot?: () => T, 10): T { 11 if (__DEV__) { 12 if (!didWarnOld18Alpha) { 13 if (React.startTransition !== undefined) { 14 didWarnOld18Alpha = true; 15 console.error( 16 'React 18의 이전 릴리스, 사전 릴리스 알파 버전을 사용하고 있으며 ' + 17 'useSyncExternalStore를 지원하지 않습니다. ' + 18 'use-sync-external-store 쉼이 제대로 작동하지 않을 것입니다. ' + 19 '더 최신 사전 릴리스로 업그레이드하세요.', 20 ); 21 } 22 } 23 } 24 25 // 매 렌더링 시점에 저장소(store)에서 현재 스냅샷(snapshot)을 읽습니다. 26 // 다시 한번 말하지만, 이는 React의 규칙을 어긴 것이며 특정 구현 세부 사항 때문에만 작동합니다. 27 const value = getSnapshot(); 28 if (__DEV__) { 29 if (!didWarnUncachedGetSnapshot) { 30 const cachedValue = getSnapshot(); 31 if (!is(value, cachedValue)) { 32 console.error( 33 'getSnapshot의 결과는 무한 루프를 피하기 위해 캐시해야 합니다.', 34 ); 35 didWarnUncachedGetSnapshot = true; 36 } 37 } 38 } 39 40 // 업데이트가 동기적으로 발생하기 때문에 업데이트를 큐에 넣지 않습니다. 41 // 대신 구독된 상태가 변경될 때마다 임의의 useState 훅을 업데이트함으로써 강제로 리렌더링합니다. 42 // 그런 다음 렌더링 중에 현재 값을 읽기 위해 getSnapshot을 호출합니다. 43 // 44 // 실제로 useState 훅에서 반환한 상태를 사용하지 않기 때문에 해당 슬롯(slot)에 다른 정보를 저장하여 메모리를 절약할 수 있습니다. 45 // 46 // 일찍 종료(bailout)를 구현하려면 변경 가능한 객체에 일부 내용을 추적해야 합니다. 47 // 보통 useRef 훅에 넣을 것이지만 useState 훅 대신에 이를 저장할 수 있습니다. 48 // 49 // 강제 리렌더링을 위해 forceUpdate({inst})를 호출합니다. 이 작업은 항상 동등성 검사를 통과하지 못하기 때문에 작동합니다. 50 const [{inst}, forceUpdate] = useState({inst: {value, getSnapshot}}); 51 52 // 최신 getSnapshot 함수를 ref를 사용하여 추적합니다. 이 ref는 레이아웃(phase) 단계에서 업데이트해야 하므로 53 // 구독(subscribe) 중에 발생하는 티어링(tearing) 확인을 수행할 때 액세스할 수 있어야 합니다. 54 useLayoutEffect(() => { 55 inst.value = value; 56 inst.getSnapshot = getSnapshot; 57 58 // getSnapshot 또는 subscribe가 변경될 때마다 변경이 있었는지 확인하기 위해 커밋(commit) 단계에서 체크해야 합니다. 59 if (checkIfSnapshotChanged(inst)) { 60 // 강제로 리렌더링합니다. 61 forceUpdate({inst}); 62 } 63 }, [subscribe, value, getSnapshot]); 64 65 useEffect(() => { 66 // 구독하기 전에 변경을 확인합니다. 그 후에는 변경 사항은 구독 핸들러에서 감지됩니다. 67 if (checkIfSnapshotChanged(inst)) { 68 // 강제로 리렌더링합니다. 69 forceUpdate({inst}); 70 } 71 const handleStoreChange = () => { 72 // TODO: 배치 업데이트를 위한 크로스 렌더러 API가 없으므로 unstable_batchedUpdates로 래핑하는 것은 이 라이브러리의 사용자에게 달려 있습니다. 73 // 개발 단계에서 이게 아닌 경우 감지하고 경고 메시지를 출력해야 할까요? 74 75 // 저장소가 변경되었습니다. 마지막으로 저장소에서 읽었던 스냅샷이 마지막으로 읽은 후 변경되었는지 확인합니다. 76 if (checkIfSnapshotChanged(inst)) { 77 // 강제로 리렌더링합니다. 78 forceUpdate({inst}); 79 } 80 }; 81 // 저장소를 구독하고 정리 함수를 반환합니다. 82 return subscribe(handleStoreChange); 83 }, [subscribe]); 84 85 useDebugValue(value); 86 return value; 87}

코드가 조금 길어보이는데,, DEV 환경일 때의 코드 없애고, 주석을 다 없애면 코드가 그렇게 길지 않습니다.

1export function useSyncExternalStore<T>( 2 subscribe: (() => void) => () => void, 3 getSnapshot: () => T, 4 getServerSnapshot?: () => T, 5): T { 6 const value = getSnapshot(); 7 const [{inst}, forceUpdate] = useState({inst: {value, getSnapshot}}); 8 9 useLayoutEffect(() => { 10 inst.value = value; 11 inst.getSnapshot = getSnapshot; 12 13 if (checkIfSnapshotChanged(inst)) { 14 forceUpdate({inst}); 15 } 16 }, [subscribe, value, getSnapshot]); 17 18 useEffect(() => { 19 if (checkIfSnapshotChanged(inst)) { 20 forceUpdate({inst}); 21 } 22 const handleStoreChange = () => { 23 if (checkIfSnapshotChanged(inst)) { 24 forceUpdate({inst}); 25 } 26 }; 27 return subscribe(handleStoreChange); 28 }, [subscribe]); 29 30 useDebugValue(value); 31 return value; 32}

어떤가요? 그렇게 길지 않죠?

주요 로직은 사용자로부터 받은 getSnapshot 함수를 통해서 snapshot value를 얻고, 해당 snapshot이 변경되었다면 강제 업데이트(forceUpdate)를 해서 리렌더링을 해주는 로직밖에 없습니다.

checkIfSnapshotChanged 함수는 아래와 같이 구현되어 있습니다.

1function checkIfSnapshotChanged<T>(inst: { 2 value: T; 3 getSnapshot: () => T; 4}): boolean { 5 const latestGetSnapshot = inst.getSnapshot; 6 const prevValue = inst.value; 7 try { 8 const nextValue = latestGetSnapshot(); 9 return !is(prevValue, nextValue); 10 } catch (error) { 11 return true; 12 } 13}

이 함수는 useSyncExternalStore 훅에서 스냅샷의 변경 여부를 확인하고, 스냅샷이 변경되었을 때 강제로 리렌더링을 유발하기 위해 사용됩니다. 변경이 감지되면 forceUpdate를 통해 리렌더링이 강제됩니다.

이렇게 useSyncExternalStore는 리액트 내부 훅을 사용하지 않고 구현한 외부 스토어에 대해서 리액트의 내부 훅들을 사용해서 리액트의 렌더링 시스템에 올라타게 해줍니다.

관련 포스트가 4개 있어요.

CSS flex box의 align-items의 flex-start와 baseline의 차이점에 대해서

짧.
css-flex-box-align-items-attributes cover image
2023/09/24

CSS에서 align-items의 flex-start와 baseline의 차이점

React 18.0.0 react-dom의 flushSync의 사용법과 주의사항에 대해서 알아보자.

짧.
react-flush-sync cover image
2023/09/24

React flushSync에 대해서

maxLength를 넣고 이모지를 넣으면 브라우저마다 계산되는 것이 다르다.

짧.
html-input-max-length cover image
2023/09/15

HTML `<input />`의 maxLength 동작이 safari에서 다른 이슈

React Fragment와 return null의 차이점

짧.
react-fragment-vs-null cover image
2023/04/09

React Fragment vs Null

profile

정현수.

Currently Managed

Currently not managed

© 2024. junghyeonsu all rights reserved.