🔋

【TS】今さら聞けないRecoil

2021/02/22に公開

はじめに

今回はRecoilについての紹介(+自分の学習メモ)です。
RecoilFacebookが開発したReact向けの状態管理全般を扱うライブラリです。
以下の記事でも触れられていますが、Zennの状態管理にも用いられており、今まさにシェアを拡大しているライブラリといえます。

https://zenn.dev/catnose99/articles/zenn-dev-stack

Javascriptでの実装例はよく見かけるので、今回はTypescriptを用いて記載した例を紹介していきます。

そもそもRecoilとは

実装の紹介の前に、Recoilについてです。

RecoilFacebookより2020年5月に発表された、状態管理ライブラリです。
以下、公式ページになります。

https://recoiljs.org

同様に状態管理を行うライブラリとしてはReduxが頭一つ抜けている印象ですが、実際に使ってみるとかなり違うことがわかります。
とりわけ 「データストアを集約するか否か」 という思想が大きく異なり、その性質がそのまま使い勝手に反映されているイメージです。

また、React Hooksとの親和性もよく、useStateなどと同じような記法・使い方をしているため、既にFunctional Component + Hooksを中心に開発している場合はすんなり理解できるかと思います。

注意点として、現在はまだExperimental(実験的)なリポジトリとなっています。
今後のサポート等のことを考えると、製品にゴリゴリ組み込むには少し早いかもしれません。

実装サンプル

今回実装したアプリのサンプルは以下になります。

recoil_sample

よくあるTODOアプリで、タスクの追加、検索ができます。

バージョン情報

  • react@17.0.1
  • react-dom@17.0.1
  • recoil@0.1.2

実装

以下の手順で実装を行います。

プロジェクトの作成

以下コマンドでプロジェクトを作成します。
プロジェクト名はrecoil-ts-sampleとしました。

npx create-react-app recoil-ts-sample --template typescript
cd recoil-ts-sample

Recoilのインストール

Recoilのインストールを行います。

yarn add recoil

typesの作成

まずこのアプリで扱う「タスク」のtypeを作成します。
ソースとしてはsrc/types/Todo.tsになります。
今回はシンプルにtitleだけを持つTodo型を定義しました。

src/types/Todo.ts
type Todo = {
    title : string;
}
export default Todo;

Atomの作成

続いてAtomの作成です。
AtomとはRecoilにおけるデータストアのことで、atom()を使って宣言します。
atom()にはAtomOptions型のオブジェクトを渡します。
AtomOptionsは以下のような型です。

// atom.d.ts
export interface AtomOptions<T> {
  key: NodeKey;
  default: RecoilValue<T> | Promise<T> | T;
  effects_UNSTABLE?: ReadonlyArray<AtomEffect<T>>;
  dangerouslyAllowMutability?: boolean;
}

必須なのはkeydefaultのみです。
keyはアプリケーション全体で一意となる文字列で、defaultAtomとして状態管理を行いたい値の初期値になります。

例えばアプリ上で表示する「タスク一覧」をAtomで宣言するには以下のようになります。

src/atoms/TodoListAtom.ts
import { atom } from "recoil";
import Todo from '../types/Todo';

export const todoListState = atom<Todo[]>({
    // keyは"todoList"
    key: "todoList",
    // 初期値として3件のタスクを持つ配列を宣言
    default: [
        {title: "one"},
        {title: "two"},
        {title: "three"},
    ],
});

同様に画面上部の「追加したいタスク名のフィールド」と「検索文字列のフィールド」の状態をAtomで宣言します。

src/atoms/TodoTitleFormAtom.ts
import { atom } from "recoil";

export const todoTitleFormState = atom<string>({
    key: "todoTitleForm",
    default: '',
});
src/atoms/SearchTextFormAtom.ts
import { atom } from "recoil";

export const searchTextFormState = atom<string>({
    key: "searchTextForm",
    default: '',
});

Atomの宣言はReduxにおけるStoreのようなもの、と理解すると分かりがいいかもしれません。

Selectorの作成

同様にSelectorを作成します。
SelectorAtomの値を用いて何らかの計算や加工・副作用を処理した結果を返します。

今回のアプリの例では「実際に画面に表示されるタスク一覧」がSelectorを使って取得する値になります。
実際に画面に表示されるタスクは 「タスク全件」の中から「検索フィールドに入力した文字」に該当する文字を含むものだけを表示している ためです。

