空雲 Blog

Eye catchuseSyncExternalStoreで作る、オレオレStateライブラリ

publication: 2023/04/18
update:2024/02/20

あまり話題にされない useSyncExternalStore

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

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

1import { useRef, useSyncExternalStore } from "react";
2
3export type ContextType<T> = {
4 state: T;
5 storeChanges: Set<() => void>;
6 dispatch: (callback: (state: T) => T) => void;
7 subscribe: (onStoreChange: () => void) => () => void;
8};
9
10export 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};
27
28export const useSelector = <T, R>(
29 context: ContextType<T>,
30 getSnapshot: (state: T) => R
31) =>
32 useSyncExternalStore(
33 context.subscribe,
34 () => getSnapshot(context.state),
35 () => getSnapshot(context.state)
36 );

はい、出来上がりです。これだけでコンポーネントの State の更新を自由自在に操れます。
createStoreContextで共有ステートのメモリ領域の作成と イベント発行に必要な関数を構築しています。
useSelectorではuseSyncExternalStoreを使って共有ステートの値を監視し、対象のデータが更新された場合にコンポーネントが再レンダリングされるようにしています。

では、使い方を見てみましょう。

1type StateType = { a: number; b: number; c: number };
2
3const 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};
15
16const Buttons = ({ context }: { context: ContextType<StateType> }) => {
17 return (
18 <div>
19 <button
20 onClick={() =>
21 context.dispatch((state) => ({ ...state, a: state.a + 1 }))
22 }
23 >
24 A
25 </button>
26 <button
27 onClick={() =>
28 context.dispatch((state) => ({ ...state, b: state.b + 1 }))
29 }
30 >
31 B
32 </button>
33 <button
34 onClick={() =>
35 context.dispatch((state) => ({ ...state, c: state.c + 1 }))
36 }
37 >
38 C
39 </button>
40 </div>
41 );
42};
43
44const 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 の内、コンポーネントが必要とする部分が更新された場合のみ、最小限で再レンダリングされるようになります。

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

Context を配るのが面倒な場合

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

1import {
2 useRef,
3 useSyncExternalStore,
4 createContext,
5 ReactNode,
6 useContext,
7} from "react";
8
9export type ContextType<T> = {
10 state: T;
11 storeChanges: Set<() => void>;
12 dispatch: (callback: (state: T) => T) => void;
13 subscribe: (onStoreChange: () => void) => () => void;
14};
15
16export 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);
28 };
29 },
30 }).current;
31 return context;
32};
33
34const StoreContext = createContext<ContextType<any>>(undefined as never);
35
36export 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};
48
49export 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};
57
58export const useDispatch = <T,>() => {
59 const context = useContext<ContextType<T>>(StoreContext);
60 return context.dispatch;
61};

使い方はこんな形です。

1type StateType = { a: number; b: number; c: number };
2
3const 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};
15
16const Buttons = () => {
17 const dispatch = useDispatch<StateType>();
18 return (
19 <div>
20 <button
21 onClick={() => dispatch((state) => ({ ...state, a: state.a + 1 }))}
22 >
23 A
24 </button>
25 <button
26 onClick={() => dispatch((state) => ({ ...state, b: state.b + 1 }))}
27 >
28 B
29 </button>
30 <button
31 onClick={() => dispatch((state) => ({ ...state, c: state.c + 1 }))}
32 >
33 C
34 </button>
35 </div>
36 );
37};
38
39const Page = () => {
40 return (
41 <StoreProvider
42 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の dispatch を収集する構造を作るだけで、useSyncExternalStoreと同じ事が可能です。コード量のほとんど同じです。

1import {
2 useRef,
3 createContext,
4 ReactNode,
5 useContext,
6 useState,
7 Dispatch,
8 SetStateAction,
9 useEffect,
10} from "react";
11
12type ContextType<T> = {
13 state: T;
14 storeChanges: Map<Dispatch<SetStateAction<any>>, (state: T) => unknown>;
15 dispatch: (callback: (state: T) => T) => void;
16};
17
18const 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};
31
32const StoreContext = createContext<ContextType<any>>(undefined as never);
33
34const 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};
46
47const 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};
59
60const useDispatch = <T,>() => {
61 const context = useContext<ContextType<T>>(StoreContext);
62 return context.dispatch;
63};

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

まとめ

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

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