🏬

Reactに追加されるuse-sync-external-storeを使えば誰でも簡単にオレオレstate管理できますよという話

2022/03/08に公開

https://github.com/hachibeeDI/nozuchi npm化

日本語で紹介している記事をみかけなかったので書いてみました。何か間違いがあったらごめんね。
なお、まだundocumentedなAPIなのでインターフェースがころっと変わる危険性があります。

https://www.npmjs.com/package/use-sync-external-store
ひとまずは↑のpolyfillを経由しておきましょう。

このAPIですが、その名の通り外部のStore(subscribableなオブジェクトのことです)とcomponentを同期させるライブラリです。
React使いはState管理機構を自作する宿命を背負って生まれてきているため、今までは各々のやり方で同期させる処理を書いていたとおもうのですが、晴れて公式のツールが誕生するわけですね。

Storeの実装例

use-sync-external-store には残念ながらちゃんとしたドキュメントはありませんが、やりたいことは明確ですし内部の実装もたいして難しくないので察してあげるコミュニケーションが可能です。

ひとまず、いくつか大事な要件がありますのでそこだけ押さえてあげます。

  • Storeはsubscribable(observable)であること
  • getSnapshotの結果はキャッシュされている(同一処理内であれば何度呼び出しても結果が同じであること)

いくつかといいましたが、この二つだけです。

今回、observableに関しては古式ゆかしいEventEmitterを使い、キャッシュに関してはクロージャーのキャプチャを利用しようとおもいます。

海外の記事だとキャッシュ機構を用意していたりしますが、universal対応を考えないのであればこれで十分だとおもいます。
逆にサーバーサイドでも使いたいのであればmemcache的なものをプラグインできるようにしたほうがいいのかもしれない?

import {EventEmitter} from 'events';

import {useSyncExternalStore} from 'use-sync-external-store/shim';
import {useSyncExternalStoreWithSelector} from 'use-sync-external-store/shim/with-selector';

export class Subscribable<State> {
  private readonly evt = new EventEmitter();

  private state: State;

  public getState = () => {
    return this.state;
  };

  constructor(initialState: State) {
    // 数に特に根拠はない。0とかで無制限にしてもいいかも
    this.evt.setMaxListeners(999);
    this.state = initialState;
  }

  public subscribe = (sub: (state: State) => void) => {
    const innerSub = () => {
      sub(this.state);
    };
    this.evt.on('update', innerSub);
    return () => this.evt.removeListener('update', innerSub);
  };

  public update = (newState: State) => {
    this.state = newState;
    this.evt.emit('update');
    return this.state;
  };
}

type Updater<State> = (prev: State) => State;
type Setter<State> = Updater<State> | ((updater: Updater<State>) => void);

type BehaviorReturn<State> = (s: State) => State | ((s: State) => Promise<State>);
type Behavior<State, Args extends ReadonlyArray<any>> = (...args: Args) => BehaviorReturn<State>;

export type Subscriber<State, Behaviors extends Record<string, Behavior<State, any>>> = {
  useSelector: <Selection>(selector: (s: State) => Selection, isEqual?: (a: Selection, b: Selection) => boolean) => Selection;
  useState(): readonly [State, Setter<State>];
  getState(): State;
  setState: Setter<State>;
  actions: {[P in keyof Behaviors]: (...args: Parameters<Behaviors[P]>) => void};
};

