recoil-persistを使う時はHydration Errorに注意しよう

投稿日 2023-09-04
最終更新日 2023-11-18

つい最近、Next.jsでHydration Errorが発生し、ハマりました。
原因がRecoilを永続化するためのrecoil-persistであることが判明したため備忘録がてら原因と対策をまとめていきます。

今回は、エラー内容を再現した簡単な例を作ったのでそれを元に解説します。
ソースコードはgithubに上がっています。
https://github.com/matsunagadaiki151/hydration-error-next-recoil-example

前提

サンプルは以下の技術で作られています。

  • Next.js 13
  • Recoil

他にはTailwindCSSなどが使われていますが、本質とは関係ないので省略します。

挙動は以下となっています。

  • ボタンが押された状態かどうかを格納するグローバルなRecoilの変数、「isClicked」があります。(別にグローバルである必要はないですが、例なので気にしないでください。)
  • isClickedの初期値はfalseです。
  • ボタンを押すとisClickedの値がtrueになります。
  • isClickedがtrueの場合、「ボタンがクリックされました」というラベルが表示されます。つまり、ボタンが押されるとこのラベルが表示されます。
  • リロードすると、useState内でisClickedの値がfalseになります。つまり、ラベルが非表示になります。


エラー内容

エラーは以下の通りです。



具体的にはラベルが表示されている(isClicked=true)の時にリロードすると上記のエラーが発生します。

エラーメッセージは以下の通りです。

Error: Hydration failed because the initial UI does not match what was rendered on the server.

Warning: Expected server HTML to contain a matching <div> in <div>.

See more info here: https://nextjs.org/docs/messages/react-hydration-error


翻訳すると初期のUIがサーバーから取得されたものと一致しない時に発生するエラーのようです。
例えばHTMLの文法ミスの時に発生するらしいですが、今回はそのような文法が見つからなかったため、エラー解消に時間がかかりました。

原因

以下のようにrecoil-persistでisClickedの値を永続化していることが原因でした。

export const isClickedState = atom<boolean>({
  key: "isClickedState",
  default: false,
  // 状態の永続化を行う設定
  effects_UNSTABLE: [persistAtom],
});


永続化することで、リロードされてもRecoilの値を保つことができます。
そのため、時間がかかる処理によって取得したデータがリロードによって消滅してしまうことなどを防ぐことができます。

今回ボタンが押された際にラベルを表示するので、以下のようなJSXとなっています。

  return (
    <div className="flex flex-col items-center space-y-2">
      <button
        onClick={clickHandler}
        className="text-white bg-blue-500 rounded-xl px-4 py-2"
      >
        ボタンを押す
      </button>
      {isClicked && <div>ボタンがクリックされました。</div>}
    </div>
  );


isClickedは、trueの状態でリロードしても、永続化されているのでtrueのままです。この状態だとラベルが画面に描画されることとなり、確かに初回レンダリング時と異なる状態になってしまいます。
なお、isClickedはロード時にuseEffectでfalseにしますが、useEffectはレンダリングの後に実行されるためこの解決策では解消できません。これはuseLayoutEffectを使用しても同様です。

  // useEffectはレンダリングの後に実行されためhydrationErrorの解消にはならない。
  useEffect(() => {
    setIsClicked(false);
  }, [setIsClicked]);


対策

永続化されていない他の変数を利用することによって解決しました。
今回はuseStateでisMountedという状態を用意しました。これはuseEffect内でtrueになる簡単な状態変数です。

  const [isClicked, setIsClicked] = useRecoilState(isClickedState);
  const [isMounted, setIsMounted] = useState<boolean>(false);  // マウントされているかを表す変数

  useEffect(() => {
     setIsClicked(false);
     // ここでのみtrueにする。
     setIsMounted(true);
  }, [setIsClicked]);


先ほどのラベルを表示する条件にこのisMountedを追加します。

{/* リロード時には表示されなくなる。 */}
{isClicked && isMounted && <div>ボタンがクリックされました。</div>}


これにより、リロード時にラベルが表示されなくなります。これによって初回レンダリングのUIと一致するため、エラーは出なくなります。

今回は以上になります。今回は簡単な例で落とし込んで紹介しましたが、実際は永続化にする必要のある変数でこのエラーが発生していました。そのため、まずはそもそも永続化する必要があるのかということを検討しましょう。

参考