React LocalStorageの活用|データを永続化する簡単な方法

React でLocalStorageを使ったデータ永続化の実装方法を解説。基本的な使い方からカスタムHooksまで、実践的なサンプルコードとともに詳しく紹介します。

Learning Next 運営
50 分で読めます

「ページをリロードしたら、入力したデータが全部消えた...」

こんな経験、ありませんか?

「ユーザーの設定を保存したい」 「ログイン状態を維持したい」 「入力途中のフォームデータを残したい」

Reactでアプリを作っていると、こういう場面によく出会いますよね。 実は、LocalStorageを使えばデータを簡単に保存できるんです。

この記事では、ReactでLocalStorageを使う方法を分かりやすく解説します。 基本的な使い方から実用的なカスタムHookまで、実際に動くサンプルコードで学べますよ。

読み終わる頃には、あなたも「データが消えない便利なアプリ」が作れるようになります!

LocalStorageって何?

「LocalStorageって聞いたことあるけど、よく分からない...」 そんな方も大丈夫です。

簡単に言うとブラウザの保存箱

LocalStorageは、ブラウザが用意してくれるデータ保存スペースです。

// データを保存
localStorage.setItem('username', 'たろう');

// データを取得
const username = localStorage.getItem('username');
console.log(username); // "たろう"

// データを削除
localStorage.removeItem('username');

// 全部削除
localStorage.clear();

この4つの操作だけで基本は完了です!

  • setItem() → データを保存
  • getItem() → データを取得
  • removeItem() → データを削除
  • clear() → 全部削除

思っているより簡単ですよね。

他の保存方法との違いは?

「Cookieとか、SessionStorageとかもあるけど、何が違うの?」

const storageComparison = {
  localStorage: {
    容量: "5-10MB",
    保存期間: "手動で削除するまで永続",
    範囲: "同じサイト内",
    使いやすさ: "簡単"
  },
  
  sessionStorage: {
    容量: "5-10MB", 
    保存期間: "タブを閉じるまで",
    範囲: "同じタブ内",
    使いやすさ: "簡単"
  },
  
  cookie: {
    容量: "4KBまで",
    保存期間: "設定した期限まで",
    範囲: "同じサイト内",
    使いやすさ: "ちょっと面倒"
  }
};

LocalStorageの良いところ

  • 容量が大きい(5-10MB)
  • 使い方が簡単
  • データがずっと残る
  • 高速でアクセスできる

一般的なWebアプリなら、LocalStorageで十分です。

いつ使うのがおすすめ?

こんな時にLocalStorageが活躍します

  • ユーザー設定(テーマ、言語など)
  • ショッピングカートの内容
  • フォームの入力途中データ
  • ゲームのスコア
  • To-doリストの内容

使わない方がいいもの

  • パスワード → セキュリティ上危険
  • クレジットカード番号 → 絶対ダメ
  • 個人情報 → 慎重に扱う

安全で便利なデータだけ保存しましょう。

ReactでLocalStorageを使ってみよう

実際にReactでLocalStorageを使ってみましょう。 最初は簡単な例から始めます。

カウンターアプリで試してみる

まずは、数字をカウントするアプリを作ってみます。

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

const PersistentCounter = () => {
  // LocalStorageから初期値を取得
  const [count, setCount] = useState(() => {
    const savedCount = localStorage.getItem('counter');
    return savedCount ? parseInt(savedCount, 10) : 0;
  });

  // countが変わったらLocalStorageに保存
  useEffect(() => {
    localStorage.setItem('counter', count.toString());
  }, [count]);

  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);
  const reset = () => setCount(0);

  return (
    <div style={{ padding: '20px', textAlign: 'center' }}>
      <h2>永続化カウンター</h2>
      <p style={{ fontSize: '24px' }}>現在の値: {count}</p>
      <div>
        <button onClick={increment} style={{ margin: '5px', padding: '10px' }}>
          +1
        </button>
        <button onClick={decrement} style={{ margin: '5px', padding: '10px' }}>
          -1
        </button>
        <button onClick={reset} style={{ margin: '5px', padding: '10px' }}>
          リセット
        </button>
      </div>
      <p style={{ fontSize: '12px', color: '#666' }}>
        ページをリロードしても値が保持されます
      </p>
    </div>
  );
};

