Subterranean Flower

Reactの実験的ステート管理ライブラリRecoilの基本的な使い方

Author
古都こと
ふよんとえんよぷよぐやま!!!

Reactにおける状態管理の方法論は、様々な道を辿ってきました。ある人はReduxを使い、またある人はMobXを、またある人はuseContextで物事を解決してきたでしょう。

先日、また新しい選択肢が増えました。Facebook公式による状態管理ライブラリRecoilです。 まだExperimental(実験版)なので実際のプロジェクトに導入することは難しいですが、ちょっとつまみ食いをしてみましょう。

Recoil

RecoilはFacebook製のReact状態管理ライブラリです。 小さくシンプルで、Hooksネイティブなライブラリとなっており、非同期処理にも対応している点が特徴です。

まだExperimental(実験版)ということで仕様は大きく変わるかも知れませんし、もしかしたらプロジェクト自体が凍結になるかもしれません。 しかしそれでも触ってみたくなるのが人間というものです。なので今日はRecoilを触ってみましょう。

注意点

2020/05/17時点でRecoilはまだ正式リリースされていません。ここに書かれている内容は大幅に変更されている可能性があります。 実際に使用する際は、各自で最新の情報を追うようにしましょう。

Recoilの導入

まずはプロジェクトにRecoilをインストールします。

npm install --save recoil

現時点で型定義ファイルはありませんが、DefinitelyTypedにPullRequestが出ているので近いうちに取り込まれるでしょう。

あとはRecoilを適用したい範囲を <RecoilRoot> で囲むだけです。一般的にはルートコンポーネントを囲むことになるでしょう。

import React from 'react';
import ReactDOM from 'react-dom';
import { RecoilRoot } from 'recoil';

const App = () => (
  <div>
    Hello!
  </div>
);

ReactDOM.render(
  <RecoilRoot>
    <App/>
  </RecoilRoot>,
  document.getElementById('root')
);

Recoilによる状態管理

Recoilでの状態管理は実にシンプルで、useRecoilState フックを呼び出すだけです。 useState フックと違う点は、生の値を直接扱うのではなく、Atomというステートオブジェクトを通して値を管理するところです。

Atomは atom 関数にプロジェクト全体でユニークなキーとデフォルト値を渡すだけで作れます。

const countState = atom({
  key: 'sample/count', // 適当なユニークキー
  default: 0           // デフォルト値
});

作成したAtomを useRecoilState フックに渡すことで状態を取得できます。あとは useState と同じです。

const Counter = () => {
  // atomから状態を取り出す
  const [count, setCount] = useRecoilState(countState);

  return <div onClick={() => setCount((c) => c + 1)}>Clicked: {count}</div>;
};

以下に例を示します:

import React from 'react';
import ReactDOM from 'react-dom';
import { RecoilRoot, atom, useRecoilState } from 'recoil';

// コンポーネントの外でatomを作成する。
// atomにはユニークなkeyとデフォルト値が含まれる。
const countState = atom({
  key: 'sample/count',
  default: 0
});

const Counter = () => {
  // atomから状態を取り出す
  const [count, setCount] = useRecoilState(countState);

  return <div onClick={() => setCount((c) => c + 1)}>Clicked: {count}</div>;
};

const App = () => (
  <Counter/>
);

ReactDOM.render(
  <RecoilRoot>
    <App/>
  </RecoilRoot>,
  document.getElementById('root')
);

Atomは複数作ることもできますし、ひとつだけにしてReduxのように使うこともできます。 しかし普通は複数のAtomを使うことを想定しているようなので、ある程度は分けるようにしましょう。

read-only / write-only

setXXX関数が不要な場合、 useRecoilValue フックを用いることで値のみを取得できます。 これ自体にはあまり大きな意味はないのですが、ユーティリティフックとして覚えておくと良いでしょう。

const CountDisplay = () => {
  const count = useRecoilValue(countState);
  return <div>{count}</div>;
};

一方、値は読み取らなくてもいいが、書き込みはしたい場合があります。そう言った場合は useSetRecoilState フックを使うと良いでしょう。

Reactにおいて値を読み取らない(setだけする)というのは大きな意味を持ち、値が変化した際も再レンダリングが行われないということになります。 これはパフォーマンスを考える上において有利に働きます。値の読み取りが不要な場合は積極的に利用していきましょう。

const CountUpdater = () => {
  const setCount = useSetRecoilState(countState);
  return <div onClick={() => setCount((c) => c + 1)}>Increment!</div>;
};

Selector