Selectorの宣言はselector()で行います。
引数としてReadOnlySelectorOptions型のオブジェクトを渡す必要があります。
ReadOnlySelectorOptions型は以下です。

export interface ReadOnlySelectorOptions<T> {
    key: string;
    get: (opts: { get: GetRecoilValue }) => Promise<T> | RecoilValue<T> | T;
    dangerouslyAllowMutability?: boolean;
}

Atom同様、一意となるkeyを持っています。
さらに「どういう値を取得するか」を定義したgetを持ちます。
getは引数としてGetRecoilValue型のget(プロパティ名が同じなのでややこしいですが・・・)をプロパティに持つオブジェクトを受け取る関数です。

実際に作成したSelectorが以下になります。

src/selectors/SearchedTodoListSelector.ts
import { selector } from "recoil";
import { todoListState } from '../atoms/TodoListAtom';
import { searchTextFormState } from '../atoms/SearchTextFormAtom';
import Todo from '../types/Todo';

export const searchedTodoListSelector = selector<Todo[]>({
  key: "searchedTodoListSelector",
  // getは{ get }を引数に取る関数
  get: ({ get }) => {
    // 引数のgetを使ってAtomから最新の値を取得(タスク一覧)
    const todoList: Todo[] = get(todoListState);
    // 同様に検索フィールドの文字列を取得
    const searchText: string = get(searchTextFormState);
    // 検索フィールドに入力がある場合は、その条件に合致したタスクのみを返却する
    return searchText? todoList.filter((t) => t.title.includes(searchText)) : todoList;
  },
});

Rootの宣言

これでRecoilで状態管理を行う準備が整いました。
Recoilで状態管理をするためには、状態管理をしたい箇所を<RecoilRoot>でラップする必要があります。
Reduxでいうところの<Provider>でラップするのと同じイメージです。

index.tsx内でRecoilRootを使ってAppをラップするようにしましょう。

index.tsx
import React from "react";
import ReactDOM from "react-dom";
import { RecoilRoot } from "recoil";
import App from "./App";

ReactDOM.render(
  <React.StrictMode>
    <RecoilRoot>
      <App />
    </RecoilRoot>
  </React.StrictMode>,
  document.getElementById("root")
);

// ...以下略

AtomやSelectorから値を取得する

それでは早速コンポーネント内でAtomSelectorから値を取得してみましょう。
値を取得するにはuseRecoilValue()を使います。
useRecoilValue()には宣言したAtomSelectorを引数として渡します。
その返り値としてAtomSelectorの最新値が返されます。

例えば「タスク一覧」を表示するTodoList.tsxは以下のようになります。

src/component/TodoList.tsx
import { useRecoilValue } from "recoil";
import { searchedTodoListSelector } from "../selectors/SearchedTodoListSelector";
import Todo from "../types/Todo";

const TodoList: React.FC = () => {
  // useRecoilValueにsearchedTodoListSelectorを渡す
  // 返される値はsearchedTodoListSelectorのget()で定義した通りTodo[]
  const list: Todo[] = useRecoilValue(searchedTodoListSelector);
  return (
    <div>
      <p>タスク一覧</p>
      <ul>
        {list.map((todo: Todo, i: number) => {
          return <li key={`${todo.title}_${i}`}>{todo.title}</li>;
        })}
      </ul>
    </div>
  );
};

export default TodoList;

Atomの値を変更する

ReduxにおいてStoreの値をActionDispatchすることで変更すると、参照しているコンポーネントでは再描画が行われました。
RecoilにおいてもAtomの値を変更することで、参照しているSelectorやコンポーネントに自動反映されます。

Atomの値を変更するためにはuseSetRecoilState()Atomを引数として渡します。
返り値としてSetterOrUpdater<T>型の関数が返されます。
これはAtomの値に対するSetter関数のようなもので、この関数を経由してAtomを更新することで前述の再計算処理が行われます。

例えば「追加するタスク名を入力するフィールド」のコンポーネントであるTitleForm.tsxは以下のようになります。

src/component/TitleForm.tsx
import { useCallback } from "react";
import { useRecoilValue, useSetRecoilState, SetterOrUpdater } from "recoil";
import { todoTitleFormState } from "../atoms/TodoTitleFormAtom";

