🤝

ReactでもVueのように簡単にグローバルな状態を管理したい

2024/04/06に公開8

はじめに

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>


Vue SFC Playground で確認


一方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

Honey32Honey32

失礼します。おそらく、代わりに useSyncExternalStore を使う必要があると思います。

僕自身、深く理解していないのですが、以下の記事によると、その方法ではトランジションに対応できないようです。(GlobalState.useState は、 this._state を返していますが、これは実質的に useRef で保持した値を使っているのと同じだと思います。)

https://qiita.com/uhyo/items/6a3b14950c1ef6974024

代わりに、valitio, jotai のようなグローバル状態管理用ライブラリを使用するか、自前で書きたいなら useSyncExternalStore を使うべきだと思います。

https://ja.react.dev/reference/react/useSyncExternalStore

おおよそ以下のようなクラスを用意すれば、 useSyncExternalStore(store.subscribe, store.getSnaphot) で正常な方法で状態を参照することが可能になるはずです。

class GlobalStateStore<T> {
  #listeners: Set<() => void> = new Set();

  subscribe(): (() => void) {
    // 省略: リスナーを登録
    return () => {
      // 省略: 登録したリスナーを解除
    }
  }

  getSnapshot(): T {
    // 省略: 現在の状態を取得する
  }
}
achamaroachamaro

コメントありがとうございます。

僕自身、深く理解していないのですが、以下の記事によると、その方法ではトランジションに対応できないようです。(GlobalState.useState は、 this._state を返していますが、これは実質的に useRef で保持した値を使っているのと同じだと思います。)

たしかに this._state を返すとトランジションに対応できませんでした。
ここについては私の理解が浅く、useStateが単に現在の値を返していると勘違いしていたため(state === this._state だと思っていた)、 なんとなく this._state を返していたのですが、useState が返す値をそのまま返すことで、同じように動作しました。こちらについては記事を修正しておきます。

代わりに、valitio, jotai のようなグローバル状態管理用ライブラリを使用するか、自前で書きたいなら useSyncExternalStore を使うべきだと思います。

趣旨としてはVueのComposition APIのように、外部ライブラリを使わずにやるというところにありました。
useSyncExternalStore といものを知らなかったので、こちらを使ったものも書いて見たのですが先にご指摘いただいたトランジションに対応でき無さそうでした。
(やり方が悪いかもしれないのでもしご興味があればご自身でも試して見てください。)

フォークして追記したものを載せておきます。
https://codesandbox.io/p/sandbox/hardcore-keldysh-ysf7dv

achamaroachamaro

GlobalState.useState は、 this._state を返していますが、これは実質的に useRef で保持した値を使っているのと同じだと思います。

迷ったけど、この記事の内容が引用されているアンチパターンだと誤解を招くと良くないのでここだけちゃんと否定しておこうと思います。

もともと本質的にはuseStateを使った状態管理でありでアンチパターンとされているuseRefを使ったコードには該当しないと思います。

実質的には確かにそうなので修正しました。
ご指摘ありがとうございます😋

Honey32Honey32

いいですね! this._state ではなく、useState で管理している state を返すようになったことで、僕もよく見たことのあるパターン(ここでは 「useEffect でサブスクライブするパターン」と呼んでおきます) になったと思います。


https://ja.react.dev/reference/react/useSyncExternalStore#reference

ただ、上記のドキュメントを見ていると、「外部ストアの更新をトランジションとして扱うこと自体が不可能」となっていました。achamaro さまの検証はこれに合っているので、uhyo さんの記事のほうが少し怪しい気がしてきました。

useSyncExternalStore が必要になる背景として、( uhyo さんの記事では「サスペンスのloading表示を出さずにトランジションとして扱えるかどうか」の話になっていましたが、) 実際には 「tearing」という現象が起きるかどうか、 つまり一つのストアを複数箇所で購読している場合に、どこかで描画の中止が関わって来たりするときの状態の非一貫が起こるかどうかが問題のようです。

(なので、少なくとも、購読が単一の箇所だけで発生する今回のケースでは問題が起こらないはず…?)

申し訳ないですが、「useEffect でサブスクライブするパターン」と useSyncExternalStore について、tearing の観点でどちらかが有利なのかが結論を出すべきですが、僕の理解がここで限界なので分かりませんでした🙇

(もし気になるなら、dai-shi 氏は jotai の作者でもあるので直接聞かれても良いかもしれません)

https://twitter.com/KoharaKazuya/status/1343883148195557376

https://github.com/reactwg/react-18/discussions/69

achamaroachamaro

申し訳ないですが、「useEffect でサブスクライブするパターン」と useSyncExternalStore について、tearing の観点でどちらかが有利なのかが結論を出すべきですが、僕の理解がここで限界なので分かりませんでした🙇

どちらが有利かはわからないままですが、他のライブラリと同じような結果が(速度面でも)出るようなので、このテストに関しては useEffect で問題なさそうです。


一番下 global-state

https://github.com/achamaro/will-this-react-global-state-work-in-concurrent-rendering

個人的に気になっていたのはuseStateからuseEffectに渡した関数が実行される直前(settersにsetStateが登録される直前)までの間にグローバルステートが更新された場合にその値が反映されないかもなところですが、多少のパフォーマンスを犠牲に this.setters.add() をuseStateの直後に移動すれば解決するのでそこまで深くは考えていませんでした。

詳しくコメントいただいたおかげで色々と勉強できました。
改めて有難うございます😋

Hirotomo YamadaHirotomo Yamada

どうぞ、なにか良いアイデアがあらん事を。

https://github.com/yamada-ui/yamada-ui/blob/main/packages/components/notice/src/notice.tsx

achamaroachamaro

ちょっと意図を読み取れずにいるのですが、先の一連のコメントにある通り、やはりリンク先で使われている useSyncExternalStore を使ったものではトランジションとやらに対応出来ないようです。

他のコードに合わせてリンク先のストアから必要な部分だけを抜き出したものを追加してみました。
https://codesandbox.io/p/sandbox/hardcore-keldysh-ysf7dv