JavaScriptのLocalStorage・SessionStorage完全ガイド|データ永続化の実践的なパターン


はじめに

前回の記事ではJavaScriptの非同期処理として Promiseasync/awaitfetch を使ったAPI通信の実装方法を解説しました。APIから取得したデータを「次回アクセス時にも使いたい」「ページをまたいで共有したい」という場面があります。今回はブラウザにデータを保存する仕組みである localStoragesessionStorage の使い方と、実践的なデータ永続化のパターンを解説します。


1. ブラウザのストレージを理解する

1.1 データを保存する方法の選択肢

ブラウザでデータを保存する方法はいくつかあります。

種類保存期間容量目安サーバーへの送信主な用途
localStorage明示的に削除するまで永続約5MBしないユーザー設定・テーマ・認証トークン
sessionStorageタブを閉じるまで約5MBしないフォームの一時保存・ウィザードの途中経過
Cookie有効期限まで約4KBするセッション管理・トラッキング
IndexedDB明示的に削除するまで永続数GBしない大量データ・ファイル・オフラインアプリ

💡 localStoragesessionStorage はAPIが同じで使い方も同じです: 保存期間だけが異なります。まず localStorage の使い方を覚えれば sessionStorage も同じ書き方で使えます。

1.2 localStorageとsessionStorageの違い

// localStorage:ブラウザを閉じても残る
localStorage.setItem( 'theme', 'dark' );

// sessionStorage:タブを閉じると消える
sessionStorage.setItem( 'currentStep', '2' );
比較項目localStoragesessionStorage
保存期間手動で削除するまで永続タブ・ブラウザを閉じると消える
タブをまたぐ共有同じオリジンなら共有される共有されない(タブごとに独立)
ページリロードデータは残るデータは残る
向いている用途ユーザー設定・認証情報フォームの一時保存・ウィザード

2. 基本的なAPIの使い方

2.1 データの保存・取得・削除

// ===== 保存(setItem) =====
localStorage.setItem( 'username', '山田太郎' );
localStorage.setItem( 'theme',    'dark' );
localStorage.setItem( 'language', 'ja' );

// ===== 取得(getItem) =====
const username = localStorage.getItem( 'username' ); // '山田太郎'
const missing  = localStorage.getItem( 'nothing' );  // null(存在しない場合)

// ===== 削除(removeItem) =====
localStorage.removeItem( 'username' );

// ===== すべて削除(clear) =====
localStorage.clear(); // ⚠️ そのオリジンのすべてのデータが消える

// ===== キーの数を取得(length) =====
console.log( localStorage.length ); // 保存されているアイテム数

// ===== キー名を取得(key) =====
const firstKey = localStorage.key( 0 ); // インデックスからキー名を取得

2.2 文字列しか保存できない

localStorage文字列しか保存できません。数値・真偽値・オブジェクト・配列を保存するには JSON.stringify() でシリアライズする必要があります。

// ❌ オブジェクトをそのまま保存するとtoStringされる
localStorage.setItem( 'user', { name: '山田太郎', age: 30 } );
localStorage.getItem( 'user' ); // '[object Object]' になる

// ✅ JSON.stringifyで文字列に変換してから保存する
localStorage.setItem( 'user', JSON.stringify( { name: '山田太郎', age: 30 } ) );

// ✅ 取得時はJSON.parseで元に戻す
const user = JSON.parse( localStorage.getItem( 'user' ) );
console.log( user.name ); // '山田太郎'

// ===== 各型のパターン =====
// 数値
localStorage.setItem( 'count', JSON.stringify( 42 ) );
const count = JSON.parse( localStorage.getItem( 'count' ) ); // 42(数値型)

// 真偽値
localStorage.setItem( 'isLoggedIn', JSON.stringify( true ) );
const isLoggedIn = JSON.parse( localStorage.getItem( 'isLoggedIn' ) ); // true(真偽値型)

// 配列
localStorage.setItem( 'tags', JSON.stringify( ['CSS', 'JavaScript', 'WordPress'] ) );
const tags = JSON.parse( localStorage.getItem( 'tags' ) ); // 配列

