📖

秩序あるRecoilの使い方を考える

2022/06/11に公開

Recoilは比較的シンプルな状態管理ライブラリで使い方に自由度がありますが、無計画に使ってしまうとコードの統一感がなくなり将来悲惨な状態になってしまうことは想像に難くないです。

そのためRecoilを導入する前に、まず秩序を持ったRecoilの使い方を検討する必要がありました。

今回考えたRecoilの使い方を、具体例としてアカウント情報を扱うatomを取り上げて紹介します。

今回作成したサンプルはこちらに置いてあります。
https://github.com/warabiiiii/recoil-state-sample

Recoilの基本的な解説については公式ドキュメントの参照をお願いします。

詳細

各ファイルと役割

src/modules
├── account
│   ├── types.ts
│   ├── atoms.ts
│   ├── operations.spec.ts
│   ├── operations.ts
│   ├── selectors.spec.ts
│   └── selectors.ts
...
├── otherAtom 
...

types.ts
atomで扱う型と、selectorが返す型を定義する。

atoms.ts
atomを定義する。

operations.ts
useRecoilCallbackを使ったatomを更新する処理を書く。ApiClientの呼び出しなども担当する。

selectors.ts
atomの内容を元にViewで必要な形式のデータを返す。

データフローは単方向にサイクルができる形式になっています。

types.ts
import { UiState } from "../types";

export type Account = {
  id: string;
  name: string;
};

export type AccountAtom = {
  isLoading: boolean;
  account: Account | null;
  error: Error | null;
};

export type AccountUiState = UiState<
  | { status: "Loading" }
  | { status: "LoggedIn"; account: Account }
  | { status: "NotLoggedIn" }
  | { status: "ShowError"; error: Error }
>;
atoms.ts
import { atom } from "recoil";
import { AccountAtom } from "./types";

export const accountAtom = atom<AccountAtom>({
  key: "account",
  default: {
    isLoading: false,
    account: null,
    error: null,
  },
});
operations.ts
import { useRecoilCallback } from "recoil";
import { accountApi } from "../../infrastructure/accountApi";
import { accountAtom } from "./atoms";

export const useRefreshAccount = () =>
  useRecoilCallback(
    ({ set }) =>
      async () => {
        set(accountAtom, (prev) => ({ ...prev, isLoading: true }));

        const accountResult = await accountApi.get();

        if (accountResult.isSuccess) {
          set(accountAtom, (prev) => ({
            ...prev,
            isLoading: false,
            account: accountResult.value,
          }));
        } else {
          set(accountAtom, (prev) => ({
            ...prev,
            isLoading: false,
            account: null,
          }));
        }
      },
    [],
  );
selectors.ts
import { selector } from "recoil";
import { accountAtom } from "./atoms";
import { AccountUiState } from "./types";

export const accountUiState = selector<AccountUiState>({
  key: "accountUiState",
  get: ({ get }) => {
    const { account, isLoading, error } = get(accountAtom);

    if (isLoading) {
      return { status: "Loading" };
    }

    if (error) {
      return { status: "ShowError", error: error };
    }

    if (account) {
      return { status: "LoggedIn", account: account };
    }

    return { status: "NotLoggedIn" };
  },
});

view

operationsを呼び出してアカウント情報の更新、selectorsから画面構築に必要なデータを受け取り描画する。

App.tsx
import { useEffect } from "react";
import { useRecoilValue } from "recoil";
import { useRefreshAccount } from "./modules/account/operations";
import { accountUiState } from "./modules/account/selectors";

export const App = () => {
  const refreshAccount = useRefreshAccount();

  useEffect(() => {
    refreshAccount();
  }, [refreshAccount]);

  const account = useRecoilValue(accountUiState);
  switch (account.status) {
    case "Loading": {
      return <div>Loading...</div>;
    }
    case "ShowError": {
      return <div>Error: {account.error.message}</div>;
    }
    case "NotLoggedIn": {
      return (
        <button
          onClick={() => {
            // SignIn
          }}
        >
          SignIn
        </button>
      );
    }
    case "LoggedIn": {
     return <div>name: {account.account.name}</div>;
    }
  }
};

