- External의 의미 == React의 외부
- External Store란 React 외부의 저장소를 의미
- 즉, React가 관리하지 않는 Store
- e.g. 상태 관리 라이브러리, 브라우저 스토리지(IndexedDb, localStorage, …), 웹소켓 등등
- useState, useReducer를 제외한 모든 것이라고 보면 됨
- Tearing 이슈를 해결하기 위함
- Tearing이란 하나의 state가 서로 다른 값으로 렌더링되는 현상
- Tearing 이슈는 왜 발생하는가
- React가 concurrent 렌더링을 지원하면서 생긴 문제
- 기존에는 synchronous 렌더링 방식을 사용했기 때문에, 한 번 시작한 렌더링은 중지되지 않음
- 하지만 concurrent 렌더링 방식을 사용하면서, 한 번 시작한 렌더링이 중단(interrupt받을 수 있는 상황)될 수 있는 상황이 생겨버림
- 이처럼 같은 데이터임에도 다른 값을 표시하는 현상을 Tearing 이라 함
- 이때, Tearing 이슈는 외부 저장소의 상태를 사용하는 경우에만 발생
- useState 같은 React 내부에서 관리하는 상태는 tearing 이슈가 발생하지 않도록 처리되어 있음
- 이때, Tearing 이슈는 외부 저장소의 상태를 사용하는 경우에만 발생
- 정리하자면, useSyncExternalStore는 concurrent 렌더링 환경에서의 Tearing 이슈를 보완하고자 등장한 훅
- React 내부의 것이 아니지만 싱크를 맞춰보겠다는 의지가 담긴…
useSyncExternalStore(
subsribe: (callback) => Unsubscribe,
getSnapshot: () => State,
getServerSnapshot: () => State,
) => State
- parameters
- subscribe
- 콜백 함수를 받아 스토어에 등록하는 용도
- 스토어에 있는 값이 변경되면 이 콜백이 호출되고,
useSyncExternalStore
훅을 호출한 컴포넌트를 리렌더링함
- getSnapshot
- 컴포넌트에 필요한 현재 스토어의 데이터를 반환하는 함수
- 스토어에 있는 값이 변경되지 않았다면 매번 함수를 호출할 때마다 동일한 값을 반환해야함
- 스토어의 값에 변경이 있는지 확인하기 위해
Object.is
로 비교
- 스토어의 값에 변경이 있는지 확인하기 위해
- getServerSnapshot
- 옵셔널 값으로, 서버 사이드 렌더링 시에 내부 리액트를 하이드레이션 하는 도중에만 사용
- 서버 사이드에서 렌더링되는 훅이라면 반드시 이 값을 넘겨줘야 하며, 클라이언트 값과 불일치가 발생하면 오류가 발생
- subscribe
- returns
- 스토어에 저장된 상태값
subscribe, getSnaphot 등의 기능을 제공하고 있는 외부 스토어여야 이 훅을 사용할 수 있음 (일종의 규격)
zustand로 스토어를 만드는 코드는 다음과 같음 (create
함수 호출)
import { create } from 'zustand'
// store 생성
const useBearStore = create((set) => ({
bears: 0, // 스토어에 저장할 상태
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })), // 상태를 다룰 메서드 1
removeAllBears: () => set({ bears: 0 }), // 상태를 다룰 메서드 2
...
}))
// 스토어를 구독하는 컴포넌트
function BearCounter() {
const bears = useBearStore((state) => state.bears); // bears라는 상태를 사용
return <h1>{bears} around here ...</h1>;
}
// 스토어의 상태 조작 메서드를 사용하는 컴포넌트
function Controls() {
const increasePopulation = useBearStore((state) => state.increasePopulation); // increasePopulation 함수 사용
return <button onClick={increasePopulation}>one up</button>;
}
// zustand의 Store는 setState/getState/getInitialState/subscribe를 가짐
export interface StoreApi<T> {
setState: SetStateInternal<T>;
getState: () => T;
getInitialState: () => T;
subscribe: (listener: (state: T, prevState: T) => void) => () => void;
}
const createStoreImpl: CreateStoreImpl = (createState) => {
type TState = ReturnType<typeof createState>;
type Listener = (state: TState, prevState: TState) => void;
let state: TState; // zustand 스토어가 관리할 상태
const listeners: Set<Listener> = new Set(); // 상태를 TODO
const setState: StoreApi<TState>['setState'] = (partial, replace) => {
const nextState =
typeof partial === 'function' // setter 함수를 받으면
? (partial as (state: TState) => TState)(state) // 함수 실행한 결과를 nextState로
: partial; // 함수가 아니면 그 값을 그대로 활용
if (!Object.is(nextState, state)) {
// 현재 상태와 nextState 비교
// nextState와 state가 다르다면
const previousState = state; // 현재 상태는 이전 상태로 취급
state = // 현재 상태는
// replace가 true라면 nextState를 덮어쓰고
// replace가 false이면서 nextState가 객체가 아니라면 nextState를 덮어쓰고
// replace가 false이면서 nextState가 객체라면 state와 nextState를 병합
replace ?? (typeof nextState !== 'object' || nextState === null)
? (nextState as TState)
: Object.assign({}, state, nextState);
// 스토어를 구독하는 함수들 모두 호출
listeners.forEach((listener) => listener(state, previousState));
}
};
// 현재 상태 반환
const getState: StoreApi<TState>['getState'] = () => state;
// 이니셜 상태 반환
const getInitialState: StoreApi<TState>['getInitialState'] = () => initialState;
// 상태가 바뀌면 호출할 함수들을 등록하거나 지움
const subscribe: StoreApi<TState>['subscribe'] = (listener) => {
listeners.add(listener);
// Unsubscribe
return () => listeners.delete(listener);
};
const api = { setState, getState, getInitialState, subscribe };
const initialState = (state = createState(setState, getState, api));
return api as any;
};
export const createStore = ((createState) =>
createState ? createStoreImpl(createState) : createStoreImpl) as CreateStore;
// react.ts
// react에서 zustand를 사용하는 경우
import React from 'react';
import { createStore } from './vanilla.ts';
import type { Mutate, StateCreator, StoreApi, StoreMutatorIdentifier } from './vanilla.ts';
export function useStore<TState, StateSlice>(
api: ReadonlyStoreApi<TState>,
selector: (state: TState) => StateSlice = identity as any
) {
const slice = React.useSyncExternalStore(
api.subscribe, // 스토어(에 저장된 상태)를 구독하게 해주는 함수
() => selector(api.getState()), // 스토어에 저장된 현재 상태를 반환하는 함수 전달
() => selector(api.getInitialState()) // 스토어에 저장된 이니셜 상태를 반환하는 함수 전달
);
React.useDebugValue(slice); // React DevTools에서 커스텀훅(=useStore)에 label을 추가할 수 있는 훅
return slice;
}
const createImpl = <T,>(createState: StateCreator<T, [], []>) => {
const api = createStore(createState); // { setState, getState, getInitialState, subscribe }
const useBoundStore: any = (selector?: any) => useStore(api, selector);
Object.assign(useBoundStore, api);
return useBoundStore;
};
export const create = (<T,>(createState: StateCreator<T, [], []> | undefined) =>
createState ? createImpl(createState) : createImpl) as Create;
- React+Zustand 환경에서 create로 스토어를 만드는 것은 결국
- zustand 내부적으로 구현된 스토어의 형태를
- useSyncExternalStore로 래핑(=즉, 쉽게 말하면 티어링 방지 처리가 된..?)하는 것