テーマの設定や、入力途中のメモなど、ページを更新しても残しておきたい値は、ブラウザのlocalStorageに保存できます。使い方は簡単ですが、文字列しか保存できない、壊れたデータや使えない環境がある、保存してはいけない情報がある、といった注意点があります。この記事では、Vue 3(<script setup>)で、安全に使うためのuseLocalStorageを作ります。
localStorageとは
localStorageは、ブラウザに保存されるキーと値のペアです。MDNによると、sessionStorageとの違いは、保存の期間です。
- sessionStorage:ページのセッションの間(ブラウザを開いている間、リロードや復元を含む)だけ保持される。保存先は、タブごとに別。
- localStorage:ブラウザを閉じて、開き直しても残る。同じオリジンの、すべてのタブで共有される。
どちらも、別のオリジン(ドメイン、スキーム、ポートが違うもの)からは、アクセスできません。容量には上限があり、超えるとQuotaExceededErrorになります。上限の大きさはブラウザによって異なるため、数MBを目安に、大きなデータの保存には使わないようにします。
文字列しか保存できない
最初につまずくのが、これです。Storageに保存できるのは、文字列だけです。オブジェクトをそのままsetItemに渡すと、"[object Object]"という文字列になってしまいます。オブジェクトや配列は、JSON.stringifyで文字列にして保存し、読むときにJSON.parseで戻します。
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関数でロジックを共通化する方法をご覧ください。
// 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にそのまま結びつけるだけで、入力が自動で保存され、ページを更新しても復元されます。
<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>
<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の内容は、そのページで動くJavaScriptなら、何からでも読めます。XSS(悪意のあるスクリプトの注入)を受けたとき、また読み込んだ外部のスクリプトに、そのまま持ち出されるおそれがあります。認証のトークン、パスワード、個人情報は保存しないでください。ログイン状態は、JavaScriptから読めないHttpOnly属性のクッキーで、サーバーが管理するのが一般的です。保存するのは、テーマや表示の設定のような、漏れても困らない値にとどめます。PiniaやVuexと組み合わせる
アプリ全体の状態を、ストアごと永続化したい場合は、ストアの変更を購読して保存する方法があります。Piniaでの具体的な書き方はPinia × LocalStorageで永続的な状態管理を行う方法をご覧ください。ストアの基本はPiniaを使った状態管理の始め方で解説しています。
よくある質問(FAQ)
Storageは文字列しか保存できないためです。JSON.stringifyで文字列にして保存し、読むときにJSON.parseで戻してください。HttpOnlyのクッキーのほうが向いています。localStorage、そのタブの作業中だけ保持したい値はsessionStorageです。localStorageは同じオリジンの全タブで共有され、sessionStorageはタブごとに別です。localStorageを返す場合があるためです。setItemをtry/catchで囲み、保存できなくても、アプリが動き続けるようにしてください。まとめ
localStorageは、文字列しか保存できません。オブジェクトはJSONで保存し、読み込みと書き込みはtry/catchで守り、保存はwatchで行います。他のタブとはstorageイベントで同期できます。そして、読み取られて困る情報(トークン、個人情報)は、保存しないでください。

