空雲 Blog

Eye catchReactのuseSyncExternalStoreで作るオレオレStateライブラリ

publication: 2023/10/18
update:2023/11/08

あまり話題にされない useSyncExternalStore

ReactHooks 解説系の記事で無かったことにされたり、一瞬だけ概要が紹介されるだけなことが多い useSyncExternalStore です。可哀想なので、オレオレ State ライブラリを作って使い方を紹介したいと思います。

オレオレ State ライブラリは一瞬で構築できる

import { useRef, useSyncExternalStore } from "react"; export type ContextType<T> = { state: T; storeChanges: Set<() => void>; dispatch: (callback: (state: T) => T) => void; subscribe: (onStoreChange: () => void) => () => void; }; export const createStoreContext = <T>(initState: () => T) => { const context = useRef<ContextType<T>>({ state: initState(), storeChanges: new Set(), dispatch: (callback) => { context.state = callback(context.state); context.storeChanges.forEach((storeChange) => storeChange()); }, subscribe: (onStoreChange) => { context.storeChanges.add(onStoreChange); return () => { context.storeChanges.delete(onStoreChange); }; }, }).current; return context; }; export const useSelector = <T, R>( context: ContextType<T>, getSnapshot: (state: T) => R ) => useSyncExternalStore( context.subscribe, () => getSnapshot(context.state), () => getSnapshot(context.state) );

はい、出来上がりです。これだけでコンポーネントの State の更新を自由自在に操れます。では、使い方を見てみましょう。

type StateType = { a: number; b: number; c: number }; const A = ({ context }: { context: ContextType<StateType> }) => { const value = useSelector(context, (state) => state.a); return <div>A:{value}</div>; }; const B = ({ context }: { context: ContextType<StateType> }) => { const value = useSelector(context, (state) => state.b); return <div>B:{value}</div>; }; const C = ({ context }: { context: ContextType<StateType> }) => { const value = useSelector(context, (state) => state.c); return <div>C:{value}</div>; }; const Buttons = ({ context }: { context: ContextType<StateType> }) => { return ( <div> <button onClick={() => context.dispatch((state) => ({ ...state, a: state.a + 1 })) } > A </button> <button onClick={() => context.dispatch((state) => ({ ...state, b: state.b + 1 })) } > B </button> <button onClick={() => context.dispatch((state) => ({ ...state, c: state.c + 1 })) } > C </button> </div> ); }; const Page = () => { const context = createStoreContext<StateType>(() => ({ a: 0, b: 10, c: 100, })); return ( <div> <A context={context} /> <B context={context} /> <C context={context} /> <Buttons context={context} /> </div> ); };

これで共有されている State の内、コンポーネントが必要とする部分が更新された場合のみ、最小限で再レンダリングされるようになります。

{"width":"125px","height":"156px"}

Context を配るのが面倒な場合

createContextを使って Provider を作り、配下に Context を配るようにします。

import { useRef, useSyncExternalStore, createContext, ReactNode, useContext, } from "react"; export type ContextType<T> = { state: T; storeChanges: Set<() => void>; dispatch: (callback: (state: T) => T) => void; subscribe: (onStoreChange: () => void) => () => void; }; export const createStoreContext = <T,>(initState: () => T) => { const context = useRef<ContextType<T>>({ state: initState(), storeChanges: new Set(), dispatch: (callback) => { context.state = callback(context.state); context.storeChanges.forEach((storeChange) => storeChange()); }, subscribe: (onStoreChange) => { context.storeChanges.add(onStoreChange); return () => { context.storeChanges.delete(onStoreChange); }; }, }).current; return context; }; const StoreContext = createContext<ContextType<any>>(undefined as never); export const StoreProvider = <T,>({ children, initState, }: { children: ReactNode; initState: () => T; }) => { const context = createStoreContext(initState); return ( <StoreContext.Provider value={context}>{children}</StoreContext.Provider> ); }; export const useSelector = <T, R>(getSnapshot: (state: T) => R) => { const context = useContext<ContextType<T>>(StoreContext); return useSyncExternalStore( context.subscribe, () => getSnapshot(context.state), () => getSnapshot(context.state) ); }; export const useDispatch = <T,>() => { const context = useContext<ContextType<T>>(StoreContext); return context.dispatch; };

使い方はこんな形です。

type StateType = { a: number; b: number; c: number }; const A = () => { const value = useSelector((state: StateType) => state.a); return <div>A:{value}</div>; }; const B = () => { const value = useSelector((state: StateType) => state.b); return <div>B:{value}</div>; }; const C = () => { const value = useSelector((state: StateType) => state.c); return <div>C:{value}</div>; }; const Buttons = () => { const dispatch = useDispatch<StateType>(); return ( <div> <button onClick={() => dispatch((state) => ({ ...state, a: state.a + 1 }))} > A </button> <button onClick={() => dispatch((state) => ({ ...state, b: state.b + 1 }))} > B </button> <button onClick={() => dispatch((state) => ({ ...state, c: state.c + 1 }))} > C </button> </div> ); }; const Page = () => { return ( <StoreProvider initState={() => ({ a: 0, b: 10, c: 100, })} > <A /> <B /> <C /> <Buttons /> </StoreProvider> ); }; export default Page;

Context を配る必要が無くなり、コード量が少なくなりました。

useSyncExternalStore を使わずに useState 同じ事をしてみる

実はuseStateの dispatch を収集する構造を作れば、useSyncExternalStoreと同じ事が可能です。コード量はほとんど同じです。

import { useRef, createContext, ReactNode, useContext, useState, Dispatch, SetStateAction, useEffect, } from "react"; type ContextType<T> = { state: T; storeChanges: Map<Dispatch<SetStateAction<any>>, (state: T) => unknown>; dispatch: (callback: (state: T) => T) => void; }; const createStoreContext = <T,>(initState: () => T) => { const context = useRef<ContextType<T>>({ state: initState(), storeChanges: new Map(), dispatch: (callback) => { context.state = callback(context.state); context.storeChanges.forEach((callback, storeChange) => storeChange(callback(context.state)) ); }, }).current; return context; }; const StoreContext = createContext<ContextType<any>>(undefined as never); const StoreProvider = <T,>({ children, initState, }: { children: ReactNode; initState: () => T; }) => { const context = createStoreContext(initState); return ( <StoreContext.Provider value={context}>{children}</StoreContext.Provider> ); }; const useSelector = <T, R>(getSnapshot: (state: T) => R) => { const context = useContext<ContextType<T>>(StoreContext); const [state, dispatch] = useState(() => getSnapshot(context.state)); context.storeChanges.set(dispatch, getSnapshot); useEffect(() => { context.storeChanges.set(dispatch, getSnapshot); return () => { context.storeChanges.delete(dispatch); }; }, [context, getSnapshot]); return state; }; const useDispatch = <T,>() => { const context = useContext<ContextType<T>>(StoreContext); return context.dispatch; };

使い方も前のサンプルと同じです。

まとめ

State ライブラリを作るときは公式でuseSyncExternalStoreが推奨されているので、必要とあらば使うようにしていますが、ぶっちゃけ無くても何にも困りません。

useSyncExternalStoreの使い道としては、外部ライブラリを入れるほどでも無いけれど、局地的に再レンダリングする場所を調整したい場合などに使うと良いかもしれません。