localStorage でサイドバーの開閉状態を保持しつつ、初回描画のちらつきを防ぐ実装
はじめに
DVDレンタルアプリの作品一覧画面には左サイドバーがあります。
「フィルタ条件を使い終わったらサイドバーを閉じたい」「次に開いたときも閉じた状態を維持したい」という要件を実装しました。
localStorage に状態を保存するのは簡単ですが、実装してみると ページ読み込み時にサイドバーが一瞬開いてから閉じる「ちらつき(FOUC: Flash of Unstyled Content)」 が発生しました。
この記事は、そのちらつきを防ぐ実装方法についてです。
まず動く実装(ちらつきあり)
<!-- FilmsView.vue -->
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
// デフォルトは open
const sidebarOpen = ref(true)
onMounted(() => {
// マウント後に localStorage から読む
const saved = localStorage.getItem('sidebarOpen')
if (saved !== null) {
sidebarOpen.value = saved === 'true'
}
})
watch(sidebarOpen, (val) => {
localStorage.setItem('sidebarOpen', String(val))
})
</script>
<template>
<div class="layout">
<aside :class="{ 'sidebar--closed': !sidebarOpen }">
<!-- サイドバーコンテンツ -->
</aside>
<main>
<!-- メインコンテンツ -->
</main>
</div>
</template>
この実装の問題:
- 初期値
ref(true)でサイドバーが開いた状態でDOMが構築される onMountedでlocalStorageを読んでfalseになる- Vueがリアクティブに閉じるアニメーションを実行 → 一瞬開いてから閉じる
ちらつきを防ぐ解決策
解決策1:初期値を localStorage から読む
// localStorage から初期値を取得する関数
function getInitialSidebarState(): boolean {
const saved = localStorage.getItem('sidebarOpen')
if (saved !== null) return saved === 'true'
return true // デフォルトは開いた状態
}
const sidebarOpen = ref(getInitialSidebarState())
ref() の初期値を localStorage から取得することで、最初から正しい状態でDOMが構築されます。
onMounted のタイミングまで待つ必要がなくなります。
解決策2:SSR環境での考慮(Nuxt等)
Next.js(React)やNuxt.js(Vue)でSSR(サーバーサイドレンダリング)を使っている場合、サーバー側では localStorage が存在しないためエラーになります。
function getInitialSidebarState(): boolean {
if (typeof window === 'undefined') return true // SSR環境
const saved = localStorage.getItem('sidebarOpen')
return saved !== null ? saved === 'true' : true
}
今回のプロジェクトはVite + Vue 3(CSR)なので不要ですが、汎用的なコードとして書いておくと移植しやすいです。
アニメーションと組み合わせる場合
CSS Transitionを使ってスムーズな開閉アニメーションを付けている場合、初回描画でもアニメーションが走ることがあります。
解決策:マウント後にアニメーションを有効化
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
function getInitialSidebarState(): boolean {
const saved = localStorage.getItem('sidebarOpen')
return saved !== null ? saved === 'true' : true
}
const sidebarOpen = ref(getInitialSidebarState())
const animationEnabled = ref(false) // 初期はアニメーション無効
onMounted(() => {
// マウント後にアニメーションを有効化
animationEnabled.value = true
})
watch(sidebarOpen, (val) => {
localStorage.setItem('sidebarOpen', String(val))
})
</script>
<template>
<aside
:class="{
'sidebar--closed': !sidebarOpen,
'sidebar--animated': animationEnabled // マウント後にアニメーションCSSを適用
}">
</aside>
</template>
/* sidebar--animated が付いたときだけトランジションを有効化 */
.sidebar--animated {
transition: width 0.3s ease, opacity 0.3s ease;
}
.sidebar--closed {
width: 0;
opacity: 0;
overflow: hidden;
}
初回描画ではアニメーションなしで瞬時に正しい状態を表示し、その後のユーザー操作に対してのみアニメーションが動きます。
完成形のコード
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
function getInitialSidebarState(): boolean {
try {
const saved = localStorage.getItem('sidebarOpen')
return saved !== null ? saved === 'true' : true
} catch {
// localStorage が使えない環境(プライベートブラウジング等)
return true
}
}
const sidebarOpen = ref(getInitialSidebarState())
const animationEnabled = ref(false)
onMounted(() => {
// 次のフレームでアニメーション有効化(念のため requestAnimationFrame を使う)
requestAnimationFrame(() => {
animationEnabled.value = true
})
})
function toggleSidebar() {
sidebarOpen.value = !sidebarOpen.value
}
watch(sidebarOpen, (val) => {
try {
localStorage.setItem('sidebarOpen', String(val))
} catch {
// localStorage が書けない環境では無視
}
})
</script>
Piniaを使う場合
Vuexや Piniaで状態管理している場合は、プラグインとして永続化できます。
npm install pinia-plugin-persistedstate
// stores/sidebar.ts
import { defineStore } from 'pinia'
export const useSidebarStore = defineStore('sidebar', {
state: () => ({
isOpen: true,
}),
actions: {
toggle() {
this.isOpen = !this.isOpen
}
},
persist: true, // localStorage に自動永続化
})
ただしCSRのみで動作します。SSRでは設定が必要です。
まとめ
| 問題 | 原因 | 解決策 |
|---|---|---|
| ちらつき(FOUC) | onMounted 後に localStorage を読む | ref() の初期値で直接読む |
| 初回にアニメーションが動く | DOMマウント時にCSSが適用済み | onMounted 後にアニメーションCSSを有効化 |
| localStorage が使えない環境 | プライベートブラウジング等 | try-catch で保護 |
「動けばいい」で実装するとちらつきが残りやすいので、初期値の取り方とアニメーション有効化のタイミングを意識することが重要です。
このアプリでの実装
このDVDレンタルアプリでは、サイドバーの状態ではなく カートとあとで借りるリスト を localStorage に永続化しています。
ページをリロードしても「カートに入れた作品」が消えない仕組みです。
記事で紹介した「初期値を直接 ref に渡す」パターンを応用し、 さらに シングルトン composable として実装することで、どのコンポーネントから呼んでも同じ状態を参照できます。
useCart composable(実際のコード)
// composables/useCart.ts
import { computed, ref } from 'vue'
import type { PublicFilmSummary } from '../api/client'
type CartItem = PublicFilmSummary & { addedAt: string }
const STORAGE_KEY = 'dvd-rental-customer.cart'
const itemsRef = ref<CartItem[]>([]) // モジュールスコープのref(シングルトン)
let initialized = false
function loadFromStorage(): CartItem[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw) as CartItem[]
if (!Array.isArray(parsed)) return []
// 型バリデーション:不正なデータを除外
return parsed.filter((item) => typeof item.filmId === 'number' && typeof item.title === 'string')
} catch {
return [] // localStorage が使えない環境(プライベートブラウジング等)も考慮
}
}
function ensureInitialized() {
if (initialized) return
if (typeof window === 'undefined') return // SSR安全性(将来のNuxt移行を想定)
itemsRef.value = loadFromStorage() // ← 初回呼び出し時に ref の初期値をセット
initialized = true
}
function addToCart(film: PublicFilmSummary): boolean {
ensureInitialized()
const exists = itemsRef.value.some((item) => item.filmId === film.filmId)
if (exists) return false // 二重追加防止
const next: CartItem = { ...film, addedAt: new Date().toISOString() }
itemsRef.value = [next, ...itemsRef.value]
localStorage.setItem(STORAGE_KEY, JSON.stringify(itemsRef.value))
return true
}
export function useCart() {
ensureInitialized()
return {
cartItems: computed(() => itemsRef.value),
cartCount: computed(() => itemsRef.value.length),
cartTotal: computed(() => itemsRef.value.reduce((sum, item) => sum + (item.rentalRate ?? 0), 0)),
addToCart,
removeFromCart,
clearCart,
isInCart,
}
}
ref() の初期化を onMounted ではなく ensureInitialized() という関数で遅延初期化する構造にすることで、
コンポーネントのライフサイクルに依存せず、どの呼び出しタイミングでも安全に動きます。
実際の画面
カートに追加された作品がリロード後も保持されているカートドロワー:

このシリーズの記事マップ
→ dvdrental 管理アプリと対になるエンドユーザー向けDVDレンタルアプリを作っている話 — Vue 3 + Spring Boot の全体構成と記事マップ