Fecebook が新しく発表した Recoil について

自分の学習手順

これは自分が写経しながら書いた型定義。色々足りてないがチュートリアルで出る範囲は理解できる。

declare module "recoil" {
  export type RecoilState<T> = {};
  export const RecoilRoot: React.ComponentType<{
    initializeState?: (options: {
      set: <T>(recoilVal: RecoilState<T>, newVal: T) => void;
      setUnvalidatedAtomValues: (atomMap: Map<string, unknown>) => void;
      dangerouslyAllowMutability?: boolean;
    }) => void;
    children: any;
  }>;
  export function atom<T>(input: {
    key: string;
    default: ValueType;
  }): RecoilState<T>;
  export function selector<T>(input: {
    key: string;
    get(helpers: {
      get<U>(atom: RecoilState<U>): U;
      getPromise<U>(atom: RecoilState<U>): Promise<U>;
    }): T;
    set?(
      helpers: {
        set<U>(atom: RecoilState<U>, newVal: U): void;
      },
      newVal: T
    ): void;
  });
  export function useRecoilValue<T>(atom: RecoilState<T>): T;
  export function useRecoilState<T>(
    atom: RecoilState<T>
  ): [T, (action: React.SetStateAction<T>) => void];
  export function useSetRecoilState<T>(
    atom: RecoilState<T>
  ): (action: React.SetStateAction<T>);
}

DefinitelyTyped に PR が出てるが、まだマージされてない。

Add type definitions for recoil by csantos42 · Pull Request #44756 · DefinitelyTyped/DefinitelyTyped

後述する waitForAll などのユーティリティが書きかけ。

思想的な部分

  • Redux は常に一つでかつすべての状態ありきの思想なので、State とその手続きが宣言される。このせいで、常に使わない State も初期化しないといけない
  • Recoil は状態を依存グラフで表現する。atom とそれを参照する selector があり、selector が atom を購読して反映される

また、 selector への set で atom を同期/非同期に書き換えるというインターフェースになっている。実体は atom を実体とした単方向サブスクリプションだが、コード上は双方向にもなりうる。

selector が mutable

atom と selector も、 const [state, setState] = useRecoilState(atom_or_selector) できる。 selector が setState できる、とはどううことだろうか。

単一な状態を持つ atom だけではなく、グラフ中で selector ノードも、まるで Mutable かのような API を持つ。自分自身への更新時、非同期に個別の atom への set を再発行できるファサードになっている。

公式サンプルからの引用だが、次のコードは華氏と摂氏の二値が連動して動く。

import {
  RecoilRoot,
  atom,
  selector,
  useRecoilState,
  useRecoilValue,
} from "recoil";

const tempFahrenheit = atom<number>({
  key: "tempFahrenheit",
  default: 32,
});

const tempCelcius = selector<number>({
  key: "tempCelcius",
  get: ({ get }) => ((get(tempFahrenheit) - 32) * 5) / 9,
  set: ({ set }, newValue) => set(tempFahrenheit, (newValue * 9) / 5 + 32),
});

function TempCelcius() {
  const [tempF, setTempF] = useRecoilState<number>(tempFahrenheit);
  const [tempC, setTempC] = useRecoilState<number>(tempCelcius);

  const addTenCelcius = () => setTempC(tempC + 10);
  const addTenFahrenheit = () => setTempF(tempF + 10);

  return (
    <div>
      Temp (Celcius): {tempC}
      <br />
      Temp (Fahrenheit): {tempF}
      <br />
      <button onClick={addTenCelcius}>Add 10 Celcius</button>
      <br />
      <button onClick={addTenFahrenheit}>Add 10 Fahrenheit</button>
    </div>
  );
}

元となる状態自体は tempFahrenheit が atom で、その selector としての tempCelcius だが、tempCelcius への set で、 tempFahrenheit を書き換えて、値を反映している。

これは正直、議論が分かれそうな設計だと思っていて、 vue の computed property などに近いようにみえて、computed property には不可能な副作用も記述できるが、その実装が正しいかどうかは、実装者が責任を持つことになるだろう。

単なる set では atom を直接書き換えたほうがきれいだと思うが、ここで面白いのは、set の実装が非同期の Promise を取れるということだ。実質 redux middleware で多段 dispatch するときと同じようなコードになる。

非同期な state と Suspense

ここで state / selector は非同期を取れるので、 get / set は async/await のインターフェースをとることができる。

1 秒後に値を表示する例

const lazyState = selector({
  key: "lazyState",
  get: async () => {
    await new Promise((r) => setTimeout(r, 1000));
    return 1;
  },
});

function LazyValue() {
  const value = useRecoilValue<number>(lazyState);
  return <div>{value}</div>;
}

function App() {
  return (
    <RecoilRoot>
      <Suspense fallback="loading">
        <LazyValue />
      </Suspense>
    </RecoilRoot>
  );
}

ReactDOM.render(<App />, document.querySelector("main"));

実装をみると、最初の useRecoilValuethrow new Promise(...) を発行し、 Suspense にキャッチさせて解決させるやつ。

これを使うと、ネットワーク越しのリソースを抽象したりすることができそう。

Redux との比較

  • 大域の再計算にならないので、React Component から参照されるときの再計算が、最小限
  • 必要なコードだけビルドに含めることができる
  • 状態更新の手続きは reducer ではなく、setState の React.SetStateAction 準拠
  • 非同期抽象が middleware ではなく、 promise + suspense になる

自分がまだわかってないところ

Redux では常にひとつの状態が全部の状態を表すので、SSR で渡したり、 localStorage に状態を書き込んでから、再訪時に状態を復元する、というのが容易だった。Recoil では、RecoilRoot がすべての状態を管理しているはずだが、それを吐き出したり、よみこんだりする方法が(まだ)ない。

今ちょうどリロードしたらドキュメントに Core 以外の Utils というのが生えて、この辺の waitForAll にその機能がありそうなので、しばらく待ったほうがよさそう。

https://recoiljs.org/docs/api-reference/utils/waitForAll

可能なら React に依存せず、Recoil のリソースの依存グラフだけで実行できると、サーバー上で hydration のために初期実行できて、嬉しい気がする。

で、結局使い物になるの?

  • 自分的にはアリ。ただし、selector への set は、非同期のユースケースを限定したほうが良さそう
  • 状態をダンプする系の API は足りてない。
History
8bc249a - Update Sun May 17 22:43:59 2020 +0900 
56de972 - Recoil Sat May 16 20:46:35 2020 +0900 
4b7592a - Recoil Sat May 16 20:38:03 2020 +0900