⚠️ JSON.parse は存在しないキーの null を渡すとエラーになります: localStorage.getItem() は存在しないキーに対して null を返します。JSON.parse( null )null を返しますが、JSON.parse( undefined ) はエラーになります。取得時は必ず null チェックを行いましょう。


3. 安全に使うためのラッパーを作る

3.1 型変換とエラーをまとめて処理する

// 安全なlocalStorageラッパー
const storage = {

    // データを保存する
    set( key, value ) {
        try {
            localStorage.setItem( key, JSON.stringify( value ) );
            return true;
        } catch ( error ) {
            // プライベートモードなどでQuotaExceededErrorが発生することがある
            console.error( `storage.set('${key}') failed:`, error );
            return false;
        }
    },

    // データを取得する
    get( key, defaultValue = null ) {
        try {
            const item = localStorage.getItem( key );
            if ( item === null ) return defaultValue;
            return JSON.parse( item );
        } catch ( error ) {
            console.error( `storage.get('${key}') failed:`, error );
            return defaultValue;
        }
    },

    // データを削除する
    remove( key ) {
        try {
            localStorage.removeItem( key );
            return true;
        } catch ( error ) {
            console.error( `storage.remove('${key}') failed:`, error );
            return false;
        }
    },

    // キーが存在するか確認する
    has( key ) {
        return localStorage.getItem( key ) !== null;
    },

    // すべて削除する(対象プレフィックスを指定できる)
    clear( prefix = null ) {
        if ( prefix === null ) {
            localStorage.clear();
            return;
        }
        // プレフィックスが一致するキーだけ削除する
        Object.keys( localStorage )
              .filter( key => key.startsWith( prefix ) )
              .forEach( key => localStorage.removeItem( key ) );
    },

    // すべてのキーを取得する
    keys( prefix = null ) {
        const keys = Object.keys( localStorage );
        return prefix ? keys.filter( k => k.startsWith( prefix ) ) : keys;
    },
};

// 使い方
storage.set( 'user', { name: '山田太郎', age: 30 } );
const user = storage.get( 'user', {} ); // デフォルト値を指定できる
storage.remove( 'user' );

3.2 有効期限付きのストレージ

localStorage には有効期限の機能がありませんが、データと一緒に有効期限を保存することで実現できます。

// 有効期限付きストレージ
const timedStorage = {

    // 有効期限付きで保存する
    set( key, value, ttlMs ) {
        const item = {
            value,
            expiresAt: ttlMs ? Date.now() + ttlMs : null,
        };
        return storage.set( key, item );
    },

    // 有効期限を確認してから取得する
    get( key, defaultValue = null ) {
        const item = storage.get( key );

        if ( item === null ) return defaultValue;

        // 有効期限チェック
        if ( item.expiresAt !== null && Date.now() > item.expiresAt ) {
            storage.remove( key ); // 期限切れなら削除する
            return defaultValue;
        }

        return item.value;
    },

    remove: ( key ) => storage.remove( key ),
    has:    ( key ) => timedStorage.get( key ) !== null,
};

// 使い方
// 1時間後に期限切れになるデータを保存する
timedStorage.set( 'api_cache', { data: [...] }, 60 * 60 * 1000 );

// 永続(期限なし)
timedStorage.set( 'user_preference', { theme: 'dark' } );

4. 実践的な使用パターン

4.1 ダークモードの設定を保存する

第三十九弾で解説したCSS変数とダークモードにストレージを組み合わせます。

const ThemeManager = {

    STORAGE_KEY: 'user-theme',

    // 初期化(ページ読み込み時に呼ぶ)
    init() {
        const saved = storage.get( this.STORAGE_KEY );

        if ( saved ) {
            this.apply( saved );
        } else {
            // システムの設定に従う
            const prefersDark = window.matchMedia(
                '(prefers-color-scheme: dark)'
            ).matches;
            this.apply( prefersDark ? 'dark' : 'light' );
        }

        // システム設定の変化を監視する
        window.matchMedia( '(prefers-color-scheme: dark)' )
              .addEventListener( 'change', e => {
                  if ( !storage.has( this.STORAGE_KEY ) ) {
                      this.apply( e.matches ? 'dark' : 'light' );
                  }
              } );
    },

    // テーマを適用する
    apply( theme ) {
        document.documentElement.setAttribute( 'data-theme', theme );
    },

    // テーマを切り替えて保存する
    toggle() {
        const current = document.documentElement.getAttribute( 'data-theme' );
        const next    = current === 'dark' ? 'light' : 'dark';
        this.apply( next );
        storage.set( this.STORAGE_KEY, next );
        return next;
    },

    // テーマをリセットする(システム設定に戻す)
    reset() {
        storage.remove( this.STORAGE_KEY );
        const prefersDark = window.matchMedia(
            '(prefers-color-scheme: dark)'
        ).matches;
        this.apply( prefersDark ? 'dark' : 'light' );
    },
};