解説

心がけたこと

今回のフローを考えるにあたりviewが画面描画の役割だけを担うようにすることを一番重視しました。
そのためviewが行うのはUiStateによる画面描画」とopeationsの処理を呼び出すの2つに限定し、atomやAPI呼び出しに関連する処理はoperations, selectorsに閉じる書き方になりました。

データ取得をselector経由にする理由

atomにはViewを構成するデータが格納されていますが、それを直接参照する場合atomの構造に関する知識を View側が持つことになり結合度が高くなってしまいます。
「atomがこの内容なら画面はこう表示する」という知識をView側が持たないで済む、selectorがviewでどういう表示にするかを判断する実装になっています。

viewはselectorが返した内容によって画面を表示することで、atomの構造に関する知識を持たないで済むようになります。

selectors.ts
export const accountUiState = selector<AccountUiState>({
  key: "accountUiState",
  get: ({ get }) => {
    const { account, isLoading, error } = get(accountAtom);

    if (isLoading) {
      return { status: "Loading" };
    }

    if (error) {
      return { status: "ShowError", error: error };
    }

    if (account) {
      return { status: "LoggedIn", account: account };
    }

    return { status: "NotLoggedIn" };
  },
});

この時selectorはUiStateという型のデータを返すようにしています。
UiStateを使うことでstatusの内容によって付属するデータを型推論できるので、View側ではswitch文を使った表示の切り替えが行えるようになります。

UiState.ts
export type UiState<T extends { status: string } & Record<string, unknown>> = T;
App.tsx
...
  switch (account.status) {
    case "Loading": {
      return <div>Loading...</div>;
    }
    case "ShowError": {
      return <div>Error: {account.error.message}</div>;
    }
    case "NotLoggedIn": {
      return (
        <button
          onClick={() => {
            // SignIn
          }}
        >
          SignIn
        </button>
      );
    }
    case "LoggedIn": {
     return <div>name: {account.account.name}</div>;
    }
  }
...

今回の設計の利点

役割が明確である

「データの更新はoperations」のように各ファイルが持つ役割が明確なので、機能を追加する時に悩みが起きなくコードの秩序が維持できるようになります。

データの流れが単方向で追いやすい

データフローが単方向のサイクルが回るようになっていて処理が追いやすくなります。

テストが書きやすい

operationsはatomを更新する、selectorsはatomからviewの表示を判断する、と役割が分離できているので、それぞれのテストが書きやすくなっています。
テストを書くことでatomの振る舞いが保証でき、機能追加・修正が容易になるメリットがあります。
テストを書く時はReact Hooks Testing Libraryを利用する形式をとりました。
https://recoiljs.org/docs/guides/testing#testing-recoil-state-inside-a-custom-hook

operations.test.ts
operations.test.ts
import { act, renderHook } from "@testing-library/react-hooks";
import { RecoilRoot, useRecoilValue } from "recoil";

import { accountApi } from "../../infrastructure/accountApi";

import { accountAtom } from "./atoms";
import { useRefreshAccount } from "./operations";
import { AccountAtom } from "./types";

jest.mock("../../infrastructure/accountApi");

export const isNotError = <V>(v: V | Error): v is V => {
  return !(v instanceof Error);
};

const renderRecoilHooks = (initialValue: AccountAtom) =>
  renderHook(
    () => ({
      refreshAccount: useRefreshAccount(),
      atom: useRecoilValue(accountAtom),
    }),
    {
      wrapper: ({ children }: { children: React.ReactNode }) =>
        RecoilRoot({
          children,
          initializeState: ({ set }) => {
            set(accountAtom, initialValue);
          },
        }),
    },
  );

