🤝
ReactでもVueのように簡単にグローバルな状態を管理したい
はじめに
VueのComposition APIはとても優秀で、単純なものであればプロバイダーや状態管理ライブラリを使用することなくグローバルな状態を簡単に管理することができます。
これはVueがComposition API呼び出し時ではなく、リアクティブな値を参照したタイミングで参照元を依存関係に自動的に追加し、値が変更されたタイミングで依存関係にあるコンポーネントが再レンダリングされるようになっている為です。
composable.ts
import { ref } from "vue";
export const state = ref(false);
check.vue
<script setup lang="ts">
import { state } from "./composable";
</script>
<template>
<input type="checkbox" v-model="state">
</template>
App.vue
<script setup>
import { state } from "./composable";
import Check from "./check.vue";
</script>
<template>
<input type="checkbox" v-model="state" />
<Check />
<Check />
</template>
一方ReactではHooks APIの呼び出し元のコンポーネントが紐づいたディスパッチャーが返されるため、コンポーネントの中から呼び出す必要があります。
const [state, setState] = useState(false);
// React Hook "useState" cannot be called at the top level. React Hooks must be called in a React function component or a custom React Hook function.eslintreact-hooks/rules-of-hooks
使いどころ
このどこからでも更新でき、どこから参照しても同じ値が返ってくるグローバルな状態は、同じくアプリ上のどこからでもアクセスしたいローディングやアラートダイアログのようなグローバルなコンポーネントの管理をするのに便利です。
loading.vue
<script setup lang="ts">
import { loading } from "./loading";
</script>
<template>
<teleport>
<div v-if="loading" class="loading"></div>
</teleport>
</template>
form.vue
<script setup lang="ts">
import { loading } from "./loading";
async function handleSubmit() {
loading.value = true;
//...
loading.value = false;
}
</script>
<template>
<button @click="handleSubmit()">Submit</button>
</template>
Reactで実装する
ということで、ないものは作りましょう。
Reactコンポーネントを再レンダリングさせるには、コンポーネント内から useState()
を呼び出す事で取得できるディスパッチャーを使って値を更新します。
そこでこのディスパッチャーを保存しておき、値を更新するときにこの保存しておいたディスパッチャーを呼び出せば再レンダリングされます。
そしてあらかじめ作っておいたものがこちらです。
global-state.ts
import { Dispatch, SetStateAction, useEffect, useState } from "react";
export class GlobalState<T> {
private setters: Set<Dispatch<SetStateAction<T>>> = new Set();
constructor(private _state: T) {}
setState(state: SetStateAction<T>) {
if (typeof state === "function") {
this._state = (state as (prevState: T) => T)(this._state);
} else {
this._state = state;
}
this.setters.forEach((setState) => setState(this._state));
}
useState() {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [state, setState] = useState<T>(this._state);
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
this.setters.add(setState);
return () => {
this.setters.delete(setState);
};
}, []);
return state;
}
}
これをグローバルな状態として new
します。
そして値の参照は .useState()
、更新は .setState()
を使います。
alert-dialog.tsx
import { GlobalState } from "./global-state";
// ...
const state = new GlobalState<Alert[]>([]);
export async function confirm(/* ... */) {
return new Promise((r) => {
const resolve = (arg: boolean) => {
// アラートを削除
state.setState((current) => current.filter((it) => it !== alert));
// プロミスを解決
r(arg);
};
const alert = {
// ...
resolve,
};
// アラートを追加
state.setState((current) => [...current, alert]);
});
}
export default function AlertDialog() {
const dialogs = state.useState();
return (
<div>
{dialogs.map(({ resolve }) => (
<div>
<button onClick={() => resovle(false)}>キャンセル</button>
<button onClick={() => resovle(true)}>OK</button>
</div>
))}
</div>
);
}
これでどこからでも呼び出せる確認ダイアログができました。
component.tsx
export default function Comp() {
async function handleSumit() {
if (!(await confirm())) {
return;
}
// ...
}
return <button onClick={handleSubmit}>Submit</button>
}
まとめ
Vueのポテンシャルはまだまだ知られていない
Discussion
失礼します。おそらく、代わりに
useSyncExternalStore
を使う必要があると思います。僕自身、深く理解していないのですが、以下の記事によると、その方法ではトランジションに対応できないようです。(
GlobalState.useState
は、this._state
を返していますが、これは実質的にuseRef
で保持した値を使っているのと同じだと思います。)代わりに、valitio, jotai のようなグローバル状態管理用ライブラリを使用するか、自前で書きたいなら useSyncExternalStore を使うべきだと思います。
おおよそ以下のようなクラスを用意すれば、
useSyncExternalStore(store.subscribe, store.getSnaphot)
で正常な方法で状態を参照することが可能になるはずです。コメントありがとうございます。
たしかに
this._state
を返すとトランジションに対応できませんでした。ここについては私の理解が浅く、useStateが単に現在の値を返していると勘違いしていたため(state === this._state だと思っていた)、 なんとなく
this._state
を返していたのですが、useState が返す値をそのまま返すことで、同じように動作しました。こちらについては記事を修正しておきます。趣旨としてはVueのComposition APIのように、外部ライブラリを使わずにやるというところにありました。
useSyncExternalStore といものを知らなかったので、こちらを使ったものも書いて見たのですが先にご指摘いただいたトランジションに対応でき無さそうでした。
(やり方が悪いかもしれないのでもしご興味があればご自身でも試して見てください。)
フォークして追記したものを載せておきます。
迷ったけど、この記事の内容が引用されているアンチパターンだと誤解を招くと良くないのでここだけちゃんと否定しておこうと思います。
もともと本質的にはuseStateを使った状態管理でありでアンチパターンとされているuseRefを使ったコードには該当しないと思います。
実質的には確かにそうなので修正しました。
ご指摘ありがとうございます😋
いいですね!
this._state
ではなく、useState
で管理しているstate
を返すようになったことで、僕もよく見たことのあるパターン(ここでは 「useEffect でサブスクライブするパターン」と呼んでおきます) になったと思います。ただ、上記のドキュメントを見ていると、「外部ストアの更新をトランジションとして扱うこと自体が不可能」となっていました。achamaro さまの検証はこれに合っているので、uhyo さんの記事のほうが少し怪しい気がしてきました。
useSyncExternalStore が必要になる背景として、( uhyo さんの記事では「サスペンスのloading表示を出さずにトランジションとして扱えるかどうか」の話になっていましたが、) 実際には 「tearing」という現象が起きるかどうか、 つまり一つのストアを複数箇所で購読している場合に、どこかで描画の中止が関わって来たりするときの状態の非一貫が起こるかどうかが問題のようです。
(なので、少なくとも、購読が単一の箇所だけで発生する今回のケースでは問題が起こらないはず…?)
申し訳ないですが、「useEffect でサブスクライブするパターン」と
useSyncExternalStore
について、tearing の観点でどちらかが有利なのかが結論を出すべきですが、僕の理解がここで限界なので分かりませんでした🙇(もし気になるなら、dai-shi 氏は jotai の作者でもあるので直接聞かれても良いかもしれません)
追記: useSyncExternalStore の、useEffect に対する優劣についても、これを見る限りよく分からない
どちらが有利かはわからないままですが、他のライブラリと同じような結果が(速度面でも)出るようなので、このテストに関しては useEffect で問題なさそうです。
一番下 global-state
個人的に気になっていたのはuseStateからuseEffectに渡した関数が実行される直前(settersにsetStateが登録される直前)までの間にグローバルステートが更新された場合にその値が反映されないかもなところですが、多少のパフォーマンスを犠牲に this.setters.add() をuseStateの直後に移動すれば解決するのでそこまで深くは考えていませんでした。
詳しくコメントいただいたおかげで色々と勉強できました。
改めて有難うございます😋
どうぞ、なにか良いアイデアがあらん事を。
https://github.com/yamada-ui/yamada-ui/blob/main/packages/components/notice/src/notice.tsx
ちょっと意図を読み取れずにいるのですが、先の一連のコメントにある通り、やはりリンク先で使われている
useSyncExternalStore
を使ったものではトランジションとやらに対応出来ないようです。他のコードに合わせてリンク先のストアから必要な部分だけを抜き出したものを追加してみました。