JavaScript実践 #5 ローカルストレージと軽量な状態管理

#4 フォームの扱い で入力を受け取って送る場面を見ました。今回の記事は — データをどこに保存するか複数の箇所でどうやって共有するか。ライブラリなしで行けるところまでのパターンを整理します。

Web Storage — localStorage / sessionStorage #

ブラウザに小さなデータを保管する標準の道具。2 種類あります。

localStoragesessionStorage
寿命永続(自分で消すかユーザーが整理するまで)タブが閉じるまで
共有同一 origin の全タブ同じタブの中だけ
容量通常 5〜10MB通常 5〜10MB

基本的な使い方 #

localStorage 基本
localStorage.setItem('username', 'カーティス');
localStorage.getItem('username');        // 'カーティス'
localStorage.removeItem('username');
localStorage.clear();                     // 全削除

// キー数 / キー名
localStorage.length;
localStorage.key(0);

sessionStorage も全く同じ API。違いは寿命だけです。

常に文字列 #

最大の落とし穴 — 値は常に文字列として保存されます。

文字列変換
localStorage.setItem('count', 42);
localStorage.getItem('count');      // '42'  (文字列!)
typeof localStorage.getItem('count');   // 'string'

オブジェクトや配列を保存するには 中級 #7 JSON で見た JSON シリアライズを通す必要があります。

オブジェクトを保存
const user = { id: 'u1', name: 'カーティス' };

localStorage.setItem('user', JSON.stringify(user));

const loaded = JSON.parse(localStorage.getItem('user'));
console.log(loaded.name);   // 'カーティス'

安全なラッパー関数 #

毎回 JSON.parse/stringify を書くのは面倒です。小さなヘルパーがあるとすっきりします。

storage wrapper
const storage = {
  get(key, fallback = null) {
    try {
      const raw = localStorage.getItem(key);
      return raw === null ? fallback : JSON.parse(raw);
    } catch {
      return fallback;
    }
  },
  set(key, value) {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch (err) {
      console.warn('storage set 失敗:', err);
    }
  },
  remove(key) {
    localStorage.removeItem(key);
  },
};

// 使用例
storage.set('user', { id: 'u1', name: 'カーティス' });
const user = storage.get('user', { name: '匿名' });
storage.remove('user');

このくらいあれば JSON.parse の落とし穴と容量超過エラーを一箇所で処理できます。

落とし穴 #

1) 容量超過は throw #

容量の落とし穴
try {
  localStorage.setItem('big', new Array(10_000_000).join('x'));
} catch (err) {
  if (err.name === 'QuotaExceededError') {
    console.warn('ストレージが満杯');
  }
}

ストレージの上限(通常 5〜10MB)を超えると throw します。小さなデータならほぼ遭遇しませんが、キャッシュのように積み上がる場面では意識する必要があります。

2) プライベート / シークレットモード #

一部のブラウザはシークレットモードで localStorage をブロックしたり、容量を 0 にしたりします。setItem 自体が throw する可能性があります。ラッパーがそれを吸収してくれるとよいです。

3) JSON で表現できない値 #

中級 #7 で見た JSON の落とし穴がそのまま — undefined、関数、Symbol、BigInt、循環参照。保存する前に変換するか、シリアライズ可能な値だけを扱う必要があります。

4) 別のタブでの変更 — storage イベント #

同一 origin の別タブで storage が変更されるとイベントが発生します。

storage イベント
window.addEventListener('storage', (e) => {
  console.log(e.key, e.oldValue, e.newValue);
});

別タブで起きた変更だけが捕捉されます — 自分のタブの setItem はこのイベントを発生させません。マルチタブ同期の標準的な道具です。

より大きなデータ — IndexedDB #

localStorage は 5MB の制限があり、また同期 API なので大きなデータには不向きです。IndexedDB がブラウザの本格的なデータベース API です。

IndexedDB の特徴
- 容量: 通常 数百 MB 〜 GB
- API: 非同期
- 構造: キー-値、インデックス可
- トランザクションをサポート

IndexedDB の標準 API はやや複雑なので、直接書くとコードが長くなります。idb-keyval のような小さなラッパーがよく選ばれます。または、より豊富な機能が必要なら dexie。このシリーズでは深くは扱いませんが、「localStorage で足りないときの次のステップが IndexedDB」と覚えておいてください。

Cookie — 古い道具 #

クッキーもデータを保存しますが、自動的にすべてのリクエストに同伴して送信されるのがポイントです。

localStorageCookie
自動送信しないリクエストごとに自動
サイズ5〜10MB4KB
JavaScript アクセス常にHttpOnly クッキーは不可
セキュリティXSS 露出HttpOnly + Secure 推奨

