•12 min read

Reactで型安全なuseLocalStorageフックを構築する

Reactで型安全なuseLocalStorageフックを構築する

Reactにおける状態管理はよく知られた道筋ですが、揮発性のコンポーネント状態と永続的なブラウザストレージの間のギャップを埋めることは、しばしば微妙なバグを引き起こします。素朴なuseLocalStorageの実装は、ちょっとしたプロトタイプには機能するかもしれませんが、サーバーサイドレンダリング(SSR)のハイドレーションミスマッチ、複雑なTypeScriptの型推論の失敗、タブ間の同期問題により、本番環境のNext.jsアプリケーションではすぐに破綻してしまいます。

この詳細な解説では、高度に技術的で堅牢、かつ完全に型安全なuseLocalStorageフックを構築します。標準的なチュートリアルで見られる基本を超越し、高度なTypeScriptのジェネリック制約、厳密なエラーハンドリング、SSRハイドレーションの安全性、そして複数のブラウジングコンテキストにわたるイベント駆動型状態同期に厳密に焦点を当てます。

Audio Briefing
0:00 / 0:00

素朴な実装の落とし穴

ほとんどの入門チュートリアルでは、次のようなuseLocalStorageフックが提供されています。

import { useState } from 'react';

export function useNaiveLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      return initialValue;
    }
  });

  const setValue = (value) => {
    setStoredValue(value);
    window.localStorage.setItem(key, JSON.stringify(value));
  };

  return [storedValue, setValue];
}

基本的なシングルページアプリケーション(SPA)では機能しますが、この実装には深刻なアーキテクチャ上の欠陥があります。

  1. SSRにおけるハイドレーションミスマッチ: Next.jsのようなフレームワークでは、サーバーはwindowオブジェクトにアクセスできません。クライアントでの初期レンダリングがサーバーのレンダリングと異なる場合(クライアントがlocalStorageから異なる値を読み取るため)、Reactはハイドレーションミスマッチエラーをスローし、UIが破損する可能性があります。
  2. 型安全性の欠如: TypeScriptがないため、storedValueは暗黙的にany型を持ち、静的解析の利点が失われます。
  3. タブ間での古い状態: ユーザーが別のタブでlocalStorageの値を更新しても、このフックはそれに反応せず、UIの状態が不整合になります。
  4. 非効率なJSONパース: 複雑なシリアライズ可能な構造を安全に処理したり、ランタイム型を検証したりしません。
Advertisement

ステップ1:高度なジェネリクスによる厳密な型安全性の強制

まず、堅牢なTypeScriptの基盤を確立しましょう。私たちのフックは、initialValueの型を自動的に推論し、新しい値を設定する際にその型を強制することを望んでいます。これは、ジェネリック型パラメータ<T>を使用することで実現できます。

さらに、useStateはアップデーター関数(prev: T) => Tを渡すことを許可しています。私たちのカスタムフックは、React開発者にとって慣用的なままであるために、この正確なシグネチャをサポートする必要があります。

import { useState, useCallback } from 'react';

// Define the type for our updater function or raw value
type SetValue<T> = React.Dispatch<React.SetStateAction<T>>;

export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, SetValue<T>] {
  // Implementation details follow...
}

SetStateAction<T>を定義することで、TypeScriptに対し、setValue関数が型Tの新しい値、または以前の値を受け取って型Tの新しい値を返すコールバック関数のいずれかを受け入れることができると指示します。これは、ReactのネイティブなuseStateシグネチャを完璧に反映しています。

ステップ2:Next.jsにおけるSSRハイドレーションの克服

ハイドレーションの問題は、サーバーでレンダリングされたHTMLが、クライアントで最初にレンダリングされたHTMLと異なる場合に発生します。これを解決するには、初期レンダリングが常にinitialValue(サーバーも知っている)を使用するようにし、コンポーネントがマウントされた後にのみlocalStorageから読み取るようにする必要があります。

ストレージの読み取りを遅延させるために、useEffect(またはモダンなReactではuseSyncExternalStoreですが、ここでは状態フラグと組み合わせたuseEffectが非常に分かりやすいです)を使用します。

import { useState, useEffect, useCallback } from 'react';

type SetValue<T> = React.Dispatch<React.SetStateAction<T>>;

export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, SetValue<T>] {
  // 1. Initialize with the exact initial value to guarantee SSR hydration parity
  const [storedValue, setStoredValue] = useState<T>(initialValue);
  const [isMounted, setIsMounted] = useState(false);

  // 2. Read from localStorage once the component mounts on the client
  useEffect(() => {
    setIsMounted(true);
    try {
      const item = window.localStorage.getItem(key);
      if (item !== null) {
        setStoredValue(JSON.parse(item));
      }
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error);
    }
  }, [key]);

  // ... setValue implementation
}

