Zustand로 만든 훅을 컴포넌트에서 부르면, 다른 곳에서 값을 바꿨을 때 그 컴포넌트가 알아서 다시 그려진다. Redux의 useSelector도, Jotai의 useAtom도 마찬가지다. 이름과 API는 제각각이지만 바닥에서 하는 일은 같다. 값을 한곳에 두고, 바뀌면 구독자에게 알린다.
이 장에서는 그 바닥을 30줄 남짓한 코드로 직접 만든다. 만들어 보면 라이브러리 문서의 경고가 왜 있는지 저절로 보인다. "selector에서 새 객체를 반환하지 마라", "상태를 직접 변경하지 마라" 같은 경고다. 라이브러리별 비교는 상태 관리 라이브러리 노트에 있다.
store: 값 하나와 구독자 목록
먼저 값을 담고 꺼내는 그릇이다.
function createStore(initial) {
let state = initial
return {
getState: () => state,
setState: next => {
// 함수를 넘기면 지금 값으로 다음 값을 만든다
state = typeof next === 'function' ? next(state) : next
},
}
}
getState와 setState만으로는 아무도 값이 바뀐 줄 모른다. 바뀔 때 알려 줄 상대가 필요하다. 그래서 구독자(listener) 목록을 두고, setState가 끝나면 구독자를 모두 부른다. 옵저버 패턴 그대로다.
function createStore(initial) {
let state = initial
const listeners = new Set()
return {
getState: () => state,
setState(next) {
const value = typeof next === 'function' ? next(state) : next
if (Object.is(value, state)) return // 같은 값이면 아무에게도 알리지 않는다
state = value
listeners.forEach(listener => listener())
},
subscribe(listener) {
listeners.add(listener)
return () => listeners.delete(listener) // 구독 해지 함수
},
}
}
세 가지를 눈여겨보자.
subscribe는 구독 해지 함수를 돌려준다. 컴포넌트가 사라질 때 이 함수를 불러 목록에서 빠진다.- 구독자는 새 값을 인자로 받지 않는다. 알림을 받으면
getState()로 직접 꺼내 본다. 다음 절의useSyncExternalStore가 store에 요구하는 것이 바로 이 모양이다. Object.is로 같은 값인지 먼저 본다. Zustand의setState도 같은 비교를 한다. 다음 상태가 지금 상태와Object.is로 같으면 구독자를 부르지 않는다(Zustand 소스).
React에 잇기: useSyncExternalStore
store가 바뀌면 컴포넌트를 다시 렌더링해야 한다. 처음 떠오르는 방법은 store 값을 useState로 복사해 두고 useEffect에서 구독하는 것이다.
// 흔히 떠올리는 방법(문제가 있다)
function useStore(store) {
const [state, setState] = useState(store.getState()) // 렌더링 때 값을 복사해 둔다
useEffect(() => store.subscribe(() => setState(store.getState())), [store]) // 구독은 렌더링이 끝난 뒤에야 시작된다
return state
}
평소에는 잘 돈다. 문제는 두 군데서 생긴다.
- 구독이 늦다. 구독은 렌더링이 끝나고 effect가 돌 때 시작된다. 그사이에 store가 바뀌면 이 컴포넌트는 알림을 받지 못한다. 그래서 다음 변경이 올 때까지 옛 값을 보여 준다.
- 화면이 찢어진다. React 18부터는
startTransition같은 동시 기능을 쓰면 렌더링을 잠시 멈췄다가 이어 갈 수 있다(동시 렌더링). 멈춘 사이에 store가 바뀌면, 먼저 렌더링한 컴포넌트는 옛 값을 쓰고 나중에 렌더링한 컴포넌트는 새 값을 쓴다. 같은 상태가 화면에 두 가지 값으로 보이는 것을 테어링(tearing)이라고 한다(React 18 작업 그룹).
React는 외부 store를 구독해 렌더링에 잇는 일을 맡는 훅을 따로 둔다. useSyncExternalStore다. useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?) 모양으로 세 함수를 받는다.
function useStore(store, selector = state => state) {
return useSyncExternalStore(
store.subscribe, // subscribe: 구독하는 함수. 구독 해지 함수를 돌려줘야 한다
() => selector(store.getState()), // getSnapshot: 지금 값(스냅숏, snapshot)을 꺼내는 함수
() => selector(store.getState()), // getServerSnapshot: 서버 렌더링·하이드레이션 때 쓸 스냅숏
)
}
React는 렌더링할 때 둘째 인자 getSnapshot으로 값을 읽는다. store가 알림을 보내면 getSnapshot을 다시 불러 이전 값과 Object.is로 비교하고, 다를 때만 다시 렌더링한다(react.dev). 렌더링 도중 store가 바뀌었는지도 React가 확인한다. 진행 중인 업데이트가 트랜지션(startTransition으로 표시한 덜 급한 업데이트)이라면 화면에 반영하기 직전에 getSnapshot을 한 번 더 불러 본다. 값이 달라졌으면 업데이트를 처음부터 다시 하되, 이번에는 끊지 않고 한 번에 끝낸다. 그래서 화면의 모든 컴포넌트가 같은 시점의 store 값을 보여 준다(react.dev 주의 사항).
대가도 있다. store 변경으로 생긴 업데이트는 startTransition으로 감싸도 항상 동기로 처리된다(React 18 작업 그룹, react.dev 주의 사항). 끊어서 렌더링하는 이점을 포기하고 일관성을 얻는 셈이다.
Zustand의 useStore(store, selector)가 바로 이 모양이다. useSyncExternalStore에 api.subscribe와 () => selector(api.getState())를 넘긴다(Zustand 소스). 우리가 만든 것과 거의 같은 코드다.
selector: 필요한 조각만 구독하기
selector를 넘기지 않으면 getSnapshot은 state 전체를 돌려준다. 불변 업데이트(고치지 않고 새로 만드는 방식, 아래 "불변 업데이트" 절)를 하면 state 객체는 바뀔 때마다 새 객체가 된다. 그래서 어떤 값이 바뀌든 Object.is 비교는 항상 "다르다"가 된다.
세 컴포넌트가 각각 count, user.name, todos를 보여 준다. 모두 selector 없이 state 전체를 구독한다. count만 1 올리면 다시 렌더링되는 컴포넌트는?
이 사이트는 React Compiler를 켜 두었다. 컴파일러는 props가 그대로면 자식을 다시 렌더링하지 않게 해 준다. 하지만 컴포넌트가 직접 구독한 store가 바뀌어 일어나는 렌더링은 막지 않는다. 실험은 바로 그 구독 때문에 일어난 렌더링만 센다. 개발 모드의 Strict Mode는 마운트할 때 effect를 한 번 더 돌린다. 실험은 마운트 직후 횟수를 0으로 되돌리기 때문에, 이 이중 실행은 횟수에 섞이지 않는다.
함정: 새 객체를 돌려주는 selector
값 두 개가 필요하면 selector에서 객체로 묶고 싶어진다. 그런데 s => ({ todos: s.todos, filter: s.filter })는 부를 때마다 새 객체를 만든다. getSnapshot이 매번 다른 값을 돌려주면 React는 매번 "바뀌었다"고 보고 다시 렌더링한다. 다시 렌더링하면 또 새 객체가 나온다. 끝이 없다. React는 이 경우 "The result of getSnapshot should be cached"라는 오류를 낸다(react.dev). Zustand v5도 같은 이유로 무한 루프("Maximum update depth exceeded")가 날 수 있다고 안내한다(Zustand v5 마이그레이션).
“Zustand v5로 할 일 store를 만들고, 머리말에 필터 이름과 완료 개수/전체 개수를 보여 줘.”
의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 2개입니다.
불변 업데이트: 알림과 비교가 성립하는 조건
실험에서 "직접 변경"을 고르고 버튼을 누르면 화면이 꿈쩍도 하지 않는다. 그때 실행되는 코드는 이렇다.
const s = store.getState()
s.count++ // 들고 있는 객체를 직접 고친다
store.setState(s) // 같은 객체를 다시 넣는다
값은 분명 바뀌었다. 하지만 setState가 받은 것은 지금 상태와 같은 참조다. Object.is(s, state)가 true라서 store는 아무에게도 알리지 않는다. 알렸다 해도 결과는 구독 방식에 따라 갈린다. state 전체를 구독했거나 s.todos처럼 직접 고친 객체를 고른 컴포넌트는 스냅숏이 같은 참조라 렌더링을 건너뛴다. s.count처럼 원시값을 고른 컴포넌트만 바뀐 값을 보고 다시 렌더링된다(react.dev). 하지만 이 장의 store는 아예 알리지 않으니, 바뀐 값은 화면에 나타나지 않다가 다른 이유로 다시 렌더링될 때 한꺼번에 나타난다. 실험에서 "직접 변경"을 고른 채 버튼을 몇 번 누른 뒤 "불변 업데이트"로 돌아가 보면, 컴포넌트를 새로 마운트하면서 숨어 있던 값이 그제야 보인다.
그래서 상태는 바꾸지 않고 새로 만든다. 바뀐 곳까지 가는 경로만 얕은 복사로 새로 만들고, 나머지는 원래 참조를 그대로 둔다.
store.setState(s => ({ ...s, user: { ...s.user, name: '서준' } }))
// s.user는 새 객체, s.todos는 원래 참조 그대로
todos는 원래 참조 그대로라서 s => s.todos를 고른 컴포넌트는 "같다"는 답을 받고 렌더링을 건너뛴다. 불변 업데이트는 단순한 습관이 아니다. 참조 비교 한 번으로 "무엇이 바뀌었나"를 알아내는 이 구조 전체가 불변 업데이트 위에 서 있다(react.dev: 객체 상태 업데이트). React의 useState도 같은 이유로 직접 변경을 알아채지 못한다(React의 상태).
스프레드가 깊어지면 읽기 어렵다. 그래서 Immer를 많이 쓴다. 직접 고치는 것처럼 코드를 쓰면 Immer가 새 객체를 만들어 준다. Redux Toolkit의 createSlice 리듀서는 Immer를 쓴다(Redux Toolkit 문서). Zustand는 immer 미들웨어를 준다(Zustand 문서).
실제 라이브러리와 견줘 보기
| 이 장에서 만든 것 | Zustand | Redux + React Redux | Jotai |
|---|---|---|---|
createStore | create, createStore | Redux Toolkit configureStore | createStore |
setState | set, setState | dispatch(action) → 리듀서 | store.set(atom, 값) |
subscribe | subscribe | store.subscribe | store.sub(atom, 콜백) |
useStore(store, selector) | useStore(store, selector), create로 만든 훅은 useXxxStore(selector) | useSelector(selector) | useAtomValue(atom) |
- Zustand는 이 장의 구조와 가장 가깝다. store 하나와 selector로 고른 조각을
useSyncExternalStore로 읽는다. - Redux는
setState대신 액션을 보내고, 다음 상태는 리듀서가 만든다. 구독과 selector 비교는 같다. React Redux의useSelector는use-sync-external-store패키지의useSyncExternalStoreWithSelector로 구독한다(React Redux 소스). - Jotai는 구독 단위가 다르다. 커다란 state 하나에서 조각을 고르는 대신, 값마다 atom을 만들고 atom 단위로 읽고 구독한다. store에는
get·set·sub이 있다(Jotai 문서). selector로 하던 "필요한 것만 구독하기"를 atom 쪼개기로 하는 셈이다.
라이브러리를 고를 때도 이 장의 질문이 기준이 된다. 값이 어디에 있나(store), 바뀌면 누가 알림을 받나(구독), 무엇을 비교해 다시 그릴지 정하나(selector와 동등성), 바뀐 것을 어떻게 알아보나(불변 업데이트).
1. useSyncExternalStore가 다시 렌더링할지 정하는 기준은?
2. s => s.todos.filter(t => t.done)를 selector로 쓰면?
3. state.count++ 뒤에 setState(state)를 부르면 이 장에서 만든 store(Object.is로 비교)는?