Recoilでobjectのatomへの参照をメモ化する

2022/08/23に公開
1

先に結論

概要

Recoilでobjectのatomを扱う際に、そのatomに含まれる特定のpropertyのみを利用したいケースがあります。

type Input = {
  name: string;
  value: string;
};

const inputAtom = atom<Input>({
  key: "inputAtom",
  default: {
    name: "name",
    value: "value"
  }
});

const Value = () => {
  // valueのみを利用したい
  const { value } = useRecoilValue(inputAtom);

  return (<p>Value: {value}</p>)
};

このValueコンポーネントはinputAtomからvalueだけを利用しているつもりですがinputAtomのnameが変更されると再レンダリングされます。

const Form = () => {
  const [input, setInput] = useRecoilState(inputAtom);

  return (
    <form>
      <div>
        <label>name: </label>
        <input
          value={input.name}
          onChange={(e) => {
            setInput((current) => ({ ...current, name: e.target.value }));
          }}
        />
      </div>

      <div>
        <label>value: </label>
        <input
          value={input.value}
          onChange={(e) => {
            setInput((current) => ({ ...current, value: e.target.value }));
          }}
        />
      </div>
    </form>
  );
};

const Value = () => {
  // inputAtom全体が再評価の対象となる
  // たとえvalueの差分がなくとも、inputAtomが変化していれば再評価は行われる
  const { value } = useRecoilValue(inputAtom);

  return (<p>Value: {value}</p>)
};

inputAtomの更新後にはobjectの参照が変化することが理由です。

このようなobjectの特性はイミュータブルの思想としばしば相性が悪いです。

再評価を抑える

atomがobjectの場合にも再評価を抑えるには、atomからユースケースに合わせた最小限のselectorを作ればよいです。
Reduxでもreselectなどでメモ化しますが、同じノリですね。

const valueSelector = selector({
  key: "inputAtom/value",
  get: ({ get }) => get(inputAtom).value
});

const Value = () => {
  // これはinputAtom.valueのみが再評価の対象となる
  const value  = useRecoilValue(valueSelector);

  return (<p>Value: {value}</p>)
};

少々冗長ですが、atomic state managementの思想的にはこれが正なのでしょう。

もっと簡単にメモ化したい

検索条件、URLステート、アプリのグローバルな設定のようなステートはobjectとしてひとまとめに扱うほうが便利なケースがあります。

そのような場合に小さなselectorを作っていくのは管理が大変ですね。
もっといい方法はないのでしょうか?

atomをメモ化するユーティリティをつくった

幸い、Recoilは原始的なAPIの集合です。[1]

ということでメモ化のユーティリティを2つ作りました。

  • memoizedSelector
  • selectAtom

memoizedSelector

  • atomを渡すとメモ化したselectorFamilyを返却する
  • react-hook-formのgetValuesと同じインタフェースで対象propertyのkeyを文字列で指定する
type Input = {
  name: string;
  value: string;
};

const inputAtom = atom<Input>({
  key: "inputAtom",
  default: {
    name: "name",
    value: "value"
  }
});

// `selectInputAtom` will return the selector that created dynamically from inputAtom
const selectInputAtom = memoizedSelector(inputAtom);

// this is equivalent to:
// const valueSelector = selector({
//   key: "inputAtom/value",
//   get: ({ get }) => get(inputAtom).value
// });
const valueSelector = selectInputAtom("value");

https://github.com/koushisa/memoized-recoil-selector/blob/393ea36d86bd87b59f47d04e1cfcfd353ecfdb60/src/lib/recoil/memoizedSelector.ts#L29-L47

selectorFamilyへ渡す文字列の型は、objectの階層が深かったり、配列でも大丈夫です。
この部分の挙動のみを確認する場合は以下にsandboxを用意しています。

https://github.com/koushisa/safe-get-ts

selectAtom

  • Reduxでおなじみな方式のselector
  • atomとそのマッパー関数を渡すと適用された新しいselectorをインラインで返却する
  • 人によってはselectではなく、mapという表現のほうがしっくりくるかもしれません
type Input = {
  name: string;
  value: string;
};