export function createStore<State, Behaviors extends Record<string, Behavior<State, any>>>(
  initialState: State,
  behaviors: Behaviors,
): Subscriber<State, Behaviors> {
  const sub = new Subscribable(initialState);

  const setState: Setter<State> = (sOrUpdate: State | Updater<State>) => {
    if (typeof sOrUpdate === 'function') {
      const prev = sub.getState();
      const nextState = (sOrUpdate as Updater<State>)(prev);
      return sub.update(nextState);
    }
    return sub.update(sOrUpdate);
  };

  return {
    useSelector: <Selection>(selector: (s: State) => Selection, isEqual?: (a: Selection, b: Selection) => boolean) => {
      return useSyncExternalStoreWithSelector(sub.subscribe, sub.getState, sub.getState, selector, isEqual);
    },

    actions: new Proxy(behaviors, {
      /**
       * behaviorで定義した (a: Args) => (s: State) => State
       * の処理をインタラプトしてstateの更新を挟む
       */
      get(target, name) {
        if (name in target === false) {
          throw new TypeError(`method ${name.toString()} is not defined in store`);
        }
        const method: (...a: ReadonlyArray<any>) => any = target[name as string] as any;
        return (...args: ReadonlyArray<any>) => {
          // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
          const updater: (s: State) => State = method(...args);
          const nextState = updater(sub.getState());
          sub.update(nextState);
          return nextState;
        };
      },
    }),

    /**
     * ```typescript
     * shim of `useState` of React.
     * ```
     */
    useState() {
      const s = useSyncExternalStore(sub.subscribe, sub.getState, sub.getState);
      return [s, setState] as const;
    },
    getState: sub.getState,
    setState,
  };
}

setStateのインターフェースをuseStateに似せていたり、actionsでProxy経由であれこれしたりちょっとオシャレをこいてみましたが、基本コンセプトは実にシンプルだということがおわかりいただけるはずです。

サンプル

さて、ためしに使ってみましょう。

const initialStateA = Object.freeze({
  a: 'a',
  b: 'b',
  c: 1,
});

export const sampleStore = createStore(initialStateA, {
  reset: () => () => initialStateA,
  init: (a: string, b: string, c: number) => () => ({
    a,
    b,
    c,
  }),
  changeA: (a: string) => (s) => ({...s, a}),
});

console.log(sampleStore.getState());

export default function SampleComponent(_: unknown) {
  const stateA = sampleStore.useSelector((s) => s.a);

  return (
    <div>
      <button onClick={() => sampleStore.actions.changeA('aでした')}>おせ!</button>
      <p>{stateA}</p>
    </div>
  );
}

はい。ちゃんと動きましたね。
このSampleComponentを複数レンダリングすると、全てが同一のStoreを監視していることがちゃんとわかるかとおもいます。

ざっくり実装のわりにcreateStoreの型推論がかなりしっかり動くので筆者もおもわず自画自賛しました。

既存実装からの優位点

vs 自作

vs 自作については明らかですね。
求められるスペックが自明なのであれば皆で同じものを共有したほうが良いです。

vs Context

自作の範疇に入るのですが、Contextと比べたときの優位点はなんでしょうか。

先の例でしれっと使っていますが、use-sync-external-storeにはselectorが用意されています。
import {useSyncExternalStoreWithSelector} from 'use-sync-external-store/shim/with-selector'; ですね。

Contextの再レンダリングの判断はContextに渡したオブジェクトそのもので行われます。
つまりどこか一部分でも更新された場合、そのContextをobserveしているComponent全てが再レンダリングされることになります。
これを防ぐためにはHoCを使う必要があります(初期のReduxがHoCを使っていたのはこのためですね)。

selector hookを実装するのはかなり面倒なので、ここを面倒見てくれる公式のやり方が生まれるのはとても良いことです。

ちなみに古いバージョンの useSyncExternalStoreWithSelector にはselectorがNaNを返すとワーニングが出るバグがあったのでPRを送ってみたら速攻でマージしてもらえました。Reactチームありがとう。

ちなみにContextにWithSelectorを生やす提案もでていたりしますが、議論を追っていないのでどうなるかは不明です。

まとめ

  • 誰でも超簡単にStoreとSelectorのhooksを作れるようになった
  • useSyncExternalStoreの例のために一応useStateを作ってみたけど、Componentがstoreを直接参照するのはアンチパターンなので全てselectorを経由すべき
  • この筆者は外に出さなければanyは使ってもいいと考えている
  • この筆者はサンプルで利用しない部分の機能も作っており、YNGNIの原則を無視している
  • この筆者はState管理ライブラリの自作を推奨している、過激分子だった

素晴らしいですね。これからはみなさんも自分のプロジェクトのState管理を自作でやりたくなったとおもいます。

こちらからは以上です。

Discussion