このコードのポイント

  1. useState(() => {...}) → 初期値をLocalStorageから取得
  2. useEffect(() => {...}, [count]) → countが変わったら保存
  3. parseInt(savedCount, 10) → 文字列を数字に変換

なぜこの順序なの?

LocalStorageは文字列しか保存できません。 数字を保存する時は、文字列に変換して保存します。 取得する時は、文字列を数字に戻します。

実際に試してみましょう

  1. +1ボタンを何回か押す
  2. ブラウザをリロード(F5キー)
  3. 数字がそのまま残っている!

これだけで、データが永続化されました。

オブジェクトを保存してみる

今度は、もう少し複雑なデータを保存してみましょう。

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

const UserProfile = () => {
  const [user, setUser] = useState(() => {
    const savedUser = localStorage.getItem('userProfile');
    return savedUser ? JSON.parse(savedUser) : {
      name: '',
      email: '',
      age: '',
      preferences: {
        theme: 'light',
        notifications: true
      }
    };
  });

  // ユーザー情報が変わったら保存
  useEffect(() => {
    localStorage.setItem('userProfile', JSON.stringify(user));
  }, [user]);

  const updateUser = (field, value) => {
    setUser(prev => ({
      ...prev,
      [field]: value
    }));
  };

  const updatePreferences = (field, value) => {
    setUser(prev => ({
      ...prev,
      preferences: {
        ...prev.preferences,
        [field]: value
      }
    }));
  };

  const clearProfile = () => {
    setUser({
      name: '',
      email: '',
      age: '',
      preferences: {
        theme: 'light',
        notifications: true
      }
    });
    localStorage.removeItem('userProfile');
  };

  return (
    <div style={{ padding: '20px', maxWidth: '400px', margin: '0 auto' }}>
      <h2>ユーザープロフィール</h2>
      
      <div style={{ marginBottom: '15px' }}>
        <label>
          名前:
          <input
            type="text"
            value={user.name}
            onChange={(e) => updateUser('name', e.target.value)}
            style={{ marginLeft: '10px', padding: '5px' }}
          />
        </label>
      </div>

      <div style={{ marginBottom: '15px' }}>
        <label>
          メールアドレス:
          <input
            type="email"
            value={user.email}
            onChange={(e) => updateUser('email', e.target.value)}
            style={{ marginLeft: '10px', padding: '5px' }}
          />
        </label>
      </div>

      <div style={{ marginBottom: '15px' }}>
        <label>
          年齢:
          <input
            type="number"
            value={user.age}
            onChange={(e) => updateUser('age', e.target.value)}
            style={{ marginLeft: '10px', padding: '5px' }}
          />
        </label>
      </div>

      <div style={{ marginBottom: '15px' }}>
        <label>
          テーマ:
          <select
            value={user.preferences.theme}
            onChange={(e) => updatePreferences('theme', e.target.value)}
            style={{ marginLeft: '10px', padding: '5px' }}
          >
            <option value="light">ライト</option>
            <option value="dark">ダーク</option>
          </select>
        </label>
      </div>

      <div style={{ marginBottom: '15px' }}>
        <label>
          <input
            type="checkbox"
            checked={user.preferences.notifications}
            onChange={(e) => updatePreferences('notifications', e.target.checked)}
            style={{ marginRight: '10px' }}
          />
          通知を受け取る
        </label>
      </div>

      <button onClick={clearProfile} style={{ padding: '10px', marginTop: '10px' }}>
        プロフィールをクリア
      </button>

      <div style={{ marginTop: '20px', padding: '10px', backgroundColor: '#f0f0f0' }}>
        <h3>保存されたデータ:</h3>
        <pre>{JSON.stringify(user, null, 2)}</pre>
      </div>
    </div>
  );
};

オブジェクト保存のポイント

  • JSON.stringify() → オブジェクトを文字列に変換
  • JSON.parse() → 文字列をオブジェクトに戻す
  • スプレッド演算子 ... → オブジェクトの一部だけ更新

どうやって動いているの?

// 保存の流れ
const user = { name: "太郎", age: 25 };
const jsonString = JSON.stringify(user); // '{"name":"太郎","age":25}'
localStorage.setItem('user', jsonString);

// 取得の流れ
const jsonString = localStorage.getItem('user'); // '{"name":"太郎","age":25}'
const user = JSON.parse(jsonString); // { name: "太郎", age: 25 }

JSONを使うことで、複雑なデータも簡単に保存できます。