const inputAtom = atom<Input>({
  key: "inputAtom",
  default: {
    name: "name",
    value: "value"
  }
});


// this is equivalent to:
// const valueSelector = selector({
//   key: "inputAtom/value",
//   get: ({ get }) => get(inputAtom).value
// });
const valueSelector = selectAtom(inputAtom, (s) => s.value);

// Inline select
const value = useRecoilValue(selectAtom(inputAtom, (s) => s.value));

https://github.com/koushisa/memoized-recoil-selector/blob/99b297603c6d828c4e5da271b7c613a247ec6106/src/lib/recoil/memoizedSelector.ts#L49-L64

内部の解説

どちらの関数もatomを引数にとり、メモ化した(selector | selectorFamily)を返す関数です。
受け取ったatomをJSON.stringify<->JSON.parseで文字列にシリアライズしたうえで参照することでRecoilのselectorが持つキャッシュ機構に乗っかっています。

また、keyをユニークするために引数の${atom}.keyとnanoidを組み合わせています。

https://github.com/koushisa/memoized-recoil-selector/blob/d90f5c79f529e2fa171d3464cd9ae5c74f11c89d/src/lib/recoil/memoizedSelector.ts#L17-L27

実際に必要になるかはアプリの特性による

ここまで書いておいてアレですが、実際に必要になるかはアプリの特性に左右されます。 [2]

"8割の状態"はライブラリでなんとかなる

そもそも、フロントエンドでコンポーネントを跨いで管理したい状態の8割はServerState, FormStateに分類できるでしょう。
現在はSWR, Tanstack Query, React Hook Formなどのように特定要件に特化しつつも抽象化の筋のよいライブラリがあります。
おそらくほとんどのアプリケーションでは状態管理ライブラリを導入しなくとも開発は可能でしょう。

"2割の状態"こそが本質

ただし、そのような文脈のなかで残った、"2割の状態"こそがプロダクトの本質であり、UI/UXのユニーク性を生むものです。
当然こだわりも強いので、概念も複雑で変更頻度も高いです。ハイパフォーマンスも求められます。
ここの取り扱いがフロントエンドへ複雑性をもたらしていますし、知見も限られています。

そのような"2割の状態"の設計とパフォーマンスを両立させる道には数々の地雷が埋まっています。

ライブラリと上手く付き合う

「ハンマーを持つ人にはすべてが釘に見える」 という言葉があるように手段の目的化を避けるためにも必ずトレードオフを考慮したうえで設計判断を行いましょう。

ライブラリにはコードだけでは表現できない背景が含まれています。
特定のライブラリにアプリケーションが一蓮托生とならないようにご注意ください。

https://scrapbox.io/koushisa/生殺与奪の権を他人に握らせるな

ただしキャッシュ機構やメモ化を自前で作るのは大変なものなので、明確な目的があればライブラリを頼るのがコストパフォーマンスはよいでしょう。

selectorという概念は難しく、使いこなすためには練度が求められますが、データ設計上のメリットがいくつか存在します。

  • キャッシュ機構が搭載されており非同期処理を冪等かつ純粋に処理できる
  • ユースケースの増加にスケールする
  • コンポーネントの独立性を高めて影響範囲を局所化できる
    • データソースや依存関係を透過的に扱える
    • コンポーネント分割やパフォーマンスチューニングを積極的に行える

反面、慎重に使わないと難解なコードを生み出してしまうデメリットもありますが、規模が大きく分割統治が必要となったアプリケーションにおいては便利なのではないでしょうか。

今回は"2割の状態"をパフォーマンスチューニングするプラクティスとしてRecoilでobjectを扱うためのユーティリティの記事を書きました。

まとめ

脚注
  1. これを柔軟性が高いと受け取るか、抽象化の漏れが生じやすいと受け取るかは文脈次第です。 ↩︎

  2. "X割の状態"という表現は勝手ながらこちらから引用させていただきました。https://twitter.com/Nkzn/status/1561516305399701504 ↩︎

Discussion

koushisakoushisa

公開して時間がたったため補足情報をトップに追加