const TitleForm: React.FC = () => {
  // useRecoilValueでtodoTitleFormStateの値を取得
  const todoTitleFormValue: string = useRecoilValue(todoTitleFormState);
  // useSetRecoilStateでtodoTitleFormStateの値を更新するSetter関数を取得
  const setTodoTitleFormValue: SetterOrUpdater<string> = useSetRecoilState(
    todoTitleFormState
  );
  // テキストフィールドのonChange処理
  const onChange = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      // 先に取得したsetTodoTitleFormValueに対して更新したい値を渡して実行
      setTodoTitleFormValue(event.target.value);
    },
    [setTodoTitleFormValue]
  );

  return (
    <label>
      タスク名:
      <input
        type="text"
        value={todoTitleFormValue}
        onChange={onChange}
        name="title"
        style={{ margin: 12 }}
      />
    </label>
  );
};

export default TitleForm;

同様に「検索文字列を入力するフィールド」コンポーネントも作成しておきます。

src/component/SearchForm.tsx
import { useCallback } from "react";
import { useRecoilValue, useSetRecoilState, SetterOrUpdater } from "recoil";
import { searchTextFormState } from "../atoms/SearchTextFormAtom";

const SearchForm: React.FC = () => {
  const searchTextFormValue: string = useRecoilValue(searchTextFormState);
  const setSearchTextFormValue: SetterOrUpdater<string> = useSetRecoilState(
    searchTextFormState
  );
  const onChange = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      setSearchTextFormValue(event.target.value);
    },
    [setSearchTextFormValue]
  );

  return (
    <label>
      検索:
      <input
        type="text"
        value={searchTextFormValue}
        onChange={onChange}
        name="title"
        style={{ margin: 12 }}
      />
    </label>
  );
};

export default SearchForm;

また「タスクの追加」を行うボタンコンポーネントも用意しておきましょう。

src/component/AddButton.tsx
import { useCallback } from "react";
import { useRecoilValue, useSetRecoilState, SetterOrUpdater } from "recoil";
import { todoTitleFormState } from "../atoms/TodoTitleFormAtom";
import { todoListState } from "../atoms/TodoListAtom";
import Todo from "../types/Todo";

const AddButton: React.FC = () => {
  const todoList: Todo[] = useRecoilValue(todoListState);
  const todoTitleFormValue: string = useRecoilValue(todoTitleFormState);
  const setTodoList: SetterOrUpdater<Todo[]> = useSetRecoilState(todoListState);
  const setTitleFormValue: SetterOrUpdater<string> = useSetRecoilState(
    todoTitleFormState
  );

  const onClick = useCallback(() => {
    setTodoList([...todoList, { title: todoTitleFormValue }]);
    // タスクを追加したら入力欄は空にする
    setTitleFormValue("");
  }, [todoList, todoTitleFormValue, setTodoList, setTitleFormValue]);

  return <button onClick={onClick}>追加</button>;
};

export default AddButton;

Appの編集

これで必要なコンポーネントの作成が完了しました。
App.tsxを以下のように書き換えて、Recoilを使ったコンポーネント群を実際に使用してみましょう。

App.tsx
import "./App.css";
import TitleForm from "./component/TitleForm";
import AddButton from "./component/AddButton";
import TodoList from "./component/TodoList";
import SearchForm from "./component/SearchForm";

const App: React.FC = () => {
  return (
    <div style={{ margin: 12 }}>
      <div>
        <TitleForm />
        <AddButton />
      </div>
      <SearchForm />
      <TodoList />
    </div>
  );
};

export default App;

以下コマンドで問題なく起動すれば、冒頭で紹介したサンプルのようなアプリが立ち上がるかと思います。

yarn start

今回作成したもの

上記のソース一式をGitHubの以下リポジトリにアップしてあります。
動作させながらソースを追いたい場合は参照ください。

https://github.com/ShutoYamada/recoil-ts-sample

まとめ

今回はRecoilについてTypescriptを使った簡単なタスク管理アプリを作ることで紹介していきました。
普段はReduxを使って開発していますが。Recoilの方がシンプルかつ直感的な作りなので分かりやすいかなと思いました。

またHooksのような使い勝手なのも今風のReactアプリっぽいので、後発のライブラリの強みなのかなとも思います。

まだExperimental扱いなので、今後使用感やサポートは大きく変わる可能性もありますが、個人的には今後伸びていくのではないかと思える有益ライブラリでした。

Discussion