原始的なAtomを直接扱う方法以外にも、Selectorというインターフェイスを通してのアクセスもできます。 SelectorはAtomの値を加工して取得する、加工して更新するなどの処理が可能になります。Atomに関係ないSelectorを作ることも可能です。

Selectorを作るには selector 関数を使います。ユニークなkey、getメソッド、setメソッドを渡します。

以下のようにします:

const liarCountState = selector({
  key: 'sample/liarCount',
  get: ({get}) => get(countState) * 3,
  set: ({get, set}, newValue) => set(countState, newValue)
});

keyについてはユニークなキーを指定してください。

getメソッドはSelectorから値を得る時の処理です。 この例では「嘘つき」カウントのSelectorとなっているので3倍の値を返します。 Selectorの中でAtomから値を得るには、getメソッドに渡される get 関数(ややこしい!)を使用します。 このとき、もしgetメソッドの中の get 関数(紛らわしい!!)でAtomにアクセスしている場合、Atomが更新されると再実行されます。

setメソッドはオプションです。getメソッドだけでも動くので実装しなくとも良いです。実装するとSelectorを通しての値の更新が可能になります。 setメソッドの第1引数には get 関数と set 関数、第2引数には更新したい値が渡されます。 ここでは渡された値をそのままセットしています。

SelectorはAtomと同等に扱うことができます。つまり各種フック(例えば useRecoilState など)にそのまま渡してそのまま使えます。

以下が使用例です:

import React from 'react';
import ReactDOM from 'react-dom';
import {
  RecoilRoot,
  atom,
  selector,
  useRecoilState
} from 'recoil';

const countState = atom({
  key: 'sample/count',
  default: 0
});

const liarCountState = selector({
  key: 'sample/liarCount',
  get: ({get}) => get(countState) * 3,
  set: ({get, set}, newValue) => set(countState, newValue)
});

const Counter = () => {
  const [count, setCount] = useRecoilState(liarCountState);
  return <div onClick={() => setCount((c) => c + 1)}>Clicked: {count}</div>;
};

const App = () => (
  <Counter/>
);

ReactDOM.render(
  <RecoilRoot>
    <App/>
  </RecoilRoot>,
  document.getElementById('root')
);

非同期処理

Recoilでは非同期処理も扱うことができます。やり方はSelectorのgetメソッドをasync関数にするだけです。

const userDataState = selector({
  key: 'sample/userData',
  get: async ({get}) => {
    return wait(1000).then(() => ({name: 'John'}));
  }
});

こうすると、getメソッドが返したPromiseが解決されるまでsuspendされるので、あとは <Suspense> で受けるだけです。 Suspenseについては当ブログの ReactのSuspenseで非同期処理を乗りこなす でも解説しています。

注意点として、get 関数でAtomにアクセスしている場合に、Atom更新時に再計算されるのは同期処理と同じなので、 HTTPリクエストを飛ばす場合は予想外のリクエストが飛ばないように気をつけましょう。

以下がサンプルになります:

import React, { Suspense } from 'react';
import ReactDOM from 'react-dom';
import {
  RecoilRoot,
  selector,
  useRecoilValue
} from 'recoil';

const wait = async (millisec) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(), millisec);
  });
};

const userDataState = selector({
  key: 'sample/userData',
  get: async ({get}) => {
    return wait(1000).then(() => ({name: 'John'}));
  }
});

const UserDataDisplay = () => {
  const userData = useRecoilValue(userDataState);
  return <div>{userData.name}</div>;
};

const App = () => (
  <Suspense fallback={<div>Loading...</div>}>
    <UserDataDisplay/>
  </Suspense>
);

ReactDOM.render(
  <RecoilRoot>
    <App/>
  </RecoilRoot>,
  document.getElementById('root')
);

動的なAtom/Selector

今までの例では、Atomは手で書き並べなければなりませんでした。 Recoilでは同的にAtomやSelectorを生成する方法が用意されています。

Atomのファクトリを作成するには atomFamily を使います。このとき atomFamily に渡す引数は atom とほぼ同じです。

const itemStateFamily = atomFamily({
  key: 'sample/item',
  default: 0
});

このときFamilyは関数なので、適当な識別子を渡してやれば、その識別子に応じたAtomを作成・取得してくれます。 あとはただのAtomなのでフックで値を取得・更新できます。

const atom = itemStateFamily('My ID'); // 'My ID'に対応するAtomを取得
const [itemCount, setItemCount] = useRecoilState(atom);

これを用いることで、Atomの数を動的に増やすことができます。

import React from 'react';
import ReactDOM from 'react-dom';
import {
  RecoilRoot,
  atomFamily,
  useRecoilState
} from 'recoil';

const itemStateFamily = atomFamily({
  key: 'sample/item',
  default: 0
});