カスタムHookで使いやすくしよう

「毎回useState とuseEffect を書くのは面倒...」

そんな時は、カスタムHookを作りましょう。 一度作れば、どこでも簡単に使えるようになります。

基本的なuseLocalStorageを作ろう

import { useState, useEffect } from 'react';

const useLocalStorage = (key, initialValue) => {
  // LocalStorageから値を取得する関数
  const getStoredValue = () => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  };

  // 状態を初期化
  const [storedValue, setStoredValue] = useState(getStoredValue);

  // 値を設定する関数
  const setValue = (value) => {
    try {
      // 関数が渡された場合は実行
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      
      // 状態を更新
      setStoredValue(valueToStore);
      
      // LocalStorageに保存
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(`Error setting localStorage key "${key}":`, error);
    }
  };

  // 値を削除する関数
  const removeValue = () => {
    try {
      window.localStorage.removeItem(key);
      setStoredValue(initialValue);
    } catch (error) {
      console.error(`Error removing localStorage key "${key}":`, error);
    }
  };

  return [storedValue, setValue, removeValue];
};

このカスタムHookの便利なところ

  • エラーハンドリングが組み込まれている
  • 削除機能もついている
  • useState と同じように使える

使い方はこんなに簡単

const TodoApp = () => {
  const [todos, setTodos, removeTodos] = useLocalStorage('todos', []);
  const [inputValue, setInputValue] = useState('');

  const addTodo = () => {
    if (inputValue.trim()) {
      setTodos(prev => [...prev, {
        id: Date.now(),
        text: inputValue,
        completed: false,
        createdAt: new Date().toISOString()
      }]);
      setInputValue('');
    }
  };

  const toggleTodo = (id) => {
    setTodos(prev => prev.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };

  const deleteTodo = (id) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  };

  return (
    <div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
      <h2>永続化Todoアプリ</h2>
      
      <div style={{ marginBottom: '20px' }}>
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder="新しいタスクを入力"
          style={{ padding: '10px', width: '300px' }}
          onKeyPress={(e) => e.key === 'Enter' && addTodo()}
        />
        <button onClick={addTodo} style={{ padding: '10px', marginLeft: '10px' }}>
          追加
        </button>
      </div>

      <div style={{ marginBottom: '20px' }}>
        <button onClick={removeTodos} style={{ padding: '10px', backgroundColor: '#dc3545', color: 'white' }}>
          全て削除
        </button>
      </div>

      <ul style={{ listStyle: 'none', padding: 0 }}>
        {todos.map(todo => (
          <li key={todo.id} style={{
            display: 'flex',
            alignItems: 'center',
            padding: '10px',
            marginBottom: '10px',
            backgroundColor: todo.completed ? '#f0f0f0' : 'white',
            border: '1px solid #ddd',
            borderRadius: '4px'
          }}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
              style={{ marginRight: '10px' }}
            />
            <span style={{
              textDecoration: todo.completed ? 'line-through' : 'none',
              flex: 1
            }}>
              {todo.text}
            </span>
            <button
              onClick={() => deleteTodo(todo.id)}
              style={{
                padding: '5px 10px',
                backgroundColor: '#dc3545',
                color: 'white',
                border: 'none',
                borderRadius: '3px',
                cursor: 'pointer'
              }}
            >
              削除
            </button>
          </li>
        ))}
      </ul>

      {todos.length === 0 && (
        <p style={{ textAlign: 'center', color: '#666' }}>
          タスクがありません
        </p>
      )}
    </div>
  );
};

カスタムHookを使うメリット

  • コードがスッキリする
  • 再利用できる
  • エラー処理を気にしなくていい
  • 削除機能も簡単に使える

たった3行でLocalStorage機能が使えるようになりました!

もっと高機能なHookを作ってみる

「有効期限をつけたい」「他のタブと同期したい」

そんな高度な要求にも対応できるHookを作ってみましょう。

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