認証トークンのような機密値は HttpOnly クッキーがほぼ正解です。JavaScript が読めないので XSS 攻撃でトークンを抜き取れません。

JavaScript が直接読めるクッキーもあります(document.cookie)が、API がぎこちないので普通はライブラリ(js-cookie)を使います。

軽量な状態管理 — ライブラリなしで #

複数の箇所で同じデータを見て変更するとき、どう同期させるか。

1) 単一オブジェクト + イベント発行 #

シンプルな store
function createStore(initialState) {
  let state = initialState;
  const listeners = new Set();

  return {
    get() {
      return state;
    },
    set(updater) {
      state = typeof updater === 'function' ? updater(state) : updater;
      listeners.forEach((fn) => fn(state));
    },
    subscribe(fn) {
      listeners.add(fn);
      return () => listeners.delete(fn);
    },
  };
}

// 使用例
const cart = createStore({ items: [] });

cart.subscribe((s) => {
  cartCount.textContent = s.items.length;
});

addBtn.addEventListener('click', () => {
  cart.set((prev) => ({ ...prev, items: [...prev.items, item] }));
});

上級 #1 クロージャ のパターンです。state 変数がクロージャで生き残り、listeners の集合が変更通知を送ります。Zustand のようなライブラリが内部的にこういう形をより精緻にしたものです。

2) localStorage と組み合わせ — 永続 store #

永続 store
function createPersistedStore(key, initialState) {
  const stored = storage.get(key, initialState);
  const store = createStore(stored);

  store.subscribe((state) => {
    storage.set(key, state);
  });

  return store;
}

const settings = createPersistedStore('settings', {
  theme: 'light',
  lang: 'ja',
});

state が変わるたびに自動で localStorage に保存。次回訪問でもそのまま復元されます。

デバウンス + 永続化 — 頻繁な変更を扱う #

state が頻繁に変わると、毎回 localStorage に書く負担が大きくなります。デバウンスでまとめるのが良いです。

デバウンスされた永続化
function createPersistedStore(key, initialState) {
  const stored = storage.get(key, initialState);
  const store = createStore(stored);

  const persist = debounce((state) => {
    storage.set(key, state);
  }, 200);

  store.subscribe(persist);

  return store;
}

200ms の間に変更が止まってから初めて実際に保存されます。フォーム入力のように頻繁に変わる場面に向いています。

別のタブと同期 #

同一 origin の複数タブが一つの store を共有するなら — storage イベントで受け取って store を更新。

マルチタブ同期
function createSyncedStore(key, initialState) {
  const store = createPersistedStore(key, initialState);

  window.addEventListener('storage', (e) => {
    if (e.key !== key || e.newValue === null) return;
    try {
      const newState = JSON.parse(e.newValue);
      store.set(newState);
    } catch {
      // 不正な JSON は無視
    }
  });

  return store;
}

タブ A でカートに項目を追加すると、タブ B でも即座に反映されます。小さいけれど印象的なユーザー体験です。

ブロードキャストチャネル — モダンなタブ間通信 #

storage イベントよりも明示的な道具。

BroadcastChannel
const channel = new BroadcastChannel('app');

// 送信
channel.postMessage({ type: 'cart-updated', items: [...] });

// 受信
channel.addEventListener('message', (e) => {
  console.log(e.data);
});

タブ間メッセージングのモダンな答えです。localStorage に依存せず直接通信します。

Window に直接乗せないでください #

アンチパターン
window.appState = { ... };   // ✗ どこからでもアクセス可能
window.handleClick = () => { ... };

グローバルオブジェクトに直接付けるのは古いパターンです。モジュールシステム(基礎 #7)が登場してからは — export した store を import して使うのが標準です。デバッグ用の一時露出を除いてグローバルは避けてください。

まとめ #

この記事で整理した内容:

  • localStorage(永続)vs sessionStorage(タブ限定)
  • 値は常に文字列 — JSON シリアライズ必須
  • ラッパー関数で parse/stringify とエラーを一箇所で処理
  • 容量超過 / シークレットモード / JSON の限界 / 別タブの変更などの落とし穴
  • 大きなデータは IndexedDB
  • トークンのような機密値は HttpOnly クッキー
  • 軽量 store — クロージャ + イベント listener
  • localStorage と組み合わせた永続 store、デバウンスで負担軽減
  • マルチタブ同期 — storage イベントまたは BroadcastChannel

次の記事(#6 小さなアプリのビルド)ではシリーズとトラックの最後に、これらすべての道具を組み合わせてライブラリなしで小さな Todo アプリを最初から最後まで作ります。

X