[Next.js] 状態管理ライブラリ「Recoil」による state の永続化

## はじめに

### Recoil とは

global state 管理ライブラリであり、add-on 充実しているのでユースケースに沿って機能拡張可能である。

https://recoiljs.org/

今回は、Next.js アプリケーションにおいて、React のみの機能では実装が複雑であったり管理が煩雑になりそうな以下のケースについてトライした。

- ブラウザの localStorage を利用した state の永続化
- URL クエリパラメータを利用した state の永続化

## TL;DR

1. ブラウザの localStorage を利用して state の永続化を行う場合、サーバーサイドレンダリング時点では localStorage の値が使用されないため、注意が必要。
    * `<select>` を使ったコンポーネントの場合、画面初期表示時の初期選択 ( `<select>` の `value` attribute) がサーバー側でレンダリングされるため、クライアント側では、localStorage の値を `value` にセットするために useEffect 等を利用して再レンダリングした。
1. 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 プロジェクトを作成するところまでは省略

```sh
npm install recoil
```

### 設定

App Router におけルートディレクトリの layout に以下を記述

```tsx:app/layout.tsx
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 で呼び出す。

```tsx:components/RecoilProvider.tsx
'use client';

import { RecoilRoot } from 'recoil';

export default function RecoilProvider({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <RecoilRoot>
      {children}
    </RecoilRoot>
  );
}
```

### 使用例

Recoil では、状態を **atom** という単位で管理する。

```ts:recoilState.ts
import { atom } from 'recoil';

export const loginIdState = atom({
  key: 'loginId',
  default: 'defaultId',
});
```

以下の擬似コードでは、atom を取得・設定するための基本的なメソッドの使用例を示す。雰囲気を掴んでみてほしい。

```tsx
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 の永続化

ここからが本記事のトピックとなる。

### 設定

```ts:fruitAtom.ts
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 初期化時や更新時に呼ばれるフック（副作用）を定義する。

https://recoiljs.org/docs/guides/atom-effects

`effects` に配列関数を与えて、その関数の中で今回は state <-> localStorage のやり取りを行うための処理を記述する。

### 使用例

```tsx:SelectFruits.ts
'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>
  );
};
```

動作確認:

![画面収録 2024-07-01 16.01.10.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/685ade73-a0b7-f7d2-b6f4-a4950e5bc187.gif)


このコードでは、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` の値がセットされているため、見た目と実際の値に齟齬が生じることになる。

```diff_tsx:UnChangeableSelectFruits.tsx
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 には正しい値が入っているにも関わらず、選択されているのは意図しないフルーツだ。

![画面収録 2024-07-02 0.07.03.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/f2df6a61-427f-7788-8646-252a45bc15a6.gif)



## URL クエリパラメータを利用した state の永続化

state 永続化のもう一つの手段として、URL のクエリパラメータを利用するというものがある。

### 設定

 追加で必要なパッケージをインストールする。

```sh
npm install recoil-sync @recoiljs/refine
```

`RecoilProvider` に以下の例のような修正を施す。

```tsx:components/RecoilProvider.tsx
'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` : クエリパラメータのでシリアライズの処理を定義。

```ts:fruitAtom.ts
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 であることをチェックする）

### 使用例

```tsx:SelectFruits.ts
'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` イベントハンドラに渡している。

```ts:useQueryParams.ts
'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 };
};
```

動作確認:

![画面収録 2024-07-01 23.13.49.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/7235e9ca-0f11-f667-3ee5-46207be4e0fb.gif)


## 終わりに

React アプリケーションでの state 管理はいろんな方法があり、ネイティブフックの 「useContext + useReducer」の組み合わせや Redux の利用なども考えられる。そんな中、まだ新しめの 3rd パーティ製ライブラリの　Recoil はその手軽さと柔軟さからとても使いやすいと感じた。まだ使っていない機能もたくさんあるので、折に触れて試していきたい。