const useAdvancedLocalStorage = (key, initialValue, options = {}) => {
  const {
    serializer = JSON,
    syncAcrossTabs = true,
    expiration = null // ミリ秒単位での有効期限
  } = options;

  // シリアライザーのデフォルト実装
  const { stringify, parse } = serializer;

  // 有効期限付きのデータ構造
  const createStorageValue = (value) => ({
    value,
    timestamp: Date.now(),
    expiration: expiration ? Date.now() + expiration : null
  });

  // LocalStorageから値を取得
  const getStoredValue = useCallback(() => {
    try {
      const item = window.localStorage.getItem(key);
      if (!item) return initialValue;

      const parsed = parse(item);
      
      // 有効期限チェック
      if (parsed.expiration && Date.now() > parsed.expiration) {
        window.localStorage.removeItem(key);
        return initialValue;
      }

      return parsed.value;
    } catch (error) {
      console.error(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  }, [key, initialValue, parse]);

  const [storedValue, setStoredValue] = useState(getStoredValue);

  // 値を設定
  const setValue = useCallback((value) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      const storageData = createStorageValue(valueToStore);
      
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, stringify(storageData));
      
      // カスタムイベントを発火(他のタブとの同期用)
      if (syncAcrossTabs) {
        window.dispatchEvent(new CustomEvent('localStorageChange', {
          detail: { key, value: valueToStore }
        }));
      }
    } catch (error) {
      console.error(`Error setting localStorage key "${key}":`, error);
    }
  }, [key, storedValue, stringify, syncAcrossTabs]);

  // 値を削除
  const removeValue = useCallback(() => {
    try {
      window.localStorage.removeItem(key);
      setStoredValue(initialValue);
    } catch (error) {
      console.error(`Error removing localStorage key "${key}":`, error);
    }
  }, [key, initialValue]);

  // 他のタブとの同期
  useEffect(() => {
    if (!syncAcrossTabs) return;

    const handleStorageChange = (e) => {
      if (e.key === key) {
        setStoredValue(getStoredValue());
      }
    };

    const handleCustomStorageChange = (e) => {
      if (e.detail.key === key) {
        setStoredValue(e.detail.value);
      }
    };

    window.addEventListener('storage', handleStorageChange);
    window.addEventListener('localStorageChange', handleCustomStorageChange);

    return () => {
      window.removeEventListener('storage', handleStorageChange);
      window.removeEventListener('localStorageChange', handleCustomStorageChange);
    };
  }, [key, getStoredValue, syncAcrossTabs]);

  return [storedValue, setValue, removeValue];
};

この高機能Hookでできること

  • 有効期限設定 → 一定時間後にデータを自動削除
  • タブ間同期 → 複数のタブでデータを共有
  • エラー処理 → 問題が起きても安全に動作

使用例:設定画面

const SettingsApp = () => {
  const [theme, setTheme] = useAdvancedLocalStorage('theme', 'light');
  const [language, setLanguage] = useAdvancedLocalStorage('language', 'ja');
  
  // 1時間で期限切れの一時データ
  const [tempData, setTempData] = useAdvancedLocalStorage('tempData', '', {
    expiration: 60 * 60 * 1000 // 1時間
  });

  return (
    <div style={{ padding: '20px' }}>
      <h2>設定画面</h2>

      <div style={{ marginBottom: '20px' }}>
        <h3>テーマ設定</h3>
        <label>
          <input
            type="radio"
            value="light"
            checked={theme === 'light'}
            onChange={(e) => setTheme(e.target.value)}
          />
          ライトテーマ
        </label>
        <label style={{ marginLeft: '20px' }}>
          <input
            type="radio"
            value="dark"
            checked={theme === 'dark'}
            onChange={(e) => setTheme(e.target.value)}
          />
          ダークテーマ
        </label>
      </div>

      <div style={{ marginBottom: '20px' }}>
        <h3>一時データ(1時間で期限切れ)</h3>
        <input
          type="text"
          value={tempData}
          onChange={(e) => setTempData(e.target.value)}
          placeholder="一時的なメモ"
          style={{ width: '100%', padding: '10px' }}
        />
      </div>
    </div>
  );
};

カスタムHookの組み合わせ技

基本版と高機能版を使い分けることで、シンプルさと機能性の両立ができます。

実用的なアプリを作ってみよう

理論だけじゃつまらないですよね。 実際に使えるショッピングカートアプリを作ってみましょう。

ショッピングカート機能

import React, { useState } from 'react';

