useSyncExternalStore使ってる?
何かと使い方が分かりずらい(と個人的に思っている)useSyncExternalStoreですが、今回はその挙動を見てみます。Reactのソースコードを読んだとかそんな詳しいところまで追っていなくて挙動を整理する記事です。
ブラウザのwindow幅をReactに同期させる
まずは公式や他の記事でよく紹介されているブラウザのAPIとReactを同期させる例です。window
幅を変えるとそれに反応してview
が更新され、値が増えたり減ったりするのが描画されます。
function getSnapshot() {
return window.innerWidth;
}
function subscribe(callback: () => void) {
window.addEventListener("resize", callback);
return () => window.removeEventListener("resize", callback);
}
const Root = () => {
const innerWidth = useSyncExternalStore(subscribe, getSnapshot);
return (
<div>innerWidth: {innerWidth}</div>
);
};
単純な例ですが、window
が変わったことを検知してReactのレンダリングを発火させている重要な例です。重要だと思うのでもう少し詳しく見ていきます。
window.innerWidth
は普通に参照した場合、Reactによる再レンダリングがされないためwindow
幅を変えたところでview
は更新されません。
const Root = () => {
/* 初回の値が表示されるだけ */
return <div>innerWidth: {window.innerWidth}</div>;
};
useSyncExternalStore
の一つ目の引数に渡しているsubscribe
関数はuseSyncExternalStore
からcallbackを受け取っています。これをちょっとconsole.log
で見てみます。
function subscribe(callback: () => void) {
console.log("callback", callback);
window.addEventListener("resize", callback);
return () => window.removeEventListener("resize", callback);
}
いかにもコンポーネントを再レンダリングしてくれそうな関数ですね。
つまりsubscribe
関数は、useSyncExternalStore
を実行しているコンポーネントを再レンダリングする関数(callback)をwindow幅が変わるたびに実行するようにしています。そしてgetSnapshot
は単純に現在のwindow
幅を返しているだけです。
挙動としては、widow幅が変わるたびにcallbackによりuseSyncExternalStore
を実行しているコンポーネントの再レンダリングを行い、再レンダリングが起きたタイミングでgetSnapshot
により現在のwindow幅を取得しています。再レンダリングが起きるとviewが更新されるため、そのタイミングでgetSnapshot
で参照している値にviewが変わっています。
(どの例を見てもcallbackという名前になっているので分かりやすさを重視するならrerenderThisComponent
(分かりやすい?)みたいな方がわかりやすいなと思いました。)
getSnapshot
の面白い、便利な挙動があったのでもう一つ例を見てみます。
自作Storeを作成している例
useSyncExternalStoreを使って自作Storeを作っている記事を参考にしています。
このStoreの例で確認したいことは以下の二つです。
- グローバルステートの実現方法
- getSnapshotによる最適化
グローバルステートをどう実現しているのか?
グローバルステートはアプリケーション内のあらゆる場所に存在するコンポーネントから単一のstate
を参照して、そのstate
が更新されたら参照しているコンポーネントを全て再レンダリングさせる役割をもつかと思います。これを上記の自作Storeの例ではPub/Subで実装しています。
dispatch
がpublisher
でsubscibe
がsubsciber
に相当するかと思います。subscribe
はonStoreChange
という関数を受け取ってそれをSetオブジェクトにaddしています。これは単純に配列にpush
しているイメージかなと思います。dispatch
はstate
を任意の値に変化させた後に、subscribe
でadd
していた関数を全て実行しています。これでsubscribe
で登録していた関数をdispatch
を実行したタイミングで全て実行する仕組みができました。
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);
};
},
この仕組みの上にReactコンポーネントを再レンダリングする仕組みをどう乗せているかという部分でuseSyncExternalStore
が登場します。
useSelector
は次のようになっています。
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)
);
};
context.subscribe
することで記事の最初の例で確認したコンポーネントを再レンダリングする関数をSetオブジェクトにadd
しています(getSnapshot
については後述します)。useSelector
したコンポーネントの数だけそのコンポーネントを再レンダリングする関数がadd
されることになります。
そしてuseDispatch
は次のようになっています。
export const useDispatch = <T,>() => {
const context = useContext<ContextType<T>>(StoreContext);
return context.dispatch;
};
単純にcontext.dispatch
を返しているだけです。useDispatch
から受け取った関数は次のようにstate
の値を変える関数を渡して実行されます。
const dispatch = useDispatch<User>();
const renameUser = () => {
dispatch((prev) => {
return { ...prev, name: getRandomName() };
});
};
これでdispatch
が実行されるとstate
の値が変わることに加え、各コンポーネントからadd
されていた「コンポーネントを再レンダリングする関数」が全て実行されるようになりました。
getSnapshotによる最適化
もう一度useSelector
と合わせてコンポーネントでの使われ方を見てみます。getSnapshot
は最初の例で示した通り、useSyncExternalStoreの
一つ目の引数(コンポーネントを再レンダリングする関数)により再レンダリングが起きたタイミングで、getSnapshot
により現在のstate
の値を取得します。useDispatch
でcontext.dispatch
が実行されることで、state
の値が変わった後に再レンダリングが起きるため、更新後のstate
の値を参照しています。
この例ではstate
の値がオブジェクトであることを想定していますが、useSelector
に渡しているコールバック関数ではオブジェクト全部を返すわけではなくプロパティの値を返すようにしています。useSyncExternalStore
の二つ(三つ)目の引数にはオブジェクトからプロパティを絞って値を取得する関数を渡していることになります。
/* useSelectorの実装 */
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)
);
};
/* コンポーネントでのuseSelectorの利用 */
const DisplayUserName = () => {
const name = useSelector((state: User) => state.name);
return (
<div>
<p>ユーザー名: {name}</p>
</div>
);
};
上記のようにgetSnapshot
を実装することでコンポーネントでオブジェクトのstate
を参照していても、参照しているプロパティに更新があった時のみコンポーネントを再レンダリングすることができます。
以下はボタンを押してユーザーの名前を更新する例ですが、名前を表示するDisplayUserName
コンポーネントだけが再レンダリングします。
import { useCallback } from "react";
import { StoreProvider, useDispatch, useSelector } from "./store";
import "./App.css";
type User = {
id: number;
isAdmin: boolean;
name: string;
email: string;
};
const initUser: User = {
id: 1,
isAdmin: true,
name: "anonymous",
email: "anonymous@anonymous.com",
};
function getRandomName() {
const names = [
"John",
"Jane",
"Alice",
"Bob",
"Charlie",
"Dave",
"Eve",
"Frank",
"Grace",
"Hank",
];
const randomIndex = Math.floor(Math.random() * names.length);
return names[randomIndex];
}
const Button = () => {
console.log("render ボタン");
const dispatch = useDispatch<User>();
const renameUser = useCallback(() => {
dispatch((prev) => {
return { ...prev, name: getRandomName() };
});
}, [dispatch]);
return (
<div>
<button onClick={renameUser}>Rename</button>
</div>
);
};
const DisplayUserName = () => {
console.log("render user name");
const name = useSelector((state: User) => state.name);
return (
<div>
<p>ユーザー名: {name}</p>
{/* <DisplayCountDepthOne /> */}
</div>
);
};
const DisplayUserEmail = () => {
console.log("render user email");
const email = useSelector((state: User) => state.email);
return (
<div>
<p>ユーザーのメールアドレス: {email}</p>
</div>
);
};
/*
DisplayUserName, DisplayUserEmailは同じオブジェクトを参照するが、
参照しているプロパティが更新された時のみ各々のコンポーネントが再レンダリングする
*/
const App = () => {
return (
<div className="App">
<Button />
<DisplayUserName />
<DisplayUserEmail />
</div>
);
};
const Root = () => {
return (
<StoreProvider initState={() => initUser}>
<App />
</StoreProvider>
);
};
export default Root;
ということでgetSnapshot
から返される値が変わらなかった場合は再レンダリングがスキップされる例でした。
まとめ
useSyncExternalStore
は公式のReferenceでも解説されていますが、外部ストアへsubscribe
するという表現がピンと来なかったため挙動を確認しました。Reactのライフサイクルに乗らない値が更新されたタイミングでコンポーネントの再レンダリングをして、viewにも値を反映できるようにするということかなと思います。Reactのライフサイクルに乗らないけど乗せたい状況は意外と多いと思うのため活用できる場面は割と多そうだと思いました。
Discussion