Tech Blog

Preserving Sidebar Open/Close State with localStorage While Preventing Initial Render Flicker

by y104
Vue3 TypeScript Frontend

Introduction

The film list page of the DVD rental app has a left sidebar.
We implemented the requirements: “close the sidebar after using filter conditions” and “maintain the closed state when next opened.”

Saving state to localStorage is simple, but when implemented we encountered a “flicker (FOUC: Flash of Unstyled Content)” where the sidebar briefly opens then closes on page load.

This article covers implementation methods to prevent that flicker.


First, a Working Implementation (with Flicker)

<!-- FilmsView.vue -->
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'

// Default is open
const sidebarOpen = ref(true)

onMounted(() => {
  // Read from localStorage after mount
  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 }">
      <!-- Sidebar content -->
    </aside>
    <main>
      <!-- Main content -->
    </main>
  </div>
</template>

The problem with this implementation:

  1. DOM is constructed with sidebar open in the initial value ref(true) state
  2. onMounted reads localStorage and it becomes false
  3. Vue reactively runs the close animation → briefly opens then closes

Solutions to Prevent Flicker

Solution 1: Read Initial Value from localStorage

// Function to get initial sidebar state from localStorage
function getInitialSidebarState(): boolean {
  const saved = localStorage.getItem('sidebarOpen')
  if (saved !== null) return saved === 'true'
  return true // Default is open
}

const sidebarOpen = ref(getInitialSidebarState())

By getting the initial value of ref() from localStorage, the DOM is constructed with the correct state from the start.
No need to wait until the onMounted timing.


Solution 2: Considerations for SSR Environments (Nuxt, etc.)

When using SSR (Server-Side Rendering) with Next.js (React) or Nuxt.js (Vue), localStorage doesn’t exist on the server side, causing errors.

function getInitialSidebarState(): boolean {
  if (typeof window === 'undefined') return true // SSR environment
  const saved = localStorage.getItem('sidebarOpen')
  return saved !== null ? saved === 'true' : true
}

This project uses Vite + Vue 3 (CSR) so it’s not needed, but writing it as generic code makes it easier to port.


Combining with Animation

When using CSS Transition for smooth open/close animation, the animation may also run on initial render.

Solution: Enable Animation After Mount

<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)  // Initially animation disabled

onMounted(() => {
  // Enable animation after mount
  animationEnabled.value = true
})

watch(sidebarOpen, (val) => {
  localStorage.setItem('sidebarOpen', String(val))
})
</script>

<template>
  <aside 
    :class="{ 
      'sidebar--closed': !sidebarOpen,
      'sidebar--animated': animationEnabled  // Apply animation CSS after mount
    }">
  </aside>
</template>
/* Enable transition only when sidebar--animated is attached */
.sidebar--animated {
  transition: width 0.3s ease, opacity 0.3s ease;
}

.sidebar--closed {
  width: 0;
  opacity: 0;
  overflow: hidden;
}

On initial render, display the correct state instantly without animation, and animation only runs for subsequent user interactions.


The Complete Code

<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 {
    // Environments where localStorage is unavailable (private browsing, etc.)
    return true
  }
}

const sidebarOpen = ref(getInitialSidebarState())
const animationEnabled = ref(false)

onMounted(() => {
  // Enable animation on next frame (use requestAnimationFrame just in case)
  requestAnimationFrame(() => {
    animationEnabled.value = true
  })
})

function toggleSidebar() {
  sidebarOpen.value = !sidebarOpen.value
}

watch(sidebarOpen, (val) => {
  try {
    localStorage.setItem('sidebarOpen', String(val))
  } catch {
    // Ignore in environments where localStorage can't be written
  }
})
</script>

When Using Pinia

When managing state with Vuex or Pinia, you can persist it as a plugin.

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,  // Auto-persist to localStorage
})

However this only works with CSR. SSR requires configuration.


Summary

ProblemCauseSolution
Flicker (FOUC)Reading localStorage after onMountedRead directly in initial value of ref()
Animation runs on initial renderCSS already applied at DOM mountEnable animation CSS after onMounted
Environments where localStorage is unavailablePrivate browsing, etc.Protect with try-catch

Implementing with “just make it work” tends to leave flickering, so it’s important to be mindful of how you get initial values and when you enable animation.


Implementation in This App

In this DVD rental app, rather than the sidebar state, we persist the cart and watch-later list to localStorage. This is the mechanism that prevents “films added to cart” from disappearing even on page reload.

Applying the pattern introduced in the article of “passing the initial value directly to ref,” and further implementing it as a singleton composable, allows any component to reference the same state.

useCart composable (Actual Code)

// 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[]>([])  // Module-scope ref (singleton)
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 []
    // Type validation: exclude invalid data
    return parsed.filter((item) => typeof item.filmId === 'number' && typeof item.title === 'string')
  } catch {
    return []  // Also handles environments where localStorage is unavailable (private browsing, etc.)
  }
}

function ensureInitialized() {
  if (initialized) return
  if (typeof window === 'undefined') return  // SSR safety (anticipating future Nuxt migration)
  itemsRef.value = loadFromStorage()          // ← Set initial value of ref on first call
  initialized = true
}

function addToCart(film: PublicFilmSummary): boolean {
  ensureInitialized()
  const exists = itemsRef.value.some((item) => item.filmId === film.filmId)
  if (exists) return false  // Prevent duplicate addition
  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,
  }
}

By structuring the initialization of ref() as lazy initialization via an ensureInitialized() function rather than onMounted, it works safely at any call timing without depending on the component lifecycle.

Actual Screen

Cart drawer with items added to cart preserved after reload:

Cart Drawer (localStorage persistence)


Article Map for This Series

Building an End-User DVD Rental App — Vue 3 + Spring Boot Paired with the Admin App, with Article Map

Feel free to send a message

Please send a message if you have any technical questions, feedback, or inquiries.