const useShoppingCart = () => {
  const [cart, setCart] = useLocalStorage('shoppingCart', []);
  
  const addItem = (product) => {
    setCart(prev => {
      const existingItem = prev.find(item => item.id === product.id);
      
      if (existingItem) {
        return prev.map(item =>
          item.id === product.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        );
      }
      
      return [...prev, { ...product, quantity: 1 }];
    });
  };

  const removeItem = (productId) => {
    setCart(prev => prev.filter(item => item.id !== productId));
  };

  const updateQuantity = (productId, quantity) => {
    if (quantity <= 0) {
      removeItem(productId);
      return;
    }
    
    setCart(prev => prev.map(item =>
      item.id === productId
        ? { ...item, quantity }
        : item
    ));
  };

  const clearCart = () => {
    setCart([]);
  };

  const getTotalPrice = () => {
    return cart.reduce((total, item) => total + (item.price * item.quantity), 0);
  };

  const getTotalItems = () => {
    return cart.reduce((total, item) => total + item.quantity, 0);
  };

  return {
    cart,
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
    getTotalPrice,
    getTotalItems
  };
};

const ShoppingApp = () => {
  const {
    cart,
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
    getTotalPrice,
    getTotalItems
  } = useShoppingCart();

  // サンプル商品データ
  const products = [
    { id: 1, name: 'MacBook Pro', price: 200000, image: '💻' },
    { id: 2, name: 'iPhone', price: 100000, image: '📱' },
    { id: 3, name: 'AirPods', price: 30000, image: '🎧' },
    { id: 4, name: 'iPad', price: 60000, image: '📱' },
    { id: 5, name: 'Apple Watch', price: 50000, image: '⌚' }
  ];

  return (
    <div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
      <h1>オンラインストア</h1>
      
      <div style={{ display: 'flex', gap: '20px' }}>
        {/* 商品一覧 */}
        <div style={{ flex: 2 }}>
          <h2>商品一覧</h2>
          <div style={{ 
            display: 'grid', 
            gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', 
            gap: '20px' 
          }}>
            {products.map(product => (
              <div key={product.id} style={{
                border: '1px solid #ddd',
                borderRadius: '8px',
                padding: '15px',
                textAlign: 'center'
              }}>
                <div style={{ fontSize: '48px', marginBottom: '10px' }}>
                  {product.image}
                </div>
                <h3>{product.name}</h3>
                <p style={{ fontSize: '18px', fontWeight: 'bold' }}>
                  ¥{product.price.toLocaleString()}
                </p>
                <button
                  onClick={() => addItem(product)}
                  style={{
                    padding: '10px 20px',
                    backgroundColor: '#007bff',
                    color: 'white',
                    border: 'none',
                    borderRadius: '4px',
                    cursor: 'pointer'
                  }}
                >
                  カートに追加
                </button>
              </div>
            ))}
          </div>
        </div>

        {/* ショッピングカート */}
        <div style={{ flex: 1 }}>
          <div style={{
            border: '1px solid #ddd',
            borderRadius: '8px',
            padding: '20px',
            position: 'sticky',
            top: '20px'
          }}>
            <h2>ショッピングカート ({getTotalItems()})</h2>
            
            {cart.length === 0 ? (
              <p style={{ textAlign: 'center', color: '#666' }}>
                カートは空です
              </p>
            ) : (
              <>
                <div style={{ maxHeight: '400px', overflowY: 'auto' }}>
                  {cart.map(item => (
                    <div key={item.id} style={{
                      display: 'flex',
                      alignItems: 'center',
                      padding: '10px',
                      borderBottom: '1px solid #eee',
                      marginBottom: '10px'
                    }}>
                      <span style={{ fontSize: '24px', marginRight: '10px' }}>
                        {item.image}
                      </span>
                      <div style={{ flex: 1 }}>
                        <h4 style={{ margin: 0 }}>{item.name}</h4>
                        <p style={{ margin: 0, color: '#666' }}>
                          ¥{item.price.toLocaleString()}
                        </p>
                      </div>
                      <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
                        <button
                          onClick={() => updateQuantity(item.id, item.quantity - 1)}
                          style={{
                            width: '30px',
                            height: '30px',
                            border: '1px solid #ddd',
                            background: 'white',
                            cursor: 'pointer'
                          }}
                        >
                          -
                        </button>
                        <span style={{ minWidth: '20px', textAlign: 'center' }}>
                          {item.quantity}
                        </span>
                        <button
                          onClick={() => updateQuantity(item.id, item.quantity + 1)}
                          style={{
                            width: '30px',
                            height: '30px',
                            border: '1px solid #ddd',
                            background: 'white',
                            cursor: 'pointer'
                          }}
                        >
                          +
                        </button>
                        <button
                          onClick={() => removeItem(item.id)}
                          style={{
                            padding: '5px 10px',
                            backgroundColor: '#dc3545',
                            color: 'white',
                            border: 'none',
                            borderRadius: '3px',
                            cursor: 'pointer'
                          }}
                        >
                          削除
                        </button>
                      </div>
                    </div>
                  ))}
                </div>
                
                <div style={{ marginTop: '20px', paddingTop: '20px', borderTop: '2px solid #ddd' }}>
                  <div style={{ 
                    display: 'flex', 
                    justifyContent: 'space-between', 
                    fontSize: '18px', 
                    fontWeight: 'bold' 
                  }}>
                    <span>合計:</span>
                    <span>¥{getTotalPrice().toLocaleString()}</span>
                  </div>
                  
                  <button
                    style={{
                      width: '100%',
                      padding: '15px',
                      backgroundColor: '#28a745',
                      color: 'white',
                      border: 'none',
                      borderRadius: '4px',
                      fontSize: '16px',
                      cursor: 'pointer',
                      marginTop: '10px'
                    }}
                    onClick={() => alert('購入機能はデモです')}
                  >
                    購入する
                  </button>
                  
                  <button
                    onClick={clearCart}
                    style={{
                      width: '100%',
                      padding: '10px',
                      backgroundColor: '#6c757d',
                      color: 'white',
                      border: 'none',
                      borderRadius: '4px',
                      cursor: 'pointer',
                      marginTop: '10px'
                    }}
                  >
                    カートをクリア
                  </button>
                </div>
              </>
            )}
          </div>
        </div>
      </div>
    </div>
  );
};