// ページ読み込み時に即座に適用する(FOUCを防ぐ)
ThemeManager.init();

// 切り替えボタン
document.querySelector( '#theme-toggle' )
        ?.addEventListener( 'click', () => ThemeManager.toggle() );

4.2 フォームの入力内容を自動保存する

長い入力フォームでブラウザが誤って閉じられても入力内容を復元できるようにします。

class FormAutoSave {

    constructor( formEl, options = {} ) {
        this.form      = formEl;
        this.storageKey = options.storageKey || `form_${formEl.id}_draft`;
        this.debounceMs = options.debounceMs || 1000; // 1秒後に保存
        this.timer      = null;

        this.restore();   // ページ読み込み時に復元する
        this.watch();     // 入力を監視する
    }

    // フォームの値をオブジェクトとして取得する
    getData() {
        const data  = {};
        const fields = this.form.querySelectorAll(
            'input:not([type="password"]):not([type="file"]), textarea, select'
        );
        fields.forEach( field => {
            if ( field.name ) {
                data[field.name] = field.type === 'checkbox'
                    ? field.checked
                    : field.value;
            }
        } );
        return data;
    }

    // フォームにデータを書き戻す
    setData( data ) {
        Object.entries( data ).forEach( ( [ name, value ] ) => {
            const field = this.form.querySelector( `[name="${name}"]` );
            if ( !field ) return;

            if ( field.type === 'checkbox' ) {
                field.checked = value;
            } else {
                field.value = value;
            }
        } );
    }

    // 保存する(デバウンス付き)
    save() {
        clearTimeout( this.timer );
        this.timer = setTimeout( () => {
            storage.set( this.storageKey, this.getData() );
            this.showSavedIndicator();
        }, this.debounceMs );
    }

    // 復元する
    restore() {
        const saved = storage.get( this.storageKey );
        if ( !saved ) return;

        this.setData( saved );
        this.showRestoredMessage();
    }

    // 入力を監視する
    watch() {
        this.form.addEventListener( 'input',  () => this.save() );
        this.form.addEventListener( 'change', () => this.save() );
        this.form.addEventListener( 'submit', () => this.clear() );
    }

    // 保存済みデータを削除する(送信完了後)
    clear() {
        storage.remove( this.storageKey );
    }

    showSavedIndicator() {
        const el = this.form.querySelector( '.autosave-indicator' );
        if ( el ) {
            el.textContent = '下書きを自動保存しました';
            el.classList.add( 'is-visible' );
            setTimeout( () => el.classList.remove( 'is-visible' ), 2000 );
        }
    }

    showRestoredMessage() {
        const el = this.form.querySelector( '.autosave-indicator' );
        if ( el ) {
            el.textContent = '前回の入力内容を復元しました';
            el.classList.add( 'is-visible' );
        }
    }
}

// 使い方
const form = document.querySelector( '#contact-form' );
if ( form ) {
    new FormAutoSave( form, {
        storageKey:  'contact_draft',
        debounceMs:  500,
    } );
}

4.3 APIレスポンスをキャッシュする

