LoginSignup
1
1

More than 1 year has passed since last update.

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

Posted at

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

なお、執筆時点では

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

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

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

試行結果

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

PageA, PageB, PageCの間で、LocalStorageおよびSessionStorageによって永続化した値をページをまたいで共有している。

コードの解説など

概要と方針:

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

_app.tsx

_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:

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の定義の中で

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

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

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

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, PageBについては正味全く同じ内容になっている。

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は保持するstateは同一だが入力は出来ず、読み取り専用。

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周りを読むと色々書いてある。

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

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

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

PageAで両方の入力欄の値を変更した後に、PageBもしくはPageC新しいタブで開いた際、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ではのvalueに値を保持している:

<input value={localValue} />

が、Cでは

<div>{localValue}</div>

のような形で埋め込んでいるのが差異。<input>の方では上記のHydrationErrorは何故か起こらない模様。
他にもHydrationErrorを回避する方法や条件があるかもしれないが、詳しいところまでは現状調べきれていない。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1