describe("AccountOperations", () => {
  test("refreshAccount success", async () => {
    (
      accountApi.get as jest.Mock<ReturnType<typeof accountApi.get>>
    ).mockResolvedValueOnce({
      isSuccess: true,
      value: { id: "testId", name: "test" },
    });

    const initialValue: AccountAtom = {
      isLoading: false,
      account: null,
      error: null,
    };
    const { result } = renderRecoilHooks(initialValue);

    await act(async () => {
      await result.current.refreshAccount();

      const all = result.all.filter(isNotError);

      const [init, loading, success] = all;

      expect(init.atom).toStrictEqual<AccountAtom>({
        isLoading: false,
        account: null,
        error: null,
      });

      expect(loading.atom).toStrictEqual<AccountAtom>({
        isLoading: true,
        account: null,
        error: null,
      });

      expect(success.atom).toStrictEqual<AccountAtom>({
        isLoading: false,
        account: { id: "testId", name: "test" },
        error: null,
      });
    });
  });
  
  // other test...

});

selectors.test.ts
selectors.test.ts
import { renderHook } from "@testing-library/react-hooks";
import { RecoilRoot, useRecoilValue } from "recoil";

import { accountAtom } from "./atoms";
import { accountUiState } from "./selectors";
import { Account, AccountAtom, AccountUiState } from "./types";

const renderRecoilHooks = (initialValue: AccountAtom) =>
  renderHook(() => useRecoilValue(accountUiState), {
    wrapper: ({ children }: { children: React.ReactNode }) =>
      RecoilRoot({
        children,
        initializeState: ({ set }) => {
          set(accountAtom, initialValue);
        },
      }),
  });

describe("AccountSelectors", () => {
  test("is loading", async () => {
    const initialValue: AccountAtom = {
      isLoading: true,
      account: null,
      error: null,
    };

    const { result } = renderRecoilHooks(initialValue);

    const expected: AccountUiState = {
      status: "Loading",
    };

    expect(result.current).toStrictEqual(expected);
  });

  test("is LoggedIn", async () => {
    const account: Account = {
      id: "testId",
      name: "test",
    };
    const initialValue: AccountAtom = {
      isLoading: false,
      account: account,
      error: null,
    };

    const { result } = renderRecoilHooks(initialValue);

    const expected: AccountUiState = {
      status: "LoggedIn",
      account: account,
    };

    expect(result.current).toStrictEqual(expected);
  });

   // other test...

});

課題・検討の余地がある点

React.Suspenseを使っていない

今回の設計はAPIを叩く処理をoperations.tsに纏めてselectors.tsではatomを元にしたデータを返すようにしたので、Asynchronouse Data Queriesは使わない形式をとっています。
そのためView側はSuspenseを使わずにUiState.stateから表示を切り替える実装に落ち着きました。

React18のメインの変更であるSuspenseを使わないことがどう転ぶか、まだ検討の余地がありそうです。

operationsのテストの書き方について

operationsでatomの内容を複数回変更する場合があり、今回は内容がどう変化したかをinit, loading, success をそれぞれ順番に確認するテストを書きました。
このコードは無駄が多いと感じていて、もう少し綺麗な書き方ができないかは考えたいです。(テストを書くモチベーションを上げたい。)

operations.test.ts
...
      const all = result.all.filter(isNotError);

      const [init, loading, success] = all;

      expect(init.atom).toStrictEqual<AccountAtom>({
        isLoading: false,
        account: null,
        error: null,
      });

      expect(loading.atom).toStrictEqual<AccountAtom>({
        isLoading: true,
        account: null,
        error: null,
      });

      expect(success.atom).toStrictEqual<AccountAtom>({
        isLoading: false,
        account: { id: "testId", name: "test" },
        error: null,
      });
...

まとめ

状態管理にRecoilを採用するにあたりviewがatomの知識を持たないで済むよう、operations, selectorsを使った単方向データフローの設計を今回は考えました。

今回考えたものはReduxを使うときのRe-ducksパターンの考えがベースになっているので、Reduxを利用している人から見ても馴染みのあるものになったと思います。

Discussion