// シンプルなAPIキャッシュ
const ApiCache = {

    PREFIX: 'api_cache_',
    DEFAULT_TTL: 5 * 60 * 1000, // 5分

    async get( url, fetchFn, ttlMs = this.DEFAULT_TTL ) {
        const cacheKey = this.PREFIX + btoa( url ); // URLをBase64でキーにする

        // キャッシュを確認する
        const cached = timedStorage.get( cacheKey );
        if ( cached !== null ) {
            console.log( `[Cache HIT] ${url}` );
            return cached;
        }

        // キャッシュにない場合はfetchしてキャッシュに保存する
        console.log( `[Cache MISS] ${url}` );
        const data = await fetchFn();
        timedStorage.set( cacheKey, data, ttlMs );
        return data;
    },

    // 特定URLのキャッシュを削除する
    invalidate( url ) {
        const cacheKey = this.PREFIX + btoa( url );
        storage.remove( cacheKey );
    },

    // すべてのAPIキャッシュを削除する
    clearAll() {
        storage.clear( this.PREFIX );
    },
};

// 使い方
const posts = await ApiCache.get(
    'https://api.example.com/posts',
    () => api.get( '/posts' ),
    10 * 60 * 1000  // 10分キャッシュ
);

4.4 直近の検索履歴を保存する

class SearchHistory {

    constructor( storageKey = 'search_history', maxItems = 10 ) {
        this.key      = storageKey;
        this.maxItems = maxItems;
    }

    // 履歴を取得する
    getAll() {
        return storage.get( this.key, [] );
    }

    // 検索キーワードを追加する
    add( keyword ) {
        const trimmed = keyword.trim();
        if ( !trimmed ) return;

        let history = this.getAll();

        // 同じキーワードがあれば先頭に移動する
        history = history.filter( item => item !== trimmed );
        history.unshift( trimmed );

        // 最大件数を超えたら末尾を削除する
        history = history.slice( 0, this.maxItems );

        storage.set( this.key, history );
    }

    // 特定のキーワードを削除する
    remove( keyword ) {
        const history = this.getAll().filter( item => item !== keyword );
        storage.set( this.key, history );
    }

    // 履歴をすべて削除する
    clear() {
        storage.remove( this.key );
    }

    // 最近の検索キーワードを候補として表示する
    render( containerEl ) {
        const history = this.getAll();
        if ( history.length === 0 ) {
            containerEl.innerHTML = '';
            return;
        }

        containerEl.innerHTML = `
            <ul class="search-history">
                ${history.map( keyword => `
                    <li class="search-history__item">
                        <button class="history-btn" data-keyword="${keyword}">
                            🕐 ${keyword}
                        </button>
                        <button class="history-delete" data-keyword="${keyword}"
                                aria-label="${keyword}を削除">×</button>
                    </li>
                `).join( '' )}
            </ul>
            <button class="history-clear-all">履歴をすべて削除</button>
        `;
    }
}

5. プレフィックスでキーを整理する

5.1 名前空間の衝突を防ぐ

複数の機能が localStorage を使う場合はプレフィックスでキーを管理します。

// ❌ プレフィックスなし(名前が衝突しやすい)
localStorage.setItem( 'theme',    'dark' );
localStorage.setItem( 'language', 'ja' );
localStorage.setItem( 'cache',    '...' );

// ✅ プレフィックスで機能ごとに整理する
localStorage.setItem( 'pref_theme',       'dark' );
localStorage.setItem( 'pref_language',    'ja' );
localStorage.setItem( 'cache_posts',      '...' );
localStorage.setItem( 'cache_user',       '...' );
localStorage.setItem( 'form_contact',     '...' );
// ストレージの名前空間を作るファクトリ
function createNamespacedStorage( namespace, baseStorage = localStorage ) {
    const prefix = `${namespace}_`;

    return {
        set:    ( key, value ) => storage.set( prefix + key, value ),
        get:    ( key, def )   => storage.get( prefix + key, def ),
        remove: ( key )        => storage.remove( prefix + key ),
        has:    ( key )        => storage.has( prefix + key ),
        clear:  ()             => storage.clear( prefix ),
        keys:   ()             => storage.keys( prefix )
                                        .map( k => k.slice( prefix.length ) ),
    };
}

// 使い方
const preferenceStorage = createNamespacedStorage( 'pref' );
const cacheStorage      = createNamespacedStorage( 'cache' );
const formStorage       = createNamespacedStorage( 'form' );

preferenceStorage.set( 'theme',    'dark' );
cacheStorage.set( 'posts',      [...] );
formStorage.set( 'contact',  {...} );

6. storageイベントでタブ間同期する

