← ./articles-ja

Next.jsのlocalStorage hydrationはinitializeWithValue falseだけでは足りない

Next.jsでは、localStorage はclient側にしかありません。server render時には読めません。

そのため、localStorage 由来の値を初期renderで使うと、serverのHTMLとclient初回renderがズレてhydration errorになることがあります。

usehooks-tsuseLocalStorage には、この問題を避けるための設定があります。

const [tabs, setTabs] = useLocalStorage("tabs", [], {
  initializeWithValue: false,
});

ただし、これだけで全て解決ではありません。初回renderの不一致は避けられても、effectがhydration前のdefault値を読んで動くことがあります。

ありがちなバグ

次のコードは自然に見えます。

const [tabs, setTabs] = useLocalStorage<Tab[]>("tabs", [], {
  initializeWithValue: false,
});

useEffect(() => {
  if (tabs.length === 0) {
    setTabs([createDefaultTab()]);
  }
}, []);

しかし、useEffect が最初に動く時点では、tabs はまだ localStorage の値ではなくdefaultの [] です。

保存済みのtabsがある場合でも、hydration完了前にdefault tabを作ってしまうことがあります。

hydration guardを入れる

最初のeffect実行をskipし、依存配列に保存値を入れます。

const [tabs, setTabs] = useLocalStorage<Tab[]>("tabs", [], {
  initializeWithValue: false,
});

const hydrated = useRef(false);

useEffect(() => {
  if (!hydrated.current) {
    hydrated.current = true;
    return;
  }

  if (tabs.length === 0) {
    setTabs([createDefaultTab()]);
  }
}, [tabs, setTabs]);

これで、hookが保存値を読み直した後にeffectが反応できます。

undefined defaultを避ける

配列やobjectには、undefined ではなく型付きの空値を使います。

// 避ける
useLocalStorage<Tab[] | undefined>("tabs", undefined, {
  initializeWithValue: false,
});

// 推奨
useLocalStorage<Tab[]>("tabs", [], {
  initializeWithValue: false,
});

render側の分岐も単純になります。保存値から副作用を作るeffectがある場合は、hydration guardと組み合わせます。

initializeWithValue falseを使わない方がよい場合

server側リソースへ再接続するIDには注意が必要です。

  • WebSocket session ID
  • PTY session ID
  • room ID

これらは初回mount時にすぐ必要なことがあります。default値で一瞬renderすると、別のserver-side resourceへ接続してしまう可能性があります。

まとめ

initializeWithValue: false は、UI preferenceや単純なclient stateには便利です。

ただし、その値を読んで副作用を起こすeffectがあるなら、hydration guardを入れてください。そうしないと、hydration前のdefault値に反応して、重複データや古い状態を作ることがあります。

参考