const Item = ({name}) => {
  const [itemCount, setItemCount] = useRecoilState(itemStateFamily(name));
  return <div onClick={() => setItemCount((c) => c + 1)}>{name}: {itemCount}</div>;
};

const App = () => (
  <div>
    <Item name="Apple"/>
    <Item name="Banana"/>
  </div>
);

ReactDOM.render(
  <RecoilRoot>
    <App/>
  </RecoilRoot>,
  document.getElementById('root')
);

atomFamily がAtomと違う大きな点のひとつとして、 default を関数にできることです。 関数を default にすることによって、引数に応じたデフォルト値を設定できます。

const itemStateFamily = atomFamily({
  key: 'sample/item',
  default: (arg) => arg * 10;
});

Selectorについても同様で、 selectorFamily が用意されています。

const LiarItemStateFamily = selectorFamily({
  key: 'sample/liarItem',
  get: (arg) => ({get}) => get(itemStateFamily(arg)) * 5,
  set: (arg) => ({get, set}, newValue) => set(itemStateFamily(arg), newValue)
});

これも selector とほぼ同じで、 getset がそれぞれ関数を返すメソッドとなっているだけです。 set は任意なので、無くても動きます。

例えば以下のように使用します:

import React from 'react';
import ReactDOM from 'react-dom';
import {
  RecoilRoot,
  atomFamily,
  selectorFamily,
  useRecoilState
} from 'recoil';

const itemStateFamily = atomFamily({
  key: 'sample/item',
  default: 0
});

const LiarItemStateFamily = selectorFamily({
  key: 'sample/liarItem',
  get: (arg) => ({get}) => get(itemStateFamily(arg)) * 5,
  set: (arg) => ({get, set}, newValue) => set(itemStateFamily(arg), newValue)
});

const Item = ({name}) => {
  const [itemCount, setItemCount] = useRecoilState(LiarItemStateFamily(name));
  return <div onClick={() => setItemCount((c) => c + 1)}>{name}: {itemCount}</div>;
};

const App = () => (
  <div>
    <Item name="Apple"/>
    <Item name="Banana"/>
  </div>
);

ReactDOM.render(
  <RecoilRoot>
    <App/>
  </RecoilRoot>,
  document.getElementById('root')
);

また、 atomFamilyselectorFamily を組み合わせて、 atomFamily のデフォルト値を selectorFamily にすることも可能です。

const itemStateFamily = atomFamily({
  key: 'sample/item',
  default: selectorFamily({
    key: 'sample/item/liar',
    get: (arg) => ({get}) => arg + 10
  });
});

Atomと結び付けずにAtomの値を読み出す

稀なケースだとは思いますが、コンポーネントをAtomと結び付けずにAtomの値を読み出したい場合があります。 その場合は useRecoilCallback フックを使って関数を作ることで実現できます。 通常のAtomの読み出し方だとAtomの更新ごとにコンポーネントも更新されますが、 useRecoilCallback を使えば任意のタイミングでのみ取得できます。

名前からわかる通り、React本体の useCallback とほぼ同じです。以下が例となります:

const getUserData = useRecoilCallback(async ({getPromise}) => {
  const {name} = await getPromise(userDataState);
  console.log(name);
}, []);

useRecoilCallback には引数に getPromise 関数を受け取る関数を渡します。第2引数(ここでは空配列)は依存関係の配列です。

get ではなく getPromise となっているのは、もしSelectorからの値の取得の場合はPromiseが返ってくる可能性があるからです。 Selectorは非同期処理を扱えるのでこのような形になっています。

使用例としては以下のようになります:

import React from 'react';
import ReactDOM from 'react-dom';
import {
  RecoilRoot,
  atom,
  useRecoilCallback
} from 'recoil';

const userDataState = atom({
  key: 'sample/userData',
  default: {name: 'John'}
})

const UserDataLogger = () => {
  const getUserData = useRecoilCallback(async ({getPromise}) => {
    const {name} = await getPromise(userDataState);
    console.log(name);
  }, []);

  return <div onClick={getUserData}>Click to Log</div>;
};

const App = () => (
  <UserDataLogger/>
);

ReactDOM.render(
  <RecoilRoot>
    <App/>
  </RecoilRoot>,
  document.getElementById('root')
);

さいごに

Recoilについて大まかに眺めてみました。ずいぶん綺麗で、シンプルに纏まっているライブラリに感じます。 また実験的なライブラリであるので現実での利用は難しいですが、少し触ってみるのも楽しいかも知れません。

Recoilはこれからも更新/変更がされていくはずなので、最新情報は公式サイトでチェックしてみてください。