このショッピングカートの便利機能

  • 商品追加 → カートに商品を追加
  • 数量変更 → +/-ボタンで数量調整
  • 商品削除 → 不要な商品を削除
  • 合計計算 → 金額と個数を自動計算
  • データ永続化 → ページリロードしても内容保持

実際に試してみてください

  1. 商品をカートに追加
  2. 数量を変更
  3. ブラウザをリロード
  4. カートの内容がそのまま残っている!

これで実用的なECサイトの基本機能ができました。

このアプリから学べること

データの構造化

const cartItem = {
  id: 1,           // 商品ID
  name: 'iPhone',  // 商品名
  price: 100000,   // 価格
  image: '📱',     // 画像(絵文字)
  quantity: 2      // 数量
};

データを構造化することで、管理しやすくなります。

配列操作のパターン

  • find() → 特定の商品を探す
  • map() → 数量を更新
  • filter() → 商品を削除
  • reduce() → 合計を計算

これらのパターンは、他のアプリでも応用できます。

エラー対策とセキュリティ

LocalStorageを使う時に気をつけるべきことを学びましょう。

エラーが起きても大丈夫にしよう

「LocalStorageが使えない環境があるの?」

実は、以下の場合にエラーが起きることがあります。

  • プライベートブラウジングモード
  • 容量不足
  • LocalStorage無効設定
  • 古いブラウザ
const useRobustLocalStorage = (key, initialValue) => {
  const [value, setValue] = useState(() => {
    // LocalStorageが使えるかチェック
    if (typeof window === 'undefined') {
      return initialValue;
    }

    try {
      // LocalStorageのサポート確認
      if (!window.localStorage) {
        console.warn('LocalStorage is not supported');
        return initialValue;
      }

      // 実際に書き込みテスト
      const testKey = '__localStorage_test__';
      window.localStorage.setItem(testKey, 'test');
      window.localStorage.removeItem(testKey);

      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error('LocalStorage error:', error);
      
      // 容量不足の場合
      if (error.name === 'QuotaExceededError') {
        console.error('LocalStorage quota exceeded');
        clearOldData(); // 古いデータを削除
      }
      
      return initialValue;
    }
  });

  const setStoredValue = (newValue) => {
    try {
      setValue(newValue);
      
      if (typeof window !== 'undefined' && window.localStorage) {
        window.localStorage.setItem(key, JSON.stringify(newValue));
      }
    } catch (error) {
      console.error('Error saving to localStorage:', error);
      
      if (error.name === 'QuotaExceededError') {
        handleQuotaExceeded(); // 容量不足の対処
      }
    }
  };

  const clearOldData = () => {
    try {
      const keysToRemove = [];
      
      for (let i = 0; i < localStorage.length; i++) {
        const key = localStorage.key(i);
        if (key.startsWith('temp_') || key.startsWith('old_')) {
          keysToRemove.push(key);
        }
      }
      
      keysToRemove.forEach(key => localStorage.removeItem(key));
    } catch (error) {
      console.error('Error clearing old data:', error);
    }
  };

  return [value, setStoredValue];
};

