はじめに
Recoil とは
global state 管理ライブラリであり、add-on 充実しているのでユースケースに沿って機能拡張可能である。
今回は、Next.js アプリケーションにおいて、React のみの機能では実装が複雑であったり管理が煩雑になりそうな以下のケースについてトライした。
- ブラウザの localStorage を利用した state の永続化
- URL クエリパラメータを利用した state の永続化
TL;DR
- ブラウザの localStorage を利用して state の永続化を行う場合、サーバーサイドレンダリング時点では localStorage の値が使用されないため、注意が必要。
-
<select>
を使ったコンポーネントの場合、画面初期表示時の初期選択 (<select>
のvalue
attribute) がサーバー側でレンダリングされるため、クライアント側では、localStorage の値をvalue
にセットするために useEffect 等を利用して再レンダリングした。
-
- URL クエリパラメータを利用して state の永続化を行う場合、画面リロード時はクエリパラメータの値を state にシンクしてくれなかったので、カスタムフックを作成して対応した。
前提・環境
主なパッケージは以下の通り。
- react: 18.3.1
- next: 14.2.3
- recoil: 0.7.7
- recoil-sync: 0.2.0
- @recoiljs/refine: 0.1.1
基本的な使い方
既に詳しい解説記事が多くあるので、サクッと説明する。
install
※React/Next.js プロジェクトを作成するところまでは省略
npm install recoil
設定
App Router におけルートディレクトリの layout に以下を記述
import RecoilProvider from '@/components/RecoilProvider';
export default function Layout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ja">
<body>
<RecoilProvider>
{children}
</RecoilProvider>
</body>
</html>
);
}
RecoilProvider
はクライアントコンポーネントである必要があるため、以下のような形で外出しして layout で呼び出す。
'use client';
import { RecoilRoot } from 'recoil';
export default function RecoilProvider({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<RecoilRoot>
{children}
</RecoilRoot>
);
}
使用例
Recoil では、状態を atom という単位で管理する。
import { atom } from 'recoil';
export const loginIdState = atom({
key: 'loginId',
default: 'defaultId',
});
以下の擬似コードでは、atom を取得・設定するための基本的なメソッドの使用例を示す。雰囲気を掴んでみてほしい。
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { loginIdState } from 'recoilState'
// 値の参照と設定両方行う場合
const [state, setState] = useRecoilState(loginIdState);
// 値のみ使用
const state = useRecoilValue(loginIdState);
// セッターのみ使用
const setState = useSetRecoilState(loginIdState);
// 最初はデフォルト値
console.log(state) // 'defaultId'
// 入力を受け付けると
<input type="text" onChange={() => setState(e.target.value)} />
console.log(state) // 入力した内容で更新されている
localStorage を利用した state の永続化
ここからが本記事のトピックとなる。
設定
import { atom } from 'recoil';
const fruitEffect =
(key) =>
({ setSelf, onSet }) => {
if (typeof window === 'undefined') return;
const savedValue = localStorage.getItem(key);
if (savedValue != null) {
setSelf(JSON.parse(savedValue));
}
onSet((newValue, _, isReset) => {
isReset
? localStorage.removeItem(key)
: localStorage.setItem(key, JSON.stringify(newValue));
});
};
export const fruitState = atom({
key: 'my_fruit',
default: 'banana',
effects: [fruitEffect('my_fruit')],
});
localStorage により状態の永続化を行うための準備を行う。Atom Effects という機能を利用して atom 初期化時や更新時に呼ばれるフック(副作用)を定義する。
effects
に配列関数を与えて、その関数の中で今回は state <-> localStorage のやり取りを行うための処理を記述する。
使用例
'use client';
import { fruitState } from 'fruitAtom';
import { useRecoilState } from 'recoil';
import { useState, useEffect } from 'react';
export const SelectFruits = () => {
const fruitDefaultOptions = {
banana: '🍌Banana',
apple: '🍎Apple',
orange: '🍊Orange',
grape: '🍇Grape',
};
const [fruitOptions, setFruitOptions] = useState([] as string[]);
const [fruit, setFruit] = useRecoilState(fruitState);
useEffect(() => {
setFruitOptions(Object.keys(fruitDefaultOptions).map((x) => x));
}, []);
return (
<div>
<span style={{ lineHeight: '32px' }}>フルーツ: </span>
<select
onChange={(e) => {
setFruit(e.target.value);
}}
value={fruit}
>
{fruitOptions.map((o) => {
return (
<option key={o} value={o}>
{fruitDefaultOptions[o]}
</option>
);
})}
</select>
</div>
);
};
動作確認:
このコードでは、2 つのことを行っている。
- 初期表示時には
value
にfruit
を表示 -
onChange
でfruitOptions
から選択したものを localStorage に保存
ここでは「選択肢配列 (options
) を useEffect で state につっこむことで初期化する」という一見非効率なことを行なっている。その理由は、Next.js におけるクライアントコンポーネントの仕様に起因する。このコードのように 'use client';
ディレクティブのあるものはクライアントコンポーネントとなるが、クライアントコンポーネントは全てがクライアント側でレンダリングされるのではなく、サーバー側であらかじめレンダリングできるものは HTML にレンダリングされた状態でブラウザにレスポンスされる。<select>
コンポーネントの場合、value
に与えた初期値はサーバー側でレンダリングされるため、初期表示時に (ブラウザからしかアクセスできない) localStorage に保存しておいた fruit
を表示する、ということができないのである。そこで、「<select>
コンポーネントは options
を変更すると再レンダリングされる」という事実を利用して、初回レンダリング時のみ useEffect によって options
を変更することで value
へ動的に初期値をセットしている。
実際に以下のコードのように、options
に固定値を与えると、サーバーサイドでレンダリングされた value
は常に banana
となる (atom 生成時に「default: 'banana'
」としているため)。そしてその場合でも、localStorage には正しい fruit
の値がセットされているため、見た目と実際の値に齟齬が生じることになる。
export const SelectFruits = () => {
const fruitOptions = {
banana: 'Banana',
apple: 'Apple',
orange: 'Orange',
grape: 'Grape',
};
- const [fruitOptions, setFruitOptions] = useState([] as string[]);
const [fruit, setFruit] = useRecoilState(fruitState);
- useEffect(() => {
- setFruitOptions(Object.keys(fruitDefaultOptions).map((x) => x));
- }, []);
+ const options = ['banana', 'apple', 'orange', 'grape'];
// 以下同じ
// ...
画面リロード後、localStorage には正しい値が入っているにも関わらず、選択されているのは意図しないフルーツだ。
URL クエリパラメータを利用した state の永続化
state 永続化のもう一つの手段として、URL のクエリパラメータを利用するというものがある。
設定
追加で必要なパッケージをインストールする。
npm install recoil-sync @recoiljs/refine
RecoilProvider
に以下の例のような修正を施す。
'use client';
import { RecoilRoot } from 'recoil';
import { RecoilURLSync } from 'recoil-sync';
export default function RecoilProvider({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<RecoilRoot>
<RecoilURLSync
location={{ part: 'queryParams' }}
browserInterface={{
getURL: () => 'http://....',
}}
serialize={(x) => JSON.stringify(x).split('"').join('')}
deserialize={(x) => JSON.parse(x)}
>
{children}
</RecoilURLSync>
</RecoilRoot>
);
}
RecoilURLSync
API に渡すパラメータをそれぞれ簡単に説明する。
-
location
: "part: 'queryParams'" を指定することで、各 atom がそれぞれ単独でクエリパラメータとシンクするようになる。 -
browserInterface
: デフォルトでは URL と直接シンクするが、今回のようなサーバーサイドレンダリング環境ではカスタムインターフェースを設定しないと 500 エラーが表示された。 -
serialize
: クエリパラメータのシリアライズするときの処理を定義。デフォルトだと「param="xxx"」のようにパラメータの値が"
によって囲われてしまうため、それを取っ払う処理を記述。 -
deserialize
: クエリパラメータのでシリアライズの処理を定義。
import { atom } from 'recoil';
import { syncEffect } from 'recoil-sync';
import { string } from '@recoiljs/refine';
export const fruitState = atom<string>({
key: 'my_fruit',
default: 'banana',
effects: [
syncEffect({
refine: string(),
}),
],
});
syncEffect
により、atom を外部のストレージ(今回はクエリパラメータ)とシンクさせるようにする。refine
は、入力のバリデーションを行う(今回は、クエリパラメーたが string であることをチェックする)
使用例
'use client';
import { fruitState } from 'fruitAtom';
import { useQueryParams } from 'useQueryParams';
export const SelectFruits = () => {
const fruitDefaultOptions = {
banana: '🍌Banana',
apple: '🍎Apple',
orange: '🍊Orange',
grape: '🍇Grape',
};
const { val, set } = useQueryParams('my_fruit', fruitState);
return (
<div>
<span style={{ lineHeight: '32px' }}>フルーツ: </span>
<select
onChange={(e) => {
set(e.target.value);
}}
value={val}
>
{Object.keys(fruitDefaultOptions).map((o) => {
return (
<option key={o} value={o}>
{fruitDefaultOptions[o]}
</option>
);
})}
</select>
</div>
);
};
ページリロード時にクエリパラメータが atom に反映されない事象が解決できず、useQueryParams
というカスタムフック内で直接クエリパラメータから値を取得するようにした。合わせてセッターも取得し、<select>
コンポーネントの onChange
イベントハンドラに渡している。
'use client';
import type { RecoilState } from 'recoil';
import { useSetRecoilState } from 'recoil';
import { useSearchParams } from 'next/navigation';
export const useQueryParams = <T>(key: string, recoilState: RecoilState<T>) => {
const searchParams = useSearchParams();
const val = searchParams.get(key) || undefined;
const set = useSetRecoilState(recoilState);
return { val, set };
};
動作確認:
終わりに
React アプリケーションでの state 管理はいろんな方法があり、ネイティブフックの 「useContext + useReducer」の組み合わせや Redux の利用なども考えられる。そんな中、まだ新しめの 3rd パーティ製ライブラリの Recoil はその手軽さと柔軟さからとても使いやすいと感じた。まだ使っていない機能もたくさんあるので、折に触れて試していきたい。