ReactのuseSyncExternalStoreで作るオレオレStateライブラリ
publication: 2023/10/18
update:2024/02/20
サンプルコード
あまり話題にされない useSyncExternalStore
ReactHooks 解説系の記事で無かったことにされたり、一瞬だけ概要が紹介されるだけなことが多い useSyncExternalStore です。可哀想なので、オレオレ State ライブラリを作って使い方を紹介したいと思います。
オレオレ State ライブラリは一瞬で構築できる
1import { useRef, useSyncExternalStore } from "react";23export type ContextType<T> = {4 state: T;5 storeChanges: Set<() => void>;6 dispatch: (callback: (state: T) => T) => void;7 subscribe: (onStoreChange: () => void) => () => void;8};910export const createStoreContext = <T>(initState: () => T) => {11 const context = useRef<ContextType<T>>({12 state: initState(),13 storeChanges: new Set(),14 dispatch: (callback) => {15 context.state = callback(context.state);16 context.storeChanges.forEach((storeChange) => storeChange());17 },18 subscribe: (onStoreChange) => {19 context.storeChanges.add(onStoreChange);20 return () => {21 context.storeChanges.delete(onStoreChange);22 };23 },24 }).current;25 return context;26};2728export const useSelector = <T, R>(29 context: ContextType<T>,30 getSnapshot: (state: T) => R31) =>32 useSyncExternalStore(33 context.subscribe,34 () => getSnapshot(context.state),35 () => getSnapshot(context.state)36 );
はい、出来上がりです。これだけでコンポーネントの State の更新を自由自在に操れます。では、使い方を見てみましょう。
1type StateType = { a: number; b: number; c: number };23const A = ({ context }: { context: ContextType<StateType> }) => {4 const value = useSelector(context, (state) => state.a);5 return <div>A:{value}</div>;6};7const B = ({ context }: { context: ContextType<StateType> }) => {8 const value = useSelector(context, (state) => state.b);9 return <div>B:{value}</div>;10};11const C = ({ context }: { context: ContextType<StateType> }) => {12 const value = useSelector(context, (state) => state.c);13 return <div>C:{value}</div>;14};1516const Buttons = ({ context }: { context: ContextType<StateType> }) => {17 return (18 <div>19 <button20 onClick={() =>21 context.dispatch((state) => ({ ...state, a: state.a + 1 }))22 }23 >24 A25 </button>26 <button27 onClick={() =>28 context.dispatch((state) => ({ ...state, b: state.b + 1 }))29 }30 >31 B32 </button>33 <button34 onClick={() =>35 context.dispatch((state) => ({ ...state, c: state.c + 1 }))36 }37 >38 C39 </button>40 </div>41 );42};4344const Page = () => {45 const context = createStoreContext<StateType>(() => ({46 a: 0,47 b: 10,48 c: 100,49 }));50 return (51 <div>52 <A context={context} />53 <B context={context} />54 <C context={context} />55 <Buttons context={context} />56 </div>57 );58};
これで共有されている State の内、コンポーネントが必要とする部分が更新された場合のみ、最小限で再レンダリングされるようになります。
Context を配るのが面倒な場合
createContextを使って Provider を作り、配下に Context を配るようにします。
1import {2 useRef,3 useSyncExternalStore,4 createContext,5 ReactNode,6 useContext,7} from "react";89export type ContextType<T> = {10 state: T;11 storeChanges: Set<() => void>;12 dispatch: (callback: (state: T) => T) => void;13 subscribe: (onStoreChange: () => void) => () => void;14};1516export const createStoreContext = <T,>(initState: () => T) => {17 const context = useRef<ContextType<T>>({18 state: initState(),19 storeChanges: new Set(),20 dispatch: (callback) => {21 context.state = callback(context.state);22 context.storeChanges.forEach((storeChange) => storeChange());23 },24 subscribe: (onStoreChange) => {25 context.storeChanges.add(onStoreChange);26 return () => {27 context.storeChanges.delete(onStoreChange);28 };29 },30 }).current;31 return context;32};3334const StoreContext = createContext<ContextType<any>>(undefined as never);3536export const StoreProvider = <T,>({37 children,38 initState,39}: {40 children: ReactNode;41 initState: () => T;42}) => {43 const context = createStoreContext(initState);44 return (45 <StoreContext.Provider value={context}>{children}</StoreContext.Provider>46 );47};4849export const useSelector = <T, R>(getSnapshot: (state: T) => R) => {50 const context = useContext<ContextType<T>>(StoreContext);51 return useSyncExternalStore(52 context.subscribe,53 () => getSnapshot(context.state),54 () => getSnapshot(context.state)55 );56};5758export const useDispatch = <T,>() => {59 const context = useContext<ContextType<T>>(StoreContext);60 return context.dispatch;61};
使い方はこんな形です。
1type StateType = { a: number; b: number; c: number };23const A = () => {4 const value = useSelector((state: StateType) => state.a);5 return <div>A:{value}</div>;6};7const B = () => {8 const value = useSelector((state: StateType) => state.b);9 return <div>B:{value}</div>;10};11const C = () => {12 const value = useSelector((state: StateType) => state.c);13 return <div>C:{value}</div>;14};1516const Buttons = () => {17 const dispatch = useDispatch<StateType>();18 return (19 <div>20 <button21 onClick={() => dispatch((state) => ({ ...state, a: state.a + 1 }))}22 >23 A24 </button>25 <button26 onClick={() => dispatch((state) => ({ ...state, b: state.b + 1 }))}27 >28 B29 </button>30 <button31 onClick={() => dispatch((state) => ({ ...state, c: state.c + 1 }))}32 >33 C34 </button>35 </div>36 );37};3839const Page = () => {40 return (41 <StoreProvider42 initState={() => ({43 a: 0,44 b: 10,45 c: 100,46 })}47 >48 <A />49 <B />50 <C />51 <Buttons />52 </StoreProvider>53 );54};55export default Page;
Context を配る必要が無くなり、コード量が少なくなりました。
useSyncExternalStore を使わずに useState 同じ事をしてみる
実はuseStateの dispatch を収集する構造を作れば、useSyncExternalStoreと同じ事が可能です。コード量はほとんど同じです。
1import {2 useRef,3 createContext,4 ReactNode,5 useContext,6 useState,7 Dispatch,8 SetStateAction,9 useEffect,10} from "react";1112type ContextType<T> = {13 state: T;14 storeChanges: Map<Dispatch<SetStateAction<any>>, (state: T) => unknown>;15 dispatch: (callback: (state: T) => T) => void;16};1718const createStoreContext = <T,>(initState: () => T) => {19 const context = useRef<ContextType<T>>({20 state: initState(),21 storeChanges: new Map(),22 dispatch: (callback) => {23 context.state = callback(context.state);24 context.storeChanges.forEach((callback, storeChange) =>25 storeChange(callback(context.state))26 );27 },28 }).current;29 return context;30};3132const StoreContext = createContext<ContextType<any>>(undefined as never);3334const StoreProvider = <T,>({35 children,36 initState,37}: {38 children: ReactNode;39 initState: () => T;40}) => {41 const context = createStoreContext(initState);42 return (43 <StoreContext.Provider value={context}>{children}</StoreContext.Provider>44 );45};4647const useSelector = <T, R>(getSnapshot: (state: T) => R) => {48 const context = useContext<ContextType<T>>(StoreContext);49 const [state, dispatch] = useState(() => getSnapshot(context.state));50 context.storeChanges.set(dispatch, getSnapshot);51 useEffect(() => {52 context.storeChanges.set(dispatch, getSnapshot);53 return () => {54 context.storeChanges.delete(dispatch);55 };56 }, [context, getSnapshot]);57 return state;58};5960const useDispatch = <T,>() => {61 const context = useContext<ContextType<T>>(StoreContext);62 return context.dispatch;63};
使い方も前のサンプルと同じです。
まとめ
State ライブラリを作るときは公式でuseSyncExternalStoreが推奨されているので、必要とあらば使うようにしていますが、ぶっちゃけ無くても何にも困りません。
useSyncExternalStoreの使い道としては、外部ライブラリを入れるほどでも無いけれど、局地的に再レンダリングする場所を調整したい場合などに使うと良いかもしれません。