6.1 複数タブの設定を同期する

// 別タブでlocalStorageが変更されたときに発火するイベント
window.addEventListener( 'storage', event => {
    console.log( '変更されたキー:', event.key );
    console.log( '変更前の値:',     event.oldValue );
    console.log( '変更後の値:',     event.newValue );
    console.log( '変更したURL:',    event.url );

    // テーマ設定が別タブで変更されたら同期する
    if ( event.key === 'pref_theme' ) {
        const newTheme = JSON.parse( event.newValue );
        ThemeManager.apply( newTheme );
    }
} );

💡 storage イベントは変更を行ったタブ以外で発火します: 自分のタブで localStorage を変更しても自分のタブの storage イベントは発火しません。複数タブを開いて設定を同期したい場合に活用できます。


7. プライバシーとセキュリティの注意点

7.1 機密情報を保存しない

// ❌ 機密情報をlocalStorageに保存してはいけない
localStorage.setItem( 'password',       'secret123' );
localStorage.setItem( 'credit_card',    '1234-5678-...' );
localStorage.setItem( 'access_token',   'eyJhbGci...' ); // JWTも注意

// ✅ localStorageに保存してよいもの
localStorage.setItem( 'theme',          'dark' );
localStorage.setItem( 'language',       'ja' );
localStorage.setItem( 'notifications',  JSON.stringify( true ) );
localStorage.setItem( 'grid_columns',   JSON.stringify( 3 ) );

⚠️ localStorage はXSSで完全に読み取られます: JavaScriptから自由にアクセスできるため、XSS脆弱性があれば localStorage の全内容が盗まれます。パスワード・クレジットカード情報・セキュリティトークンなどの機密情報は絶対に保存しないでください。

7.2 容量の上限に注意する

// 残り容量を推定する関数
function getStorageUsage() {
    let total = 0;
    for ( const key of Object.keys( localStorage ) ) {
        total += localStorage.getItem( key ).length + key.length;
    }
    return {
        usedBytes:  total * 2,  // UTF-16なので2バイト/文字
        usedKB:     Math.round( total * 2 / 1024 * 10 ) / 10,
        limitKB:    5120,       // 約5MB
        usageRate:  Math.round( total * 2 / ( 5120 * 1024 ) * 100 ),
    };
}

const usage = getStorageUsage();
console.log( `使用量:${usage.usedKB}KB / ${usage.limitKB}KB(${usage.usageRate}%)` );

8. 実践チェックリスト

localStorage.setItem() でオブジェクト・配列を保存する前に JSON.stringify() を使っているか

localStorage.getItem() で取得した値を JSON.parse() で変換しているか

JSON.parse() の前に null チェックを行っているか

try/catch でQuotaExceededErrorなどのエラーをハンドリングしているか

キーにプレフィックスを付けて名前空間を整理しているか

パスワード・トークンなどの機密情報を保存していないか

有効期限が必要なデータに期限情報を一緒に保存しているか

フォームの自動保存は送信成功後に removeItem() で削除しているか

storage イベントで複数タブ間の設定同期が必要か検討したか

プライベートモードでストレージが使えない場合の try/catch を書いているか


まとめ

今回は localStoragesessionStorage の使い方と実践的なデータ永続化のパターンを解説しました。ポイントをまとめると:

  • localStorage は永続保存・sessionStorage はタブを閉じると消えるという違いがある
  • どちらも文字列しか保存できないため、オブジェクトは JSON.stringify() / JSON.parse() で変換する
  • JSON.parse()null を渡すと null を返すが安全のため常に null チェックを行う
  • ラッパー関数を作ることで型変換・エラーハンドリング・デフォルト値を一元管理できる
  • 有効期限は expiresAt を一緒に保存するパターンで実現できる
  • キーはプレフィックスで名前空間を整理してキーの衝突を防ぐ
  • パスワード・クレジットカード・アクセストークンなどの機密情報は絶対に保存しない
  • フォームの自動保存・ダークモード設定・APIキャッシュ・検索履歴が代表的な用途

次の記事では、JavaScriptのモジュールシステム(importexport)とバンドラー(Vite)の基本的な使い方を解説します。お楽しみに!

コメント

タイトルとURLをコピーしました