Reactの状態管理ライブラリとして何を使っていますか。巷には状態管理ライブラリが溢れており、選択に悩むことが多いと思います。ここではその選択肢の一つであるRecoilに重きを置いてReactにおける状態管理について解説します。Recoilについてだけ知りたい場合はrecoilについてから読んでください。
この記事ではクラスコンポーネントは利用せず関数コンポーネントを扱う前提で書きます。
Reactの原理と思想
Reactは
ユーザインターフェース構築のための JavaScript ライブラリ
https://ja.reactjs.org/
であり、以下の3つの理念が掲げられています。
- 宣言的な View
- コンポーネントベース
- 一度学習すれば、どこでも使える
Reactは理念を大事にしているライブラリですので、これらは頭に置いておきたいです。Reactはバージョンアップによる破壊的変更は比較的少ないライブラリですが、理念に従っていない使用方法をしていた場合は新機能が使えなくなることはよくあります。React18へのバージョンアップではuseRefを用いて状態を管理していた場合、新機能であるTransitionが正しく動作しないというケースがありました。そのためただ動くコードを書くのではなく、理念を正しく理解して利用することが求められています。
状態管理の手法
Reactでは状態管理を行う公式のAPIとしてuseState
、useReducer
などが挙げられます。useRef
を用いた状態管理を見たことがある方もいらっしゃると思いますが、先述の通りReactが意図する使用方法ではないためここでは考えないものとします。単一コンポーネント内で状態をuseState
やuseReducer
で管理することには全く問題ありません。むしろそれがベストと言って良いでしょう。しかし、複数コンポーネントで共通の状態を管理する場合にこの二つのAPIでは問題が起きます。Reactの機能で状態を共有して管理する方法は一般的に2つあります(他に良い方法があれば教えてください)。1つは状態を親から子へpropsで伝えていく方法、もう一つはuseContextを使ってpropsを経由せずに伝えていく方法です。Reactでは親から子への情報伝達が原則となっており、異なるコンポーネント間で同じ状態を扱うときは共通の祖先が存在することが約束されています。
バケツリレー
状態を親から子へpropsを介して伝えていく方法はバケツリレー方式と呼ばれています(もしくは呼びます)。具体的には以下のコードのようなものを言います。
const GrandChild = (props) => <>{props.count}</>;
const Child = (props) => (
<>
<p>
click count(by child):
<GrandChild count={props.count} />
</p>
</>
);
const Parent = () => {
const [count, setCount] = useState();
const increment = () => setCount(count + 1);
return (
<>
<Child count={count} />
<p>
click count(by parent):
<GrandChild count={props.count} />
</p>
<button onClick={increment}>+1</button>
</>
);
};
ParentコンポーネントはChildやGrandChildコンポーネントを呼び出し、ボタンとボタンを押すと値が増える二つの数値を表示します。コード内にはParentとChildとGrandChildの3つのコンポーネントがあり、一部親から孫を呼んでいますがおおよそ名前の通りの関係となっています。これらは共通してcountという状態を扱っており、親で生成された状態を子供または孫、子供に伝搬した状態を子供から孫に伝える仕組みになっております。
シンプルで分かりやすいコードとなっていますが、これには問題があります。
1つ目はコンポーネントの状態に対する責務が曖昧になっている点です。この例では親や孫はcountを操作する立場なのに対し子供はただcountを伝えるだけの役割となっています。しかし、その立場による違いはコンポーネント内部の実装により閉じられており子供であっても状態を使用する立場のように捉えられてしまいます。このように伝えるだけのコンポーネントであっても使用するかのような立場に捉えられてしまうことは責務の分離としてはよろしくありません。そしてメンテナンスの際に厄介になります(命名変更などがあれば全て変えていかなければならない)。
2つ目はパフォーマンスが劣ってしまう点です。Reactではpropsの変更が起きたコンポーネントは再レンダリングされます。バケツリレーではpropsに状態をのせて伝えていくので伝えるだけの中間コンポーネントであっても状態の変化によって再レンダリングされてしまいます。実際に情報を扱っているコンポーネントだけではなく再レンダリングの必要のないコンポーネントに対しても行われてしまうので損ですよね。
useContext
このAPIでも親から子へ伝わることには変わりありません。上位にProviderを設置することでその配下であればどこからでもpropsを介さずに状態を手に入れることができます。これによって先ほど挙げた欠点は全て解消することができます。先ほどと同様のコードを記述すると少し煩雑ですが以下のようになります。
const initialCount = 0;
const CountContext = createContext(initialCount);
const SetCountContext = createContext(() => {});
export const useCount = () => useContext(CountContext);
export const useSetCount = () => useContext(SetCountContext);
export const CountProvider = (props) => {
const [count, setCount] = useState(initialState);
return (
<CountContext.Provider value={count}>
<SetCountContext.Provider value={setCount}>
{props.children}
</SetCountContext.Provider>
</CountContext.Provider>
);
};
const GrandChild = () => {
const count = useCount();
return <>{count}</>;
};
const Child = () => {
const count = useCount();
return (
<>
<p>
click count(by child):
<GrandChild />
</p>
</>
);
};
const Parent = () => {
const count = useCount();
const setCount = useSetCount();
// 本来はsetCountではなくこちらの関数をContextでもった方が良い
const increment = () => setCount(count + 1);
return (
<>
<Child />
<p>
click count(by parent):
<GrandChild />
</p>
<button onClick={increment}>+1</button>
</>
)
};
const App = () => (
<AppStateProvider>
<Counter />
</AppStateProvider>
);
前半はcount
とsetCount
をContextに定義しています。状態を取り出すための関数をhooksに切り出して、count
とsetCount
のProviderをひとまとめにしたコンポーネントを作成しています。Providerを分けたのはsetCountのみしか使わないコンポーネントがcountの更新によって再レンダリングされるのを防ぐためです。これでバケツリレーの時のように余計なコンポーネントの再レンダリングも防げて完璧なように思えますが、やはりこれにも欠点があります。
1つ目はProviderが複数あるということです。先述の通りProviderをまとめてしまうと再レンダリングが必要のないコンポーネントも再レンダリングされてしまうのでパフォーマンスの面から分けることが必要となってきます。そのため状態ごとにProviderが存在し、深いネストされたコンポーネントが出来上がります。Providerが互いに依存している場合は入れ替えによるバグが出てきますし(依存していることを確認するには内部ロジックを見る必要がある)、追加や変更がとても面倒なものになります。
2つ目はProviderの位置が不明瞭であることです。Providerはアプリの支点ではなく任意の位置に設定することができます。そのためContextからの状態の取得をある場所からはできてある場所からは使用できないというケースが生まれます。そのためコンポーネントで使用するときはcontextを使用可能な場所で使用するかどうかの考慮が必要になり、コンポーネントを作る際に外部のことを気にかける必要が出てきてしまいます。これは容易に解決できてアプリ内でルートにしか置かないと決めれば全く問題のない話です(その代わり先述のツリーがより深くなりますが)。
これらは欠点と言っても致命的なものではないです。これらが許容できる場合はuseContextを用いて状態管理を行なっても良いかもしれません。
状態管理ライブラリ
useContextでもある程度の許容で状態管理ができることがわかりました。ではどのタイミング、どういう場合にライブラリを使用するのでしょうか。Reactの開発者である Pete Huntはこう言いました(Fluxは状態管理ライブラリの一種)。
You'll know when you need Flux. If you aren't sure if you need it, you don't need it.
つまり、必要に感じたら使えってことですね。状態管理ライブラリについてよく知らない場合は、Reactの機能だけで状態管理を行うことが苦しくなったら使うべきだと考えています。
有名どころではReduxやMobx、Recoil、Jotaiなどがあります。今回はRecoilについて解説するので他のライブラリが気になる方は是非調べてみてください。
非同期状態ライブラリについて
状態管理ライブラリとしてReact QueryやSWRのようなライブラリを状態管理ライブラリとして使用する場合もあると考えられます。このようなライブラリは非同期な状態を扱うことに限っては正規の状態管理ライブラリと遜色なく扱うことができると考えられます。しかし、非同期な状態ではない他の状態も扱うことはできないのでそのような状態を扱うときはこれらのライブラリでの状態管理は避けた方が良いでしょう。もちろん、これらのライブラリ+他の状態管理を用いて非同期でない他の状態も含めた状態管理を達成することも可能ですが、それは一つの状態管理ライブラリだけを用いた場合に比べてパフォーマンスが落ちてしまいます。
これらのことから非同期データ以外を扱うことがほとんどない場合はReact QueryやSWRを用いることは問題ないが、そうでない場合は一つの状態管理ライブラリを用いて行った方が良いです。
recoil
RecoilはReactの状態管理ライブラリの一つでReactと同様の開発元であるMeta(facebook)が開発を行なっています。
他のライブラリ(主にRedux)と比べて特に良い点について述べます。
1つ目は他のライブラリでは全ての状態を一箇所にまとめて中央集権的なのに対してRecoilは状態をそれぞれ使いたいコンポーネントだけで共有されるところです。これによって状態を取得する時に一度中央にアクセスする必要がないですし、状態も実際に呼び出されるまでは読み込まれません。ここにパフォーマンス上の利点があります。
2つ目は書き心地がReactに沿っていて書きやすいと言う点です。RecoilはReactと開発元が一緒と言うだけはあってReactの理念の一つである宣言的と言う点を重要視してますし、hooks apiとの相性が抜群です。さらにシンプルと言うことも掲げていますので、普段からReact使っている人ならばRecoilの書き心地に感動するでしょう。
導入
npmを使用する場合は以下のコマンドでinstallできます(typeもついてきます)。
npm install recoil
Recoilを使用するにはまずrootにRecoilRoot
を設定する必要があります。そのため以下のように配置してください。この配下でのみRecoilの恩恵を授かれます。ReduxのようにrootにProviderを配置するのでrecoilもまた中央集権的に状態を扱っているように感じます。おそらくですが裏側で中央集権的に持っているだけなので、実際に扱うときは中央から欲しい状態だけを読み込めるようになっています。これによって、使用者の面、パフォーマンスの面からこれを設置するとき以外は中央集権的と考えることはないと考えられます。
function App({ children }) {
return (
<RecoilRoot>
{children}
</RecoilRoot>
);
}
atom
atomはrecoilにおいて状態を指します。この状態は他の状態とは独立して存在しています。先ほどのcountの例だと以下のように宣言できます。
export const count = atom({
key: "count",
default: 0
});
このようにkeyと初期値のみで宣言することができます。keyはユニークである必要があります。
状態を利用する際は以下のように書きます。
const GrandChild = () => {
const [count, setCount] = useRecoilState(count);
return (
<>{count}</>
);
};
useRecoilState
はuseState
と同じような動きをします。しかし、useState
とは異なりこれには宣言が含まれておりません。このように、宣言と利用で分かれているのでより宣言的に感じたり、useState
と似ていることに親近感を覚えますよね。
また、値だけを使用したい、更新関数だけを使用したい場合もあるでしょうそのときはuseRecoilValue
やuseSetRecoilState
を使用することができます。後者を使用しているコンポーネントは状態が変化したとしても再レンダリングを避けることができると言う点で優れています。以下の例ではcountが変わることでGrandChildには再レンダリングが走りますが、Parentの再レンダリングは避けることができます。
const GrandChild = () => {
const count = useRecoilValue(count);
return (
<>{count}</>
);
};
const Parent = () => {
const setCount = useSetRecoilValue(count);
const increment = () => setCount(count + 1);
return (
<>
<GrandChild />
<button onClick={increment}>+1</button>
</>
)
}
さらにuseRecoilCallback
という便利なAPIもあります。このAPIはコンポーネントの読み込みのタイミングではなく何らかのアクションを起こしたときに状態の取得を行なってくれます。これを用いたコンポーネントは状態に更新があった場合でも再レンダリングを行うことはありません(アクションが起きた時に取得すれば良いので)。以下のコンポーネントはボタンをクリックした時にconsoleにcountを吐き出すものでcountを扱っていますが、他のコンポーネントでcountが更新されてもこのコンポーネントは再レンダリングされません。
const ConsoleCount = () => {
const consoleCount = useRecoilCallback(async ({ getPromise }) => {
// buttonが押されてから値を取りに行く
const count = await getPromise(count);
console.log(count);
}, []);
return (
<>
<button onClick={consoleCount}>console count</button>
</>
)
}
selector
atomの説明で
この状態は他の状態とは独立して存在しています。
という一見不要に見える説明をしましたが、これはselectorがあるために行いました。selectorはatomと同じくrecoilにおける状態を指しています。atomと異なる点はその状態はatomもしくはselectorの値から計算される状態であるという点です。宣言方法は以下のようになっています。
export const squareCount = selector({
key: "squareCount",
get: ({ get }) => get(count) ** 2)
keyが必要という面でatomと同じですがatomにあったdefaultが落ちgetが追加されました(keyはatomとselectorで一意)。getは他の状態を用いてselectorの状態を作ることができ、引数のオブジェクトであるgetは他の状態を取得することができます。この例ではatomであるcountを取得してその二乗を取得してsquareCountの値としています。countが更新されればsquareCountは更新され、squareCountを利用しているコンポーネントは再レンダリングされます。この例ではあり得ないですが、countの更新によってsquareCountの値が変わらなかった場合はsquareCountを利用しているコンポーネントは再レンダリングされません。
宣言はこのようにatomとやや異なる方法で行いましたが、呼び出しのAPIはatomと同様のものを使用することができます。もちろん値を更新することもでき、squareCountを更新した場合それの元であるcountも更新されます。ただ、更新するには先ほどの宣言に追加する必要があります。
const squareCount = selector({
key: "squareCount",
get: ({ get }) => get(count) ** 2)
set: ({ set }, newValue) => set(count, newValue ** (1 / 2)))
setは新たな値から逆算して他の状態をsetすることができます。ここで話したgetやsetは非同期でも行うことができるので、APIを呼び出して値を代入するみたいなことも可能です。
family
atomやselectorの派生としてatomFamily、selectorFamilyなるものがあります。基本的にはatomやselectorと変わらないですが、引数を受け取ることができ、それに対応した状態をそれぞれ保つことができます。
atomFamily
atomFamilyは以下のように定義できます。
export const count = atomFamily({
key: "count",
default: 0,
});
引数の値によってdefault値を変えたいときは下のようにすることも可能です。
export const count = atomFamily({
key: "count",
default: (param) => defaultParam(param),
});
状態を扱うときも通常のatomとほとんど変わりなくこの例ではcountに引数を追加することで扱うことができます。
const GrandChild = () => {
// それぞれ独立した状態をもつ
const [count1, setCount1] = useRecoilState(count(1));
const [count2, setCount2] = useRecoilState(count(2));
return (
<>{count1}</>
<>{count2}</>
);
};
他のAPIもatomと同様に使うことができます。
selectorFamily
これもselectorと同じように定義することができます。
export const squareCount = selector({
key: "squareCount",
get: (param) => ({ get }) => get(count(param)) ** 2)
例ではatomFamilyからselectorFamilyを作成していますが、atomからの作成でも全く問題ありません。selectorFamilyの他の扱いもselectorに毛が生えた程度なので割愛します。
familyを扱うと多くのデータの集合からなる配列やマップから条件を与えて取り出すときに引数を条件にして状態を作成することができるなど非常に便利です。
非同期データを扱う
recoilは非同期データの扱いにも優れています。非同期データを扱うにはConcurrent Modeを使うと便利ですので、React18以上であると良いです。
非同期データを扱えると言っても特別な何かがあるわけではありません。同期データを扱うのとほとんど同じように定義可能です。
export const counterInfo = selectorFamily({
key: "counterInfo",
get: (counterId) => async () => {
const response = await getCounterInfo({counterId});
if (response.error) {
throw response.error;
}
return response.name;
},
});
同期関数と異なるのはデータ取得の部分がasync関数であるところです(async関数するのではなくPromiseを返すような関数を定義しても問題ありません)。このように定義した状態は一度取得すると依存関係が変更されるまでキャッシュされます。キャッシュついての設定は公式ドキュメントを参照してください。
このような状態をuseRecoilValueで取り出すと、データの取得中はPromiseをthrow、内部でエラーが起きた場合はErrorをthrow、データの取得がうまくいった場合は値をReturnします。つまり、SuspenceとErrorBoundaryを用いることでうまく画面の表示を決定できるように設計されています。実際に以下のように書くことができます。
const Counter = () => {
const counter = useRecoilValue(counter(1));
return <Presenter counter={counter} />;
}
const App = () => (
<RecoilRoot>
<ErrorBoudary>
<Suspence fallback={<Loading />}>
<Counter />
</Suspence>
</ErrorBoundary>
</RecoilRoot>
);
今回はCounterの構造には興味がないためPresenterで隠蔽しました。また、ErrorBoundaryの実装も本質ではないため省略しました。
React18以前ではSuspenceが実験的な機能として存在しますが、正式な機能では扱うことはできません。そのためLoadableクラスを用いて上記と同様な実装することが多いです。LoadableクラスはRecoilの状態を確認することができ、これよって解決された値を持っているか、エラーが起きたか、待ち状態かを判断することができます。このクラスはuseRecoilValueLoadableで取得することができます。これによって非同期な状態は以下のように扱えます。
const Counter = () => {
const counterLoadable = useRecoilValueLoadable(counter(1));
// loading中ならLoadingを返す
if (counterLoadable.state === 'loading') {
return <Loading />
}
return <Presenter counter={counter.contents} />;
}
const App = () => (
<RecoilRoot>
<ErrorBoundary>
<Counter />
</ErrorBoundary>
</RecoilRoot>
);
Suspenceを使った時と比べてどうでしょうか。Suspenceを使った方がより宣言的に書くことができますね。これらはRecoilの話ではなくSuspenceの話になるので省略します。
recoilで統一する記法
基本的な説明は以上とします。さらに詳しく知りたい場合は公式ドキュメントを参照してください。ここではrecoilで開発する際に設けている簡単なルールを話したいと思います。これがベストとされているものではないので参考程度にお願いします。他により良いルールなどありましたらご教授ください。
keyの一元管理
keyは重複を避けるためにrecoilKey.tsで管理するようにしています。このファイルで一覧を見ることができるので重複の心配が少なくなります。
export const RECOIL_ATOM_KEYS = {
CATEGORIES: "categories"
} as const;
export const RECOIL_SELECTOR_KEYS = {
CATEGORY: "category"
} as const;
AtomとSelectoryのkeyは両方で一意でなければいけないことから、下のようにAtomとSelectorのkeyを同一箇所に書くのも良いと思います。
export const RECOIL_KEYS = {
CATEGORY: "category"
CATEGORIES: "categories"
} as const;
値の読み書きはカスタムフックでラップして行う
これまで具体例としてカウンターを紹介してきました。そしてcountの更新は必ずincrementという1増やす関数でラップして利用していました。
countは1増やす以外の振る舞いはしたくない状態だったとします。これまで紹介してきた実装ではその知識のない誰かがsetCountを用いることで自由に増減することができ、その対策が必要になります。口頭やドキュメントでの共有も良いですが、コード上で1増やすことしかできないようにするのがベストではないでしょうか。そのためにはsetCountを隠蔽してhooksを提供します。以下のようにhooksを作成します。
const count = atom({
key: "count",
defalt: 0;
});
export const countState = () => {
// 値を取り出すだけのhooks
useCountValue = () => useRecoilValue(count),
// incrementしか使えないhooks
useIncrement = () => {
const setCount = useSetRecoilState(count);
const increment = () => {
setCount(count => count + 1);
};
return increment;
},
}
atomの呼び出しはexportされていなければ外部で呼び出しができませんので、定義したファイル内に閉じ込めらます。そして代わりにcountStateをexportすることで状態を外部に書き出しています。これによって外部に書き出すのはuseCountValueとuseIncrementに限るのでuseIncrementを変えない限りcountを1増やす更新以外はできなくなります。これには別の利点もあり、別の状態管理ライブラリに移行する必要が出たときには、状態を利用するコンポーネントを触ることなく、このファイルを書き換えるだけで置き換えが済みます。これらの利点があるので直接recoilのAPIの呼び出しは避けて、hooksのラップしたものを呼び出すようにしました。
フォルダ構成
recoillを扱うロジックはglobalStates配下に全て置きます。globalStates内にrecoilを閉じ込めることで他の状態管理ライブラリへの以降の際にglobalStates内部を触るだけで済みます。また、recoilではなくglobalStatesと名付けたのも同様の理由です。
フォルダ構成は以下のようにしました。
globalStates
├── counter
│ └── counter.ts
└── common
│ └── hoge.ts
└── recoilKey.ts
recoiKey.tsは先述の通りatomやselectorのkeyを管理するファイルです。
counterとして記述しましたが、アプリが持っている各機能名(authなども含む)ごとにディレクトリを作りその中でatomとそれに依存するselectorを定義しています。ファイルは排他的な状態ごとに作成します。これによってatomやselectorをexportすることなくhooksのみが外部から扱える状態になります。
アプリの機能に依存しない状態はcommonフォルダを作成し、その配下に置くようにしています。