はじめに
Reactで扱う状態は大きく2種類に分けられると考えています。アプリケーション全体や複数のコンポーネント間で扱うグローバルな状態と単一コンポーネントや周辺の隣接したコンポーネントだけで扱うローカルな状態です。
この記事では私の考えるグローバルな状態とローカルな状態それぞれの扱い方を紹介します。Reactにおける状態管理の方法論の1つとして参考にしていただければ幸いです。
グローバルな状態
グローバルな状態は主にアプリケーション全体のように広い範囲で扱う状態を指します。具体的にはサーバーから送られてくるユーザーの一覧データ取得や、スナックバーの表示のようなアプリ全体で扱うコンポーネントの状態管理などを扱います。データを取得するために利用するパラメーターのように特定の機能に対する値など全体で保持したい状態(異なるライフサイクルで扱う状態)もグローバルな状態として扱います。
そんなグローバルな状態は基本的にライブラリを用いて扱うことをお勧めします。ReactのuseContext
などを用いて実装可能ですが、初期値の指定やProviderの定義、メンテナンスコストの高さことからライブラリを用いた方が良いと考えています。
これらの状態はライブラリの変更のしやすさなどを考えて、ライブラリについての知識をなるべく漏れ出さないようにglobalStates
という名前のディレクトリ内で閉じ込めて管理します。状態管理に関するライブラリのAPIはこのディレクトリ内でのみ使用するようにして、コンポーネントからグローバルな状態を扱う時はそこより提供されるhooksを利用するようにします。
グローバルな状態はさらにユーザー側で持つ状態とサーバーから送られてくるデータを扱う2つの状態に分けることが可能と考えています。
前者はrecoilなどの状態管理ライブラリで、後者はSWRなどのデータ取得のためのライブラリで扱うようにしています。recoilはjotaiなど、SWRはReactQueryのようなライブラリが類似するものとしてあります。この記事ではrecoilとSWRを用いたとして記述しますので各自置き換えて読んで頂ければと考えています。
グローバルな状態を扱うために2つのライブラリを利用する理由として、recoilでデータのキャッシュを上手く取り扱えなかったという背景があります。
先述の通りglobalStates
フォルダ内にライブラリのコードを隠してhooksとして出荷する方式なので、recoilで優れたサーバーデータの扱いができるようになればglobalStates
からSWRに関するAPIをすべてrecoilのAPIに置き換えるようなケースも想定した構成となっています(recoilが非同期の状態の取り扱いには優れているのに加えて、グローバルな状態を1つのライブラで扱った方が良いパフォーマンスを得られるので可能になればどちらの状態もrecoilで行いたいと今は考えています)。
ユーザーがもつグローバルな状態
まずはrecoilで扱う状態についてです。この状態は異なるライフサイクル間で共有して利用したい状態や離れたコンポーネント間で扱いたい状態などが当てはまります。
異なるライフサイクル間で共有する状態
まずはアカウント一覧を取得する時に必要となるページネーションの値を扱うことを考えます。この状態は他のページに遷移して戻ってきた時も同じページを表示するような要件があると仮定すれば、異なるライフサイクル間で共有したい状態となります。
そしてそのような状態は以下のように定義されたhooksから利用できるようにします。
export const RECOIL_KEYS = {
ACCOUNTS_PAGE_ATOM: 'accountsPageAtoms',
} as const satisfies Readonly<Record<string, string>>;
import { atom, useSetRecoilState, useRecoilValue } from 'recoil';
import { RECOIL_KEYS } from '../recoilKeys';
const accountsPage = atom<number>({
key: RECOIL_KEYS.ACCOUNTS_PAGE_ATOM,
default: 0,
});
export const useGetAccountPages = () => useRecoilValue(accountsPage);
export const useSetAccountPages = () => useSetRecoilState(accountsPage);
recoilで扱うキーは重複が許されないためglobalstates
直下のrecoilKeys.ts
で一元管理するようにしています。これによって同じキーが存在しないことを確かめやすくはなります。ただし、これで存在しないことを保証しているわけではないので、Readonly<Record<string, string>>
をさらに適切な型を定義し直すかテストを記述して担保することをお勧めします。状態の定義については芸がないですが、atom
を定義してuseRecoilValue
とuseSetRecoilState
をラップしたcustom hooksを提供しています。このようにしてrecoilのAPIを直接利用することを防いでいます(ディレクトリ構成については後程のサーバーデータの状態の方で言及します)。
離れたコンポーネント間やアプリ全体で扱う状態
次に離れたコンポーネント間で扱いたい状態としてアプリ全体で利用するスナックバーの状態を考えます。スナックバーは表示に関する状態と、メッセージ内容に関する状態を元にレンダリングされるとします。
import { RECOIL_KEYS } from '@/globalStates/recoilKeys';
import {
atom,
selector,
useRecoilState,
useRecoilValue,
useSetRecoilState,
} from 'recoil';
const snackBarOpen = atom<boolean>({
key: RECOIL_KEYS.SNACK_BAR_OPEN,
default: false,
});
const snackBarMessage = atom<string>({
key: RECOIL_KEYS.SNACK_BAR_MESSAGE,
default: '',
});
const snackBar = selector<{ open: boolean; message: string }>({
key: RECOIL_KEYS.SNACK_BAR,
get: ({ get }) => ({
open: get(snackBarOpen),
message: get(snackBarMessage),
}),
});
export const useOnOpenSnackBar = () => {
const setOpen = useSetRecoilState(snackBarOpen);
const setMessage = useSetRecoilState(snackBarMessage);
return (message: string) => {
setOpen(true);
setMessage(message);
};
};
export const useOnCloseSnackBar = () => {
const setOpen = useSetRecoilState(snackBarOpen);
return setOpen(false);
};
export const useGetSnackBarState = () => useRecoilValue(snackBar);
recoilKeys
の定義部分は端折りました。このコードによってスナックバー自体を定義するファイルではuseGetSnackBarState
を用いて状態の取得して見た目の調整をし、開きたいときは外部からuseOnOpenSnackBar
を用いて指定したメッセージを含むスナックバーを表示させることができます。useOnCloseSnackBar
はスナックバーを閉じたい時に利用するhooksで、スナックバーの実装によっては内部と外部両方から利用するので他のhooksと分けました。
このように実装することで不要なバケツリレーもありませんし、スナックバーを呼び出す各コンポーネントはこれらの状態をサブスクライブしないのでパフォーマンスも良く、コードの見通しが良い状態での開発が可能になります。
サーバーデータの状態
次にSWRで扱う状態です。外部のサーバーから取得したデータが当てはまります。
例えばアカウントの一覧データや詳細データが欲しい場合は以下のように実装しています。
export type Account = {
id: number;
name: string;
email: string;
imagePath: string;
};
import { Account } from './type';
import useSWR from 'swr';
// ここには先ほど紹介したアカウント一覧のページネーションについての状態が定義されています。
export const useAccounts = (): readonly Account[] => {
// globalStates内であればSWRからrecoilを直接触ることもある
const page = useRecoilValue(accountsPage);
const { data } = useSWR(
`/accounts?page=${page}`,
fetcher,
{
suspense: true,
},
);
return data
};
import { Account } from './type';
import useSWR from 'swr';
export const useAccount = (id: number): Account => {
const { data } = useSWR(
`/accounts/${id}`,
fetcher,
{
suspense: true,
},
);
return data
};
fetcher
の実装は状態管理に直接関係ないので省略しました。ディレクトリはAccounts
のように取得するデータを表す命名でフォルダを作成して、一覧に関する状態であればindex.ts
、詳細に関する状態であればshow.ts
のようなファイル名でこれらの状態を扱うhooksを記述しています(index.ts
を潰してしまうので命名は再考の余地がありそうです)。hooksは使う側で違和感がないものを提供したいのでglobalStates
ではrecoilで扱う状態とSWRで扱う状態は混在することを許可するようにしています。グローバルな状態はtype.ts
というファイルで定義した型を元にして外部で利用します。globalStates
が状態に関する単一の源となるためにしていますが、サーバーとクライアントで型を共有しているケースなど他の箇所に置く方が良い場合もあります。
サーバーからのデータはSWRの重複排除機能のおかげて複数のコンポーネントから同時に呼び出しても通信が1度しか行われません。これによって、他の状態に対してサーバーからの状態を特別扱いする必要がないので開発体験としてとても優れているように感じます(React Queryも同様に扱えます)。
さらにSWRではデータの取得と同時にデータのキャッシュも行ってくれます。これはサーバーとの通信回数の削減やユーザーの画面にLoading中の状態を露出する機会が少なくなる点からはとても助かっていますが、データの更新処理を行なった時には関連するキーのキャッシュを削除する必要があります。キャッシュ削除の処理はSWRに関連する操作ですので、それを用いた更新処理もglobalStates
でhooksを作成して提供する必要があります。SWR2以前はmutate
を用いて、それ以降はuseSWRMutation
を用いて作成します。
import useSWRMutation from 'swr/mutation';
export const useAccountMutation = (id: number) => {
const { trigger, isMutating } = useSWRMutation(
`/accounts/${id}`,
update,
);
return {
trigger: (newName: string) => trigger(newName),
isMutating,
};
};
今回は楽観的なデータ更新や、キャッシュの初期化をキーが同じものに対してしか行いませんでしたが、行う場合は全てこのhooksの実装に閉じ込めて行うようにしています。
ローカルな状態
ローカルな状態の扱いは基本的にReactのuseState
などのAPIを用いて行います。バケツリレーで状態の受け渡しが大変にならない程度またはライフサイクルを跨いで保持する必要のない状態をローカルな状態として扱います。
基本的に扱う状態について特質すべき点はありませんが、フォームに関わる状態はReact Hook Formを用いて管理することも候補として考えています。フォームに関わる状態についてはコンポーネント設計などの状態以外の部分に深く関わってくるので、別の機会で紹介したいと考えています。
さいごに
Reactにおける状態の扱い方の一例を紹介しました。適切に状態を分割して扱うことでパフォーマンス上の利点を得られたり、コード全体の見通しがかなり良くなるので参考にしてみてはいかがでしょうか。
私はReactのディレクトリ構成に定型があっても正解はないと考えているので、さまざまな構成の中から開発するプロダクトに合った定型を選択し、成長させてベストな構成を作っていきたいです。