Tech Blog

การรักษาสถานะเปิด/ปิด Sidebar ด้วย localStorage พร้อมป้องกัน Flicker ในการ Render ครั้งแรก

by y104
Vue3 TypeScript Frontend

บทนำ

หน้ารายการภาพยนตร์ของแอป DVD rental มี sidebar ทางซ้าย
เราได้ implement ข้อกำหนด: “ปิด sidebar หลังใช้ filter conditions” และ “รักษาสถานะปิดเมื่อเปิดครั้งถัดไป”

การบันทึกสถานะไปยัง localStorage เป็นเรื่องง่าย แต่เมื่อ implement จริงพบ “flicker (FOUC: Flash of Unstyled Content) ที่ sidebar เปิดชั่วครู่แล้วปิดเมื่อโหลดหน้า”

บทความนี้ครอบคลุมวิธีการ implement เพื่อป้องกัน flicker ดังกล่าว


ขั้นแรก การ Implement ที่ทำงานได้ (มี Flicker)

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

// ค่าเริ่มต้นคือ open
const sidebarOpen = ref(true)

onMounted(() => {
  // อ่านจาก localStorage หลัง 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 -->
    </aside>
    <main>
      <!-- เนื้อหาหลัก -->
    </main>
  </div>
</template>

ปัญหาของการ implement นี้:

  1. DOM ถูกสร้างด้วย sidebar ที่เปิดอยู่ในสถานะค่าเริ่มต้น ref(true)
  2. onMounted อ่าน localStorage และกลายเป็น false
  3. Vue รัน animation ปิดแบบ reactive → เปิดชั่วครู่แล้วปิด

วิธีแก้ปัญหาเพื่อป้องกัน Flicker

วิธีที่ 1: อ่านค่าเริ่มต้นจาก localStorage

// ฟังก์ชันสำหรับดึงสถานะ sidebar เริ่มต้นจาก 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 Environment (Nuxt ฯลฯ)

เมื่อใช้ SSR (Server-Side Rendering) กับ Next.js (React) หรือ Nuxt.js (Vue), localStorage ไม่มีอยู่ฝั่ง server ทำให้เกิด error

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

โปรเจกต์นี้ใช้ Vite + Vue 3 (CSR) จึงไม่จำเป็น แต่การเขียนเป็น generic code ทำให้ port ได้ง่ายขึ้น


การรวมกับ Animation

เมื่อใช้ CSS Transition สำหรับ animation เปิด/ปิดแบบ smooth, animation อาจรันในการ render ครั้งแรกด้วย

วิธีแก้: เปิดใช้งาน Animation หลัง 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)  // ปิดใช้งาน animation ในตอนแรก

onMounted(() => {
  // เปิดใช้งาน animation หลัง 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 หลัง mount
    }">
  </aside>
</template>
/* เปิดใช้งาน transition เฉพาะเมื่อ sidebar--animated ถูก attach */
.sidebar--animated {
  transition: width 0.3s ease, opacity 0.3s ease;
}

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

ในการ render ครั้งแรก แสดงสถานะที่ถูกต้องทันทีโดยไม่มี animation และ animation จะรันเฉพาะการโต้ตอบของผู้ใช้ที่ตามมาเท่านั้น


โค้ดสำเร็จรูป

<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 {
    // Environment ที่ localStorage ไม่สามารถใช้ได้ (private browsing ฯลฯ)
    return true
  }
}

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

onMounted(() => {
  // เปิดใช้งาน animation ใน frame ถัดไป (ใช้ requestAnimationFrame เพื่อความปลอดภัย)
  requestAnimationFrame(() => {
    animationEnabled.value = true
  })
})

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

watch(sidebarOpen, (val) => {
  try {
    localStorage.setItem('sidebarOpen', String(val))
  } catch {
    // Ignore ใน environment ที่ localStorage ไม่สามารถเขียนได้
  }
})
</script>

เมื่อใช้ Pinia

เมื่อจัดการ state ด้วย Vuex หรือ Pinia สามารถ persist ได้ในรูปแบบ 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 ไปยัง localStorage
})

อย่างไรก็ตาม ทำงานได้เฉพาะกับ CSR เท่านั้น SSR ต้องการ configuration


สรุป

ปัญหาสาเหตุวิธีแก้
Flicker (FOUC)อ่าน localStorage หลัง onMountedอ่านโดยตรงในค่าเริ่มต้นของ ref()
Animation รันในการ render ครั้งแรกCSS ถูก apply แล้วตอน DOM mountเปิดใช้งาน animation CSS หลัง onMounted
Environment ที่ localStorage ไม่สามารถใช้ได้Private browsing ฯลฯป้องกันด้วย try-catch

การ implement แบบ “ขอให้ทำงานได้ก็พอ” มักทิ้ง flicker ไว้ ดังนั้นการตระหนักถึงวิธีดึงค่าเริ่มต้นและเวลาในการเปิดใช้งาน animation จึงสำคัญ


การ Implement ในแอปนี้

ในแอป DVD rental นี้ แทนที่จะเป็นสถานะ sidebar เรา persist cart และ watch-later list ไปยัง localStorage
นี่คือกลไกที่ป้องกัน “ภาพยนตร์ที่เพิ่มในตะกร้า” ไม่ให้หายไปแม้จะ reload หน้า

โดยประยุกต์ pattern ที่แนะนำในบทความ “ส่งค่าเริ่มต้นโดยตรงไปยัง ref”
และต่อยอดด้วยการ implement เป็น singleton composable ทำให้ component ใดก็ตามสามารถ reference สถานะเดียวกันได้

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[]>([])  // 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: ยกเว้นข้อมูลที่ไม่ถูกต้อง
    return parsed.filter((item) => typeof item.filmId === 'number' && typeof item.title === 'string')
  } catch {
    return []  // รับมือกับ environment ที่ localStorage ไม่สามารถใช้ได้ด้วย (private browsing ฯลฯ)
  }
}

function ensureInitialized() {
  if (initialized) return
  if (typeof window === 'undefined') return  // SSR safety (anticipating future Nuxt migration)
  itemsRef.value = loadFromStorage()          // ← Set ค่าเริ่มต้นของ 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,
  }
}

การสร้าง structure การ initialize ของ ref() เป็น lazy initialization ผ่านฟังก์ชัน ensureInitialized() แทน onMounted
ทำให้ทำงานได้อย่างปลอดภัยในทุก call timing โดยไม่ขึ้นอยู่กับ component lifecycle

หน้าจอจริง

Cart drawer ที่รายการที่เพิ่มในตะกร้าถูกรักษาไว้หลัง reload:

Cart Drawer (localStorage persistence)


แผนที่บทความสำหรับซีรีส์นี้

สร้างแอป DVD Rental สำหรับผู้ใช้ปลายทาง — โครงสร้าง Vue 3 + Spring Boot คู่กับแอปผู้ดูแลระบบ พร้อมแผนที่บทความ

ส่งข้อความได้ตามสบาย

กรุณาส่งข้อความ หากมีคำปรึกษาด้านเทคนิค ความคิดเห็น หรือคำถาม