【Vue.js】ローカルストレージを使った状態管理|ページ更新後も値を保持

【Vue.js】ローカルストレージを使った状態管理|ページ更新後も値を保持 Vue.js

テーマの設定や、入力途中のメモなど、ページを更新しても残しておきたい値は、ブラウザのlocalStorageに保存できます。使い方は簡単ですが、文字列しか保存できない、壊れたデータや使えない環境がある、保存してはいけない情報がある、といった注意点があります。この記事では、Vue 3(<script setup>)で、安全に使うためのuseLocalStorageを作ります。

スポンサーリンク

localStorageとは

localStorageは、ブラウザに保存されるキーと値のペアです。MDNによると、sessionStorageとの違いは、保存の期間です。

  • sessionStorage:ページのセッションの間(ブラウザを開いている間、リロードや復元を含む)だけ保持される。保存先は、タブごとに別。
  • localStorage:ブラウザを閉じて、開き直しても残る。同じオリジンの、すべてのタブで共有される。

どちらも、別のオリジン(ドメイン、スキーム、ポートが違うもの)からは、アクセスできません。容量には上限があり、超えるとQuotaExceededErrorになります。上限の大きさはブラウザによって異なるため、数MBを目安に、大きなデータの保存には使わないようにします。

文字列しか保存できない

最初につまずくのが、これです。Storageに保存できるのは、文字列だけです。オブジェクトをそのままsetItemに渡すと、"[object Object]"という文字列になってしまいます。オブジェクトや配列は、JSON.stringifyで文字列にして保存し、読むときにJSON.parseで戻します。

JavaScript:文字列しか保存できない
const person = { name: 'Alex' }

// NG:オブジェクトをそのまま渡すと、文字列に変換されて壊れる
localStorage.setItem('user', person)
console.log(localStorage.getItem('user'))        // "[object Object]"

// OK:JSON 文字列にして保存し、読むときに戻す
localStorage.setItem('user', JSON.stringify(person))
console.log(JSON.parse(localStorage.getItem('user')))   // { name: 'Alex' }

安全に読み書きする:useLocalStorage

基本のsetItemとgetItemを、そのまま使うと、実際の環境で問題が出ます。

  • 保存されたデータが壊れている:手で書き換えられたり、アプリの更新で形式が変わったりすると、JSON.parseが例外を投げ、画面が真っ白になります。try/catchで囲み、失敗したら既定値を使います。
  • localStorageが使えない環境がある:MDNによると、ブラウザがlocalStorageをサポートしていても、スクリプトから使えるとは限りません。たとえばプライベートブラウズでは、容量が0の空のlocalStorageになることがあり、setItemが失敗します。使えるかを事前に確認し、書き込みもtry/catchで囲みます。
  • 日本語入力の変換中:保存を@inputのイベントで行うと、v-modelが変換中は更新されないため、古い値が保存されることがあります。値の変化に反応するwatchで保存するほうが安全です。
  • ほかのタブとの食い違い:同じアプリを複数のタブで開いていると、一方で変えた設定が、他方に反映されません。

これらをまとめたComposableです。Composableの作り方はComposable関数でロジックを共通化する方法をご覧ください。

JavaScript:composables/useLocalStorage.js
// composables/useLocalStorage.js
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'

// localStorage が「使える」とは限らない(プライベートブラウズ等で、容量0になることがある)
function storageAvailable() {
  try {
    const test = '__storage_test__'
    localStorage.setItem(test, test)
    localStorage.removeItem(test)
    return true
  } catch (e) {
    return false
  }
}

export function useLocalStorage(key, defaultValue) {
  const available = storageAvailable()

  // 読み込み:保存されていない、壊れている、使えない場合は、既定値を返す
  function read() {
    if (!available) return defaultValue
    try {
      const raw = localStorage.getItem(key)
      return raw === null ? defaultValue : JSON.parse(raw)
    } catch (e) {
      return defaultValue
    }
  }

  const state = ref(read())

  // 保存:値が変わるたびに書き込む(v-model の値の変化に反応するので、日本語入力の変換中も安全)
  watch(
    state,
    (value) => {
      if (!available) return
      try {
        localStorage.setItem(key, JSON.stringify(value))
      } catch (e) {
        // 容量超過など。保存に失敗しても、アプリは動かし続ける
      }
    },
    { deep: true }
  )

  // 他のタブで変更されたら、こちらにも反映する
  // (storage イベントは、変更したタブ自身には発生しない)
  function onStorage(event) {
    if (event.storageArea !== localStorage || event.key !== key) return
    state.value = read()
  }
  onMounted(() => window.addEventListener('storage', onStorage))
  onBeforeUnmount(() => window.removeEventListener('storage', onStorage))

  return state
}