エラー対策のポイント

  • 事前チェック → LocalStorageが使えるか確認
  • try-catch → エラーをキャッチして対処
  • 容量管理 → 古いデータを自動削除
  • フォールバック → 使えない場合は通常のstateで動作

セキュリティに気をつけよう

LocalStorageは便利ですが、セキュリティリスクもあります。

保存してはいけないもの

const securityGuidelines = {
  // ❌ 絶対に保存しちゃダメ
  avoid: [
    'パスワード',
    'クレジットカード番号',
    'セッショントークン',
    'APIキー',
    '個人情報(住所、電話番号など)'
  ],

  // ✅ 保存してもOK
  safe: [
    'ユーザー設定(テーマ、言語)',
    'ショッピングカート',
    'フォームの下書き',
    'ゲームスコア',
    'UI状態'
  ]
};

なぜ危険なの?

  • LocalStorageはJavaScriptから誰でもアクセス可能
  • XSS攻撃で情報が盗まれる可能性
  • 平文で保存されるため暗号化されない

安全に使うコツ

// 機密データは暗号化(簡単な例)
const encryptData = (data) => {
  return btoa(JSON.stringify(data)); // Base64エンコード
};

const decryptData = (encryptedData) => {
  return JSON.parse(atob(encryptedData)); // Base64デコード
};

// 使用例
const saveUserPreferences = (preferences) => {
  const encrypted = encryptData(preferences);
  localStorage.setItem('userPrefs', encrypted);
};

ただし、これは簡単な難読化です。 本当に重要なデータは、サーバーで管理しましょう。

パフォーマンスも考えよう

LocalStorageは便利ですが、使いすぎると重くなることがあります。

最適化のコツ

// デバウンス機能付きの保存
const useOptimizedLocalStorage = (key, initialValue) => {
  const [value, setValue] = useState(initialValue);
  const [debouncedValue, setDebouncedValue] = useState(initialValue);

  // デバウンス処理(300ms待ってから保存)
  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, 300);

    return () => clearTimeout(timer);
  }, [value]);

  // LocalStorageへの保存
  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(debouncedValue));
    } catch (error) {
      console.error('LocalStorage save error:', error);
    }
  }, [key, debouncedValue]);

  return [value, setValue];
};

デバウンスって何?

連続した操作をまとめて、最後の操作だけを実行する仕組みです。

// デバウンスなし → 毎回保存(重い)
onChange: (e) => saveToLocalStorage(e.target.value)

// デバウンスあり → 300ms後に一回だけ保存(軽い)
onChange: (e) => setValue(e.target.value) // 300ms後に自動保存

入力のたびに保存せず、少し待ってから保存することでパフォーマンスが向上します。

まとめ:LocalStorageで快適アプリを作ろう

LocalStorageを使えば、ユーザーにとってもっと便利なアプリが作れます。

今回学んだポイント

  • 基本操作 → setItem, getItem, removeItem, clear
  • React統合 → useState, useEffect との組み合わせ
  • カスタムHook → 再利用可能な便利機能
  • 実践応用 → ショッピングカート、ユーザー設定
  • エラー対策 → 安全で堅牢な実装
  • セキュリティ → 適切なデータの選別

すぐに使えるテクニック

  1. 基本パターンをコピペして使う
  2. カスタムHookで作業効率アップ
  3. エラーハンドリングで安定性向上
  4. セキュリティを意識したデータ選択

LocalStorageが活躍する場面

  • ユーザー設定の保存
  • ショッピングカート
  • フォームの下書き保存
  • ゲームスコア
  • UI状態の記憶
  • 一時的なデータキャッシュ

最後に大切なこと

LocalStorageはユーザー体験を向上させるための道具です。 適切に使えば、ユーザーが「このアプリ使いやすい!」と感じるはずです。

ぜひこの記事を参考にして、実際のプロジェクトでLocalStorageを活用してみてください。 きっと、もっと便利で快適なWebアプリケーションが作れるようになりますよ!

関連記事