2021年に活用していきたいReactの状態管理ライブラリRecoil

f:id:aptpod_tech-writer:20201218093750p:plain

aptpodフロントエンドエンジニアの黒川です!

aptpod Advent Calender2020の19日目を担当します。

2020年は新型コロナウイルスの世界的流行により全てが一変した年でした。
オリンピックも延期になりましたし、私達の生活様式や働き方、価値観まで変わりました。 そんな2020年にReactの状態管理を大きく変えるライブラリがリリースされました。それがRecoilです。

Recoilについては、私の以前書いた記事でも名前だけ触れました。 2020年の5月に行われたReact Europe2020で発表され、瞬く間に注目を浴びまして、2020年12月現在GitHubスター数1万を超えるなかなかの人気ライブラリとなっております。
とはいえ、npm trendsなどを見ても、同じく状態管理ライブラリであるReduxMobXには大きく水をあけられており、まだまだ実際に使われている機会は少ない、これからのライブラリです。

今回は、そんなRecoilについて基本的な使い方と思想、そしてこういう使い方をすると嬉しいんじゃないかというお話をしたいと思います。

Recoilについて

Recoilは2020年5月にリリースされたReact用の状態管理ライブラリです。
2020年12月現在はまだexperimentalということもあり、まだまだ製品へと本格投入されるフェーズにありません。しかし、その利便性やReactの本家本元であるFacebookが開発しているという話題性から注目を集めています。

Recoilのコンセプトは非常にシンプルです。atomと呼ばれる関数から生成されたRecoilStateというRecoil専用の状態をそのままコンポーネントにsubscribeさせるか、あるいはselectorと呼ばれる純粋関数を通してコンポーネントにsubscribeさせるか、これだけです。Reduxのようなreducerやactionといった多くのボイラープレートをRecoilは必要としません。
これはRecoilの開発が、Reactの元々持つ状態管理(useStateやContext)のシンプルさと便利さを損なうことなく、逆にそれらを扱う上で制約となっていた不自由さを取っ払うことを目的に行われていることによるものです。以下に実際に従来のコードとRecoilを用いたコードを書いたので見比べてみましょう。

// 従来の書き方
import React, { useCallback, useState } from "react";

export const Conventional = () => {
  const [count, setCount] = useState<number>(0);
  const onIncrement = useCallback(() => {
    setCount((prev) => prev + 1);
  }, []);
  const onDecrement = useCallback(() => {
    setCount((prev) => prev - 1);
  }, []);

  return (
    <div>
      <p>{count}</p>
      <button onClick={onIncrement}>インクリメント</button>
      <button onClick={onDecrement}>デクリメント</button>
    </div>
  );
};
// Recoilを用いた書き方
import React, { useCallback } from "react";
import { useRecoilState } from "recoil";
import { counterState } from "../../atoms/state";

// counterStateはこのような定義がされています
// const counterState = atom<number>({
//   key: "counterState",
//   default: 0
// });

export const RecoilWay = () => {
  const [count, setCount] = useRecoilState(counterState);
  const onIncrement = useCallback(() => {
    setCount((prev) => prev + 1);
  }, []);
  const onDecrement = useCallback(() => {
    setCount((prev) => prev - 1);
  }, []);

  return (
    <div>
      <p>{count}</p>
      <button onClick={onIncrement}>インクリメント</button>
      <button onClick={onDecrement}>デクリメント</button>
    </div>
  );
};

このようにほとんど変わりません。
変更点は、Recoilの方でimportしているatom関数によって定義されたcounterStateuseState関数と入れ替わったuseRecoilState関数によってsubscribeされたことだけです1
これだけで今やRecoilで定義されたほうのcountは、他のコンポーネントからもcounterStateをsubscribeすることで参照可能となったのです。簡単で便利ですね!
ここではRecoilが既存のReactのシンプルさを引き継ぎつつ、簡単に他のコンポーネントからもRecoilStateが参照可能になることを紹介しました。続いて、Recoilの代表的なAPIと基本的な使い方を紹介したいと思います。

Recoilの代表的なAPI

atom

atomはRecoilで用いられるRecoilStateを生成する関数で、使い方は非常にシンプルです。以下のようにatom関数で宣言するだけで完了です。

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

ほとんど初見でも分かりそうなくらいにシンプルですが、簡単に説明します。
atomkeydefaultという2つのプロパティを引数として求めます。keyはアプリケーション全体でユニークなものを渡す必要があります。これは内部的にkeyによってatomを判別しているからです。defaultuseStateやReduxにおける初期値になります。
以上を設定すれば、あとはこのatomから生成されたStateを使用するコンポーネントにおいてsubscribeすれば参照可能になります。
アプリケーションを通してこのatomは何度も使用することになるかと思います。
そのような時に、例えばユーザーネームとパスワードなど同じinputを用いたコンポーネントでありながら、別々のRecoilStateを扱うコンポーネントに対してuserNameState, passwordStateなどといちいちatomからRecoilStateを生成するのは面倒ですよね。
そんな時は、atomFamilyを使えば解決です。atomFamilyは動的にatomを作成してくれる関数であり、宣言もほとんどatomと同様です。

import React from "react";
import { useRecoilState, atomFamily } from "recoil";

const inputTextState = atomFamily<string, string>({
  key: "inputTextState",
  default: ""
});

export const InputFamily = () => {
// もしパラメータを同じものにすると変数名が違っても、同じ値になる
  const [userName, setUserName] = useRecoilState(inputTextState("userName"));
  const [password, setPassword] = useRecoilState(inputTextState("password"));
  const [remarks, setRemarks] = useRecoilState(inputTextState("remarks"));
  return (
    <>
      <input value={userName} onChange={(e) => setUserName(e.target.value)} />
      <input value={password} onChange={(e) => setPassword(e.target.value)} />
      <input value={remarks} onChange={(e) => setRemarks(e.target.value)} />
      <p>{userName}</p>
      <p>{password}</p>
      <p>{remarks}</p>
    </>
  );
};

ほとんどatomを用いた宣言と同様な事がわかります。異なる点は、RecoilStateをsubscribeする際に任意のパラメータをatomFamilyに渡すことです。ちなみにatomFamilyの型引数の1つ目はStateの型で、2つ目はパラメータの型になります。
これにより内部的にatomFamilyが生成したatomkeyをマッピングしてくれるのでこちらで細かいことを気にする必要はありません。 今回はinputで簡単に説明しましたが、例えばユーザーが動的に追加していく項目やボタンなどにもatomFamilyは用いることができますので、非常に活用の幅は広いです。

selector

selectoratomで生成されたRecoilStateをコンポーネントにsubscribeする際、使いやすい形に加工するための関数です。 まずはサンプルコードをお見せして説明しようと思います。こちらはRecoilの公式ドキュメントから引用しました。

const tempFahrenheit = atom({
  key: 'tempFahrenheit',
  default: 32,
});

const tempCelcius = selector({
  key: 'tempCelcius',
  get: ({get}) => ((get(tempFahrenheit) - 32) * 5) / 9,
  set: ({set}, newValue) =>
    set(
      tempFahrenheit,
      newValue instanceof DefaultValue ? newValue : (newValue * 9) / 5 + 32
    ),
});

selectorを使うときには、2つのプロパティを定義する必要があります。1つ目はおなじみのkeyですね。ユニークなものを定める必要があります。
2つ目は、getです。ここでコールバック関数を定めることでselectorがどんな加工を行うか定義できます。また、このコールバック関数は引数にgetという関数を受け取れます。このget関数にatomで定義したRecoilStateを渡すことでselector内で値を読み込むことができます。ここからは自由に加工可能です。上の例では、温度の単位変換として華氏(°F)の値を読み込んで摂氏(℃)に変換しています。
さて、2つのプロパティと言いましたが、上記の例では3つ定義していますね。3つ目のプロパティsetはオプションです。定義せずに読み込み専用のselectorとすることも勿論可能ですが、定義することでselectoratomの値を連動させることができます。
setの役割は、RecoilStateを新しい値に上書きすることです。getプロパティ同様にsetプロパティもset関数を引数に受け取ります。また、上書きするための新しい値も受け取ります。このset関数の第1引数に更新対象であるRecoilState、そして第2引数にsetプロパティが受け取った新しい値を渡すことで、RecoilStateが更新されます。上の例ではtempFahrenheitが更新の対象です。また、tempCelciustempFahrenheitの値をもとに生成されているので、こちらも連動して更新されます。このようにselectorRecoilStateを更新させた際は、結果的にそのselectorgetから得られる値も更新されることとなります。

また、上記のコードを見るとset関数の新しい値をDefaultValueのインスタンスかどうかを判別しているかと思います。これは、useResetRecoilStateというRecoilStateの値をdefaultに戻すHooksの使用を考慮したものです。すなわち、useResetRecoilStatenewValueDefaultValueのインスタンス = defaultの値の時はそのままdefaultの値に更新し、それ以外の時は摂氏を華氏に変換して更新しています。文字で説明すると少々ややこしいですが、実際に使ってみるとわかりやすいAPIです。

さらにselectorは非同期処理も組み合わせることが可能です。 これを利用して以下のように、例えばあるidのデータをAPIを通して取り出したいというものが簡単に実装できるようになります。

const asyncSelector = selector({
  key: "asyncSelector",
  get: async ({ get }) => {
    const res = await getSomething(get(idState));
    return res.data;
  }
});

ただし、非同期処理にselectorを用いる時は一点だけ注意が必要です。
このasyncSelectorから返ってくる値は非同期であるため、同期的にコンポーネントをマウントした場合はまだ取得されていないデータを参照する可能性があり、アプリケーションの動作に支障を来してしまうということです。これを回避するための方法は2つあります。

1つ目はコンポーネント内は同期的に取得して、親コンポーネントで回避する方法です。以下のコードをご覧ください。

// 親コンポーネントで回避するパターン
const SomethingAsync = () => {
  const value = useRecoilValue(asyncSelector);
  return <div>{value}</div>;
};

const ParentComponent = () => {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <SomethingAsync />
    </Suspense>
  );
};

ここではSuspenseを使っています。Suspenseについて簡単に説明しますと、SomethingAsyncが読み込まれるまで待機するためのコンポーネントです。Suspensefallbackに渡したコンポーネントを待機中に表示してくれますので、非同期中であってもアプリケーションは正常に動作できます。

もう1つは、コンポーネント内で回避する方法です。この回避をするためのHooksとしてRecoilはuseRecoilValueLoadableを提供しています。
このuseRecoilValueLoadableは値をそのまま返すのではなく、Loadableオブジェクトというものを返します。Loadableオブジェクトはstatecontentsという2つのプロパティを持ち、contentsstateの状態によって中身が変わります。
以下のコードを見て、statecontentsがどのような形になりうるか確認したいと思います。

// コンポーネント内で回避するパターン
const SomethingAsync = () => {
  const valueLoadable = useRecoilValueLoadable(asyncSelector);
  switch (valueLoadable.state) {
    //  非同期処理が終わっていない時、stateはloadingとなり、contentsはPromiseとなる
    // そのままだと表示できないので、<Suspense>と同じ扱いをする
    case "loading":
      return <div>Loading...</div>;
    // 非同期処理が終わればstateはhasValueとなり、
    // contentsには実際の値が入るので、コンポーネント内で使用できる
    case "hasValue":
      return <div>{valueLoadable.contents}</div>;
    // もしも非同期処理中にエラーが生じるとstateはhasErrorとなる
    // contentsにはErrorオブジェクトが入るので、これをthrowすることでErrorBoundaryに検知させる
    case "hasError":
      throw valueLoadable.contents;
  }
};

このような感じです。実際にどちらのパターンを取るかは好みやコンポーネントの設計によると思いますが、selectorで非同期処理を扱う時はこのような処理が必要ということだけ留意する必要があります。

useRecoilState/useRecoilValue/useSetRecoilState

今まででRecoilを扱うためのStateを作るAPIを紹介してきました。ここからは実際にコンポーネントにsubscribeするためのHooksを紹介していきます!
ただ、実はRecoilでsubscribeするためのHooksはほとんど中身は一緒なんです!
違いは、

  1. 「値」と「更新するための関数」両方必要か
  2. 「値」だけ必要か
  3. 「更新する関数」だけ必要か

の3つに応じて使い分けるだけです。
そして、それぞれ①のケースがuseRecoilState、②がuseRecoilValue、③がuseSetRecoilStateとなります。
すなわち、useRecoilValue + useSetRecoilState = useRecoilStateです。簡単ですね。

useRecoilStateは今までのサンプルコードで何度も用いてきましたが、以下に3つのサンプルコードを書きます。
Reactのhooksに慣れている方であれば、全く違和感なく書けると思います。

  // ReactのuseStateと全く一緒です
  const [count, setCount] = useRecoilState(counterState);

  // ReactのuseStateからstateだけを取り出したものです
  const count = useRecoilValue(counterState);

  // ReactのuseStateからsetStateだけを取り出したものです
  const setCount = useSetRecoilState(counterState)  

いかがでしょうか。本当にシンプルなAPIなのでありがたいですね。

さて、ここまででRecoilStateを作り、それをコンポーネントにsubscribeさせる方法を説明してきました。これによりもう<RecoilRoot>内のどこからでも自由にRecoilStateを参照できるようになっています。ただ、まだ気になる点が1つあります。onClickなどイベント発火時のコールバック関数です。従来のuseCallbackは便利ですが、下手に依存関係を持たせてしまうと依存している変数の更新に伴い、useCallbackの更新も行われ、意図しない再レンダリングが生じていました。RecoilStateが更新されても不要な時はsubscribeせず、必要になった時にRecoilStateを読み込む、そんなコールバック関数があれば理想的です。そのような希望を叶えてくれるのが次に紹介するAPIです。

useRecoilCallback

RecoilにはsnapshotというRecoilの現在のStateを読み取れるオブジェクトがあります。useRecoilCallbackはこのsnapshotを用いて、必要な時にだけStateを読み込み発火することができます。この必要な時に読み込むことの何が嬉しいのかは、上述の通り不要な再レンダリングを避けられることです。

この再レンダリングを説明するために、姓と名を入力して送信ボタンを押すと、フルネームが表示される簡単なアプリケーションのコードをReactのAPIのみで書きました。

gyazo.com

import React, { useCallback, useState } from "react";

type InputProps = {
  value: string;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
};

const Input = (props: InputProps) => {
  return <input value={props.value} onChange={props.onChange} />;
};

type ButtonProps = {
  onClick: () => void;
};

const Button = (props: ButtonProps) => {
  return <button onClick={props.onClick}>送信</button>;
};

export const Form = () => {
  const [lastName, setLastName] = useState<string>("");
  const [firstName, setFirstName] = useState<string>("");
  const [fullName, setFullName] = useState<string>("");

  const onChangeFirstName = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setFirstName(e.target.value);
    },
    []
  );

  const onChangeLastName = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setLastName(e.target.value);
    },
    []
  );
  const onClick = useCallback(() => {
    setFullName(`${firstName} ${lastName}`);
  }, [firstName, lastName]);

  return (
    <div>
      <Input value={firstName} onChange={onChangeFirstName} />
      <Input value={lastName} onChange={onChangeLastName} />
      <p>FullName: {fullName}</p>
      <Button onClick={onClick} />
    </div>
  );
};

こちらのGIFでわかりますように、Inputの文字を変更するたびにInputそのものとForm、そしてButtonに再レンダリングがかかっています。(白い明滅がレンダリングがかかっているということです)
これはInputの変数をButtonに渡すために、その親コンポーネントであるFormがInputの変数も持っているため、lastNamefirstName更新時にInputだけではなくFormまで再レンダリングがかかるためです。また、Buttonに降ろすコールバックもlastNamefirstNameに依存しているため、このコールバックを使用するButtonにも再レンダリングがかかります。勿論、いくつかの工夫により多少は抑制されますが、やはりレンダリングは必要なコンポーネントに留め、不要な再レンダリングは可能な限り避けたいものです。

これを解消するのがRecoil、そしてuseRecoilCallbackになります!同じような構成をRecoilで書き直してみたのが以下になります。

gyazo.com

import React, { useCallback, useState } from "react";
import { atomFamily, useRecoilState, useRecoilCallback } from "recoil";

const nameState = atomFamily<string, string>({
  key: "nameState",
  default: ""
});

type Props = {
  param: string;
};

const Input = (props: Props) => {
  const [value, setValue] = useRecoilState(nameState(props.param));
  const onChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  }, []);

  return <input value={value} onChange={onChange} />;
};

type ButtonProps = {
  onClick: () => void;
};

const Button = (props: ButtonProps) => {
  return <button onClick={props.onClick}>送信</button>;
};

export const RecoilForm = () => {
  const [fullName, setFullName] = useState<string>("");
  const onClick = useRecoilCallback(
    ({ snapshot }) => async () => {
      const firstName = await snapshot.getPromise(nameState("firstName"));
      const lastName = await snapshot.getPromise(nameState("lastName"));
      setFullName(`${firstName} ${lastName}`);
    },
    []
  );

  return (
    <div>
      <Input param={"firstName"} />
      <Input param={"lastName"} />
      <p>FullName: {fullName}</p>
      <Button onClick={onClick} />
    </div>
  );
};

いかがでしょうか。明滅が変更のかかっているコンポーネントのみに留まりましたね!
InputにはatomFamilynameStateというものを作り、複数のInputであっても1つのRecoilStateで対応できるようにしました。
そして、RecoilFormでは自身が使うローカル変数であるfullNameuseStateで作り、Buttonに渡すコールバックはuseRecoilCallbackで作成しています。
useRecoilCallbackは高階関数のような形をとっていまして、冒頭で説明したsnapshotオブジェクトは1つ目の引数に受け取ります。このsnapshotから他のatomRecoilStateを読み込むことができるのですが、ここで注意してほしいのはsnapshotからはPromiseの形でしか読み込めないことです。なぜPromiseかといいますと、非同期selectorを考慮した設計だそうです。そして、Promiseを扱うので2つめの引数にはasyncを付ける必要があります。
コールバック内ではsnapshotオブジェクトのgetPromiseという関数に他のAPI同様にRecoilStateを渡すとその値を読み込むことができます。これにより、useCallbackのように再レンダリングをかけることなくInputの値を取り扱うことができます!

一点注意してほしいのは、今回はuseStateの更新を行うためにsetStateuseRecoilCallback内部に置きましたが、もしRecoilの値を更新したい時はuseSetRecoilStateを用いてはいけないということです。したがって、useRecoilCallbackselector同様にset関数を用意しています。使い方もselectorと一緒で第一引数に更新したいRecoilState、第2引数に新しい値を指定します。useRecoilCallbackを用いる時にRecoilStateを更新したい場面は多いと思いますので、この点だけ気をつけてください。

まとめ

今回はRecoilのAPIを中心にどのような点が嬉しいのか、どのような点に気をつければいいのかを中心に書かせていただきました。
Recoilは、私自身も書いていてとても書き心地がいいですし、便利なのでもっと広まればいいなっと思っています。今回は書ききれませんでしたが、便利なAPIや機能はまだまだたくさんあります。
一方で、今回は伝えきれなかったのですが、atomなどは油断するとすぐに無秩序になりがちなので治安の良い書き方というのも意識する必要がありそうです。このあたりのベストプラクティスはまだまだ溜まっていないので、これからに期待ですね。
また、Facebook Experimentalということで、まだまだRecoil自体もどうなっていくか分かりません。最初のリリースからAPIも変わったりしていますので、製品にホイホイと投入できるほど安心はできないというのも事実です。この点についても趨勢を見守っていきたいですが、2021年には何らかの進展が見られることを期待して、本タイトルをつけさせていただきました!2021年に活用していきたいです!(願望)

さて、来週月曜日はハードウェアグループおおひらさんの「非接触CANセンサーで車両の制御信号を可視化してみた」になります。
自動車の非接触センサーを用いてリアルタイムにちゃんと可視化できるかを検証する弊社らしい記事になっておりますので、是非こちらもご注目ください!

<参考文献>

Recoil公式サイト  https://recoiljs.org/recoiljs.org


  1. 正確にはRecoilを使用しているコンポーネント群のトップに<RecoilRoot>を置く必要があります。