使い方

返ってくるのは通常のrefです。v-modelにそのまま結びつけるだけで、入力が自動で保存され、ページを更新しても復元されます。

Vue:文字列を保存する
<script setup>
import { useLocalStorage } from './composables/useLocalStorage'

// キーにバージョンを付けておくと、保存する形を変えたときに、古いデータと区別できる
const message = useLocalStorage('myapp:v1:message', '')
</script>

<template>
  <label for="message">メモ</label>
  <input id="message" v-model="message">
  <p>保存されたメモ:{{ message }}</p>
</template>
Vue:オブジェクトを保存する
<script setup>
import { useLocalStorage } from './composables/useLocalStorage'

// オブジェクトも、そのまま渡せる(内部で JSON に変換して保存する)
const settings = useLocalStorage('myapp:v1:settings', { theme: 'light', fontSize: 16 })
</script>

<template>
  <select v-model="settings.theme">
    <option value="light">ライト</option>
    <option value="dark">ダーク</option>
  </select>
  <input v-model.number="settings.fontSize" type="number" min="10" max="32">
</template>

v-modelの使い方は双方向データバインディングでフォームの入力値を反映させる方法をご覧ください。

キーにバージョンを付ける理由

myapp:v1:settingsのように、アプリ名とバージョンをキーに含めておくと、あとで保存する形を変えたときに、古い形式のデータと区別できます。形式を変えるときはv2にして、古いキーは削除するか、読み込んで変換します。

他のタブとの同期

MDNによると、storageイベントは、同じオリジンのほかのタブでStorageが変更されたときに発生します。変更したタブ自身には発生しません。上のComposableは、このイベントを受けて、他のタブでの変更をこちらの状態に反映します。

localStorageに保存してはいけない情報
localStorageの内容は、そのページで動くJavaScriptなら、何からでも読めます。XSS(悪意のあるスクリプトの注入)を受けたとき、また読み込んだ外部のスクリプトに、そのまま持ち出されるおそれがあります。認証のトークン、パスワード、個人情報は保存しないでください。ログイン状態は、JavaScriptから読めないHttpOnly属性のクッキーで、サーバーが管理するのが一般的です。保存するのは、テーマや表示の設定のような、漏れても困らない値にとどめます。

PiniaやVuexと組み合わせる

アプリ全体の状態を、ストアごと永続化したい場合は、ストアの変更を購読して保存する方法があります。Piniaでの具体的な書き方はPinia × LocalStorageで永続的な状態管理を行う方法をご覧ください。ストアの基本はPiniaを使った状態管理の始め方で解説しています。

よくある質問(FAQ)

Qオブジェクトを保存したら、「[object Object]」と表示されます。
AStorageは文字列しか保存できないためです。JSON.stringifyで文字列にして保存し、読むときにJSON.parseで戻してください。
QlocalStorageにログイン情報を保存してもよいですか?
Aおすすめしません。そのページで動くJavaScriptなら、何からでも読めるため、XSSや外部のスクリプトで持ち出されるおそれがあります。サーバーが管理するHttpOnlyのクッキーのほうが向いています。
QlocalStorageとsessionStorageは、どう使い分けますか?
Aブラウザを閉じても残したい設定はlocalStorage、そのタブの作業中だけ保持したい値はsessionStorageです。localStorageは同じオリジンの全タブで共有され、sessionStorageはタブごとに別です。
Qプライベートブラウズで、保存に失敗します。
Aブラウザが、容量0のlocalStorageを返す場合があるためです。setItemをtry/catchで囲み、保存できなくても、アプリが動き続けるようにしてください。

まとめ

localStorageは、文字列しか保存できません。オブジェクトはJSONで保存し、読み込みと書き込みはtry/catchで守り、保存はwatchで行います。他のタブとはstorageイベントで同期できます。そして、読み取られて困る情報(トークン、個人情報)は、保存しないでください。