recoilを使ってNext.js(SSR)のページ間をまたいだLocalStorage/SessionStorageによるデータの共有を行うメモ

個人用メモ。
また、一部微妙な部分も残っているため注意。

なお、執筆時点では

- Next.js: 12.3.1
    - SSR
- React: 18.2.0
- recoil: 0.7.5
- TypeScript: 4.8.4

で動作確認している。（nodeのバージョンなどは↓のcodesandboxに依るので省略）

また、[recoil-persist](https://github.com/polemius/recoil-persist)というライブラリもあったりするが、今回は使用していない。
Next.jsやrecoil自体の説明もしないのでそこも注意。

## 試行結果

せっかくなのでcodesandboxを使ってみた。

https://codesandbox.io/embed/admiring-ritchie-lo8jg4?fontsize=14&hidenavigation=1&theme=dark

[PageA](https://lo8jg4.sse.codesandbox.io/A), [PageB](https://lo8jg4.sse.codesandbox.io/B), [PageC](https://lo8jg4.sse.codesandbox.io/C)の間で、LocalStorageおよびSessionStorageによって永続化した値をページをまたいで共有している。


## コードの解説など

概要と方針: 

- RecoilRootは`_app.tsx`で作っておいて、全ページに適用しておく
- 基本的にはrecoilの公式ドキュメントに記載の[Atom Effects - Local Storage Persistence](https://recoiljs.org/docs/guides/atom-effects/#local-storage-persistence)の通りにやっているだけ
- ただし、Next.jsによるSSRにしている都合上、初期描画時にブラウザ側でなくサーバー側で処理が走る？ようで、localstorageが無いなどと怒られるため対応する処理を追加する。

### `_app.tsx`

```typescript:_app.tsx
import '../styles/globals.css'
import type { AppProps } from 'next/app'
import { RecoilRoot } from 'recoil'

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <RecoilRoot>
      <Component {...pageProps} />
    </RecoilRoot>
  )
}

export default MyApp
```

`_app.tsx`で`<Component />`を`RecoilRoot`で挟んでおく。この際、特に追加の設定などは不要。


### recoilのatomsの定義（stateの作成）

前述の通り、 https://recoiljs.org/docs/guides/atom-effects/#local-storage-persistence のような感じで作っていく。
今回は単純に`string`型の値を保持するようにする。

LocalStorage:

```typescript:src/global-states/localstorage-example.ts
import { atom } from "recoil";

const localStorageEffect = (key: string) => ({ setSelf, onSet }) => {
  if (typeof window === "undefined") return;
  const savedValue = localStorage.getItem(key);
  if (savedValue != null) {
    setSelf(JSON.parse(savedValue));
  }

  onSet((newValue: any, _: any, isReset: boolean) => {
    isReset
      ? localStorage.removeItem(key)
      : localStorage.setItem(key, JSON.stringify(newValue));
  });
};

export const localStorageState = atom({
  key: "localstorage-test",
  default: "localstorage test string.",
  effects: [localStorageEffect("localstorage-example")]
});
```

型周りは現状`any`を多用してサボってしまっているので、時間があれば改善する。
（ https://recoiljs.org/docs/guides/atom-effects の冒頭にAtomEffectsの型が書かれていたりするので、この辺を参考にすれば出来はしそう ）

ポイントとして、storageEffectの定義の中で

```typescript
if (typeof window === "undefined") return;
```

のようにしている箇所がある。
これを入れないとサーバーサイドレンダリングのときに`window`が無いためエラーが発生してしまう。


sessionStorageを使う際も、上記の例で`localStorage`→`sessionStorage`とするだけでほぼ全く同じように使える模様。

```typescript:src/global-states/sessionstorage-example.ts
import { atom } from "recoil";

const sessionStorageEffect = (key: string) => ({ setSelf, onSet }: any) => {
  if (typeof window === "undefined") return;
  const savedValue = sessionStorage.getItem(key);
  if (savedValue != null) {
    setSelf(JSON.parse(savedValue));
  }

  onSet((newValue: any, _: any, isReset: boolean) => {
    isReset
      ? sessionStorage.removeItem(key)
      : sessionStorage.setItem(key, JSON.stringify(newValue));
  });
};

export const sessionStorageState = atom({
  key: "sessionstorage-test",
  default: "sessionstorage test string.",
  effects: [sessionStorageEffect("sessionstorage-example")]
});
```

### ページ作成

recoil周りの最低限の動作確認が目的なので色々と適当なのはご容赦。

[PageA](https://lo8jg4.sse.codesandbox.io/A), [PageB](https://lo8jg4.sse.codesandbox.io/B)については正味全く同じ内容になっている。

```typescript:pages/A.tsx
import React from "react";
import { useCallback } from "react";
import type { NextPage } from "next";
import Link from "next/link";
import { useRecoilState } from "recoil";
import { localStorageState } from "../src/global-states/localstorage-example";
import { sessionStorageState } from "../src/global-states/sessionstorage-example";

const Page: NextPage = () => {
  const [localValue, setLocalValue] = useRecoilState(localStorageState);
  const [sessionValue, setSessionValue] = useRecoilState(sessionStorageState);
  const handleOnChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setLocalValue(e.target.value);
    },
    [setLocalValue]
  );
  const handleOnChange2 = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setSessionValue(e.target.value);
    },
    [setSessionValue]
  );

  return (
    <div>
      <div>PageA</div>
      <div>
        <span>LocalStorage Input</span>
        <div>
          <input type="text" value={localValue} onChange={handleOnChange} />
        </div>
        <br />
      </div>
      <div>
        <span>SessionStorage Input</span>
        <div>
          <input type="text" value={sessionValue} onChange={handleOnChange2} />
        </div>
        <br />
      </div>
      <div>
        <ul>
          <li>
            <Link href="/A">
              <a>Go to PageA</a>
            </Link>
          </li>
          <li>
            <Link href="/B">
              <a>Go to PageB</a>
            </Link>
          </li>
          <li>
            <Link href="/C">
              <a>Go to PageC</a>
            </Link>
          </li>
        </ul>
      </div>
    </div>
  );
};
export default Page;
```
1つ目のinputのvalueがLocalStorageに、2つ目のinputのvalueがSessionStorageに保持される。
入力されている値を変化させながらページAとBを行き来しても値が保持される様子が確認できる。

 [PageC](https://lo8jg4.sse.codesandbox.io/C)は保持するstateは同一だが入力は出来ず、読み取り専用。

```typescript:pages/C.tsx
import type { NextPage } from "next";
import Link from "next/link";
import { useRecoilValue } from "recoil";
import { localStorageState } from "../src/global-states/localstorage-example";
import { sessionStorageState } from "../src/global-states/sessionstorage-example";

const Page: NextPage = () => {
  const localValue = useRecoilValue(localStorageState);
  const sessionValue = useRecoilValue(sessionStorageState);
  return (
    <div>
      <div>PageC</div>
      <div>
        <span>LocalStorage Value</span>
        <div>
          <span>{localValue}</span>
        </div>
        <br />
      </div>
      <div>
        <span>SessionStorage Value</span>
        <div>
          <span>{sessionValue}</span>
        </div>
        <br />
      </div>
      <div>
        <ul>
          <li>
            <Link href="/A">
              <a>Go to PageA</a>
            </Link>
          </li>
          <li>
            <Link href="/B">
              <a>Go to PageB</a>
            </Link>
          </li>
          <li>
            <Link href="/C">
              <a>Go to PageC</a>
            </Link>
          </li>
        </ul>
      </div>
    </div>
  );
};
export default Page;
```

PageAで入力してもPageBで入力してもPageCで相当する値が見れるようになっている。

なお、PageA\~Cのいずれについても、LocalStorageやSessionStorageを意識した実装はされておらず、普通にrecoilの使い方と同じように記述している。


なお、今更だがLocalStorageとSessionStorageの違いについて。

例えば[MDN - Web_Storage_API](https://developer.mozilla.org/ja/docs/Web/API/Web_Storage_API)周りを読むと色々書いてある。


- [Window.localStorage](https://developer.mozilla.org/ja/docs/Web/API/Window/localStorage)

> localStorage プロパティはローカルの Storage オブジェクトにアクセスすることができます。 localStorage は sessionStorage (en-US) によく似ています。唯一の違いは、localStorage に保存されたデータには保持期間の制限はなく、sessionStorage に保存されたデータはセッションが終わると同時に（ブラウザが閉じられたときに）クリアされてしまうことです。

- [Window.sessionStorage](https://developer.mozilla.org/ja/docs/Web/API/Window/sessionStorage)

> sessionStorage プロパティで、 session Storage オブジェクトにアクセスできます。sessionStorage は Window.localStorage に似ています。唯一の違いは、localStorage に保存されたデータに期限がないのに対して、sessionStorage に保存されたデータはページのセッションが終了するときに消去されます。ページのセッションはブラウザを開いている限り、ページの再読み込みや復元を越えて持続します。**新しいタブやウィンドウにページを開くと、新しいセッションが開始します。**これは、セッション Cookie の動作とは異なります。


↑に記載の通り、両者の挙動の差異の一例として新しいタブを開いた場合が挙げられる。

[PageA](https://lo8jg4.sse.codesandbox.io/A)で両方の入力欄の値を変更した後に、[PageB](https://lo8jg4.sse.codesandbox.io/B)もしくは[PageC](https://lo8jg4.sse.codesandbox.io/C)を**新しいタブで**開いた際、`LocalStorage Input`の値は保持されるが`SessionStorage Input`の値はデフォルト値がセットされて保持されないことが確認できる。


### 挙動が微妙な点について

冒頭で書いた通り、若干うまくいっていない？箇所があるので補足。
PageA, Bでは大丈夫だが、値をデフォルト値以外に設定してからPageCでF5などによって再読み込みを行うと

- `Error: Text content does not match server-rendered HTML.`
- `Error: Hydration failed because the initial UI does not match what was rendered on the server.`
- `Error: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.`

といったエラーが起こる。

エラーの中でも https://nextjs.org/docs/messages/react-hydration-error を参照するように言われるが、React Hydration Errorが起こる：

> アプリケーションのレンダリング中に、事前にレンダリングされた React ツリー (SSR/SSG) と、ブラウザーでの最初のレンダリング中にレンダリングされた React ツリーに違いがありました。（上記リンクの該当部分のGoogle翻訳より）

LocalStorageやSessionStorageといったブラウザ側の機能で値を保持する仕組みを使っているため、SSRなどによる事前レンダリングと値が異なってしまう現象は避けるのが難しい？気がする。

なお、エラーが起こるとはいってもブラウザがクラッシュするとかもなく、少なくとも今回のケースでは普通に意図通りに動作しているように見える。気持ち悪いが、実害は少ないような感じもする。


※補足

PageA, BおよびCの差異について、PageA, Bでは<input>のvalueに値を保持している：
```typescript
<input value={localValue} />
```
が、Cでは
```typescript
<div>{localValue}</div>
```
のような形で埋め込んでいるのが差異。`<input>`の方では上記のHydrationErrorは何故か起こらない模様。
他にもHydrationErrorを回避する方法や条件があるかもしれないが、詳しいところまでは現状調べきれていない。