Tech Blog

localStorage でサイドバーの開閉状態を保持しつつ、初回描画のちらつきを防ぐ実装

Vue3 TypeScript Frontend

はじめに

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>

この実装の問題:

  1. 初期値 ref(true) でサイドバーが開いた状態でDOMが構築される
  2. onMountedlocalStorage を読んで false になる
  3. 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() という関数で遅延初期化する構造にすることで、 コンポーネントのライフサイクルに依存せず、どの呼び出しタイミングでも安全に動きます。

実際の画面

カートに追加された作品がリロード後も保持されているカートドロワー:

カートドロワー(localStorage永続化)


このシリーズの記事マップ

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

気軽にメッセージください

技術相談・ご感想・ご質問があればメッセージをお願いします。