isMountedフラグを導入するか、読み取りを遅延させることで、Reactツリーの最初のパスはサーバーと完全に一致します。ハイドレーションが完了した後にのみエフェクトが実行され、ローカルストレージの値を読み取り、永続化されたデータで再レンダリングをトリガーします。

ステップ3:型安全なセッターの実装

セッター関数は、直接の値と関数型アップデーターの両方を処理する必要があります。安定した参照を維持するためにuseCallbackを使用し、子コンポーネントでの不要な再レンダリングを防ぎます。

  const setValue: SetValue<T> = useCallback(
    (value) => {
      try {
        // Allow value to be a function so we have the same API as useState
        setStoredValue((prev) => {
          const valueToStore =
            value instanceof Function ? value(prev) : value;
            
          if (typeof window !== 'undefined') {
            window.localStorage.setItem(key, JSON.stringify(valueToStore));
          }
          
          return valueToStore;
        });
      } catch (error) {
        console.warn(`Error setting localStorage key "${key}":`, error);
      }
    },
    [key]
  );

value instanceof Functionをチェックしていることに注目してください。TypeScriptのSetStateAction型は関数を許可しているため、提供された値がアップデーターコールバックであるかどうかを安全に判断し、そうであればprevの状態に対して実行する必要があります。また、セッターがサーバー評価中に誤って呼び出された場合にSSRクラッシュを防ぐための安全チェックtypeof window !== 'undefined'も含まれています。

Advertisement

ステップ4:Storage APIを介したタブ間同期

真にモダンなWebアプリケーションは、複数のタブ間で状態を同期する必要があります。ユーザーがタブAで「ダークモード」を切り替えた場合、タブBもこの変更を即座に反映する必要があります。ブラウザのネイティブなstorageイベントは、別のドキュメントからストレージ領域が変更されるたびに発生します。

これらの外部ミューテーションを捕捉するためにイベントリスナーをアタッチします。

  useEffect(() => {
    const handleStorageChange = (event: StorageEvent) => {
      if (event.key === key && event.newValue !== null) {
        try {
          setStoredValue(JSON.parse(event.newValue));
        } catch (error) {
          console.warn(`Error parsing storage change for key "${key}":`, error);
        }
      } else if (event.key === key && event.newValue === null) {
        // Handle deletion from another tab
        setStoredValue(initialValue);
      }
    };

    // Listen only on the client
    if (typeof window !== 'undefined') {
      window.addEventListener('storage', handleStorageChange);
    }

    return () => {
      if (typeof window !== 'undefined') {
        window.removeEventListener('storage', handleStorageChange);
      }
    };
  }, [key, initialValue]);

この同期メカニズムにより、高価なポーリングや複雑なWebSocketアーキテクチャを必要とせずに、アプリケーションの状態がグローバルに一貫して維持されます。

ステップ5:useSyncExternalStoreによる高度な改良(React 18以降)

上記のほとんどの実装は堅牢ですが、React 18ではuseSyncExternalStoreが導入されました。これは、localStorageのような外部データソースを購読するために特別に作られています。これにより、同時レンダリングのティアリング問題が本質的に解決されます。

究極のモダンな実装をどのように構築するか、その一端をご紹介します。

import { useSyncExternalStore, useCallback } from 'react';

function subscribe(callback: () => void) {
  window.addEventListener('storage', callback);
  return () => window.removeEventListener('storage', callback);
}

export function useModernLocalStorage<T>(key: string, initialValue: T): [T, (val: T | ((prev: T) => T)) => void] {
  const getSnapshot = () => window.localStorage.getItem(key);
  const getServerSnapshot = () => null; // Prevent SSR mismatch

  const store = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);

  const value = store !== null ? (JSON.parse(store) as T) : initialValue;

  const setValue = useCallback(
    (newValue: T | ((prev: T) => T)) => {
      const valueToStore = newValue instanceof Function ? newValue(value) : newValue;
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
      // Manually dispatch storage event for same-tab reactivity if needed,
      // though useSyncExternalStore handles cross-tab out of the box.
      window.dispatchEvent(new Event('storage'));
    },
    [key, value]
  );

  return [value, setValue];
}

このuseSyncExternalStoreアプローチは、現在のReactデータ同期の最高峰であり、同時実行機能とのシームレスな統合を提供し、サブスクリプション管理のための複雑なuseEffectチェーンの必要性を排除します。

結論

本番環境に対応したuseLocalStorageフックを構築することは、localStorage.setItemをラップするだけでは終わりません。ジェネリクスを厳密に型付けし、SSRハイドレーションフェーズを明示的に処理し、タブ間同期を実装することで、回復力があり、スケーラブルな状態管理ツールを作成できます。

標準のuseEffectパターンを選択するか、モダンなuseSyncExternalStore APIを活用するかにかかわらず、これらの複雑なエッジケースに対処することで、Next.jsアプリケーションの安定性とチームの開発者エクスペリエンスが劇的に向上します。

こちらもおすすめ

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement