All skills

Durable UI patterns for modern web development — persisting client-side state across page loads, browser sessions, and shareable URLs. Use this skill when implementing localStorage persistence, URL query parameter state, form draft auto-save, multi-step wizard persistence, click-outside dismissal, modal/dialog backdrop patterns, or any client-side state and interaction pattern that should be resilient and well-behaved. Works with React, Vue, and Svelte.

Use this Skill: https://skilld.dev/gh/sailscastshq/boring-stack/durable-ui

This session only. Nothing lands on disk.

ruleslocal-storage.md

≈3.2k tokens on demand. Your agent reads this file only when SKILL.md points to it.

localStorage Patterns

localStorage is the right choice for browser-scoped preferences — state that should persist across page reloads and browser sessions but doesn't need to be shareable via URL or synced across devices.

Sidebar Collapsed State

The Ascent templates persist sidebar state so it survives navigation:

React

import { useLocalStorage } from '~/hooks/useLocalStorage'

function AppLayout({ children }) {
  const [collapsed, setCollapsed] = useLocalStorage(
    'ASCENT_SIDEBAR_COLLAPSED',
    false
  )

  return (
    <div className="flex">
      <aside className={collapsed ? 'w-16' : 'w-64'}>
        <button onClick={() => setCollapsed(!collapsed)}>
          {collapsed ? '→' : '←'}
        </button>
        {!collapsed && <nav>{/* nav items */}</nav>}
      </aside>
      <main className="flex-1">{children}</main>
    </div>
  )
}

Vue

<script setup>
import { useLocalStorage } from '~/composables/localStorage'

const collapsed = useLocalStorage('ASCENT_SIDEBAR_COLLAPSED', false)
</script>

<template>
  <div class="flex">
    <aside :class="collapsed ? 'w-16' : 'w-64'">
      <button @click="collapsed = !collapsed">
        {{ collapsed ? '→' : '←' }}
      </button>
      <nav v-if="!collapsed"><!-- nav items --></nav>
    </aside>
    <main class="flex-1"><slot /></main>
  </div>
</template>

Svelte

<script>
  import { localStorageStore } from '$lib/stores/localStorage'

  const collapsed = localStorageStore('ASCENT_SIDEBAR_COLLAPSED', false)
</script>

<div class="flex">
  <aside class={$collapsed ? 'w-16' : 'w-64'}>
    <button onclick={() => $collapsed = !$collapsed}>
      {$collapsed ? '→' : '←'}
    </button>
    {#if !$collapsed}
      <nav><!-- nav items --></nav>
    {/if}
  </aside>
  <main class="flex-1"><slot /></main>
</div>

Dark Mode Preference

Dark mode has three states: light, dark, and system (follows OS preference). The Ascent templates implement this with a matchMedia listener for system changes:

React

import { useEffect, useState } from 'react'

export function useDarkMode() {
  const [mode, setMode] = useState(() => {
    if (typeof window === 'undefined') return 'system'
    return localStorage.getItem('darkModePreference') || 'system'
  })

  const [isDark, setIsDark] = useState(() => {
    if (typeof window === 'undefined') return false
    const pref = localStorage.getItem('darkModePreference')
    if (pref === 'dark') return true
    if (pref === 'light') return false
    return window.matchMedia('(prefers-color-scheme: dark)').matches
  })

  useEffect(() => {
    const root = document.documentElement
    if (mode === 'system') {
      const systemDark = window.matchMedia(
        '(prefers-color-scheme: dark)'
      ).matches
      root.classList.toggle('dark', systemDark)
      setIsDark(systemDark)
    } else {
      root.classList.toggle('dark', mode === 'dark')
      setIsDark(mode === 'dark')
    }
  }, [mode])

  // Listen to OS preference changes when in system mode
  useEffect(() => {
    if (mode !== 'system') return
    const mq = window.matchMedia('(prefers-color-scheme: dark)')
    const handler = (e) => {
      setIsDark(e.matches)
      document.documentElement.classList.toggle('dark', e.matches)
    }
    mq.addEventListener('change', handler)
    return () => mq.removeEventListener('change', handler)
  }, [mode])

  const toggleDarkMode = () => {
    const newMode = isDark ? 'light' : 'dark'
    setMode(newMode)
    localStorage.setItem('darkModePreference', newMode)
  }

  const setSystemMode = () => {
    setMode('system')
    localStorage.removeItem('darkModePreference')
  }

  return { isDark, toggleDarkMode, setSystemMode, mode }
}

Vue

<script setup>
import { useDarkMode } from '~/composables/darkMode'

const { isDark, toggleDarkMode, setSystemMode, mode } = useDarkMode()
</script>

<template>
  <button @click="toggleDarkMode">
    {{ isDark ? '☀️' : '🌙' }}
  </button>
  <button @click="setSystemMode" :class="{ active: mode === 'system' }">
    System
  </button>
</template>

Svelte

<script>
  import { localStorageStore } from '$lib/stores/localStorage'
  import { derived } from 'svelte/store'
  import { onMount } from 'svelte'

  const mode = localStorageStore('darkModePreference', 'system')
  let systemDark = $state(false)

  onMount(() => {
    const mq = window.matchMedia('(prefers-color-scheme: dark)')
    systemDark = mq.matches
    mq.addEventListener('change', (e) => systemDark = e.matches)
  })

  $effect(() => {
    const dark = $mode === 'dark' || ($mode === 'system' && systemDark)
    document.documentElement.classList.toggle('dark', dark)
  })
</script>

Dismissed Banners and Onboarding Steps

Track which banners or tips the user has dismissed:

React

function WelcomeBanner() {
  const [dismissed, setDismissed] = useLocalStorage(
    'ASCENT_DISMISSED_BANNERS',
    []
  )

  if (dismissed.includes('welcome')) return null

  return (
    <div className="bg-blue-50 p-4 rounded-lg flex justify-between">
      <p>Welcome to your new app! Here's how to get started...</p>
      <button onClick={() => setDismissed([...dismissed, 'welcome'])}>
        Dismiss
      </button>
    </div>
  )
}

Vue

<script setup>
import { useLocalStorage } from '~/composables/localStorage'
import { computed } from 'vue'

const dismissed = useLocalStorage('ASCENT_DISMISSED_BANNERS', [])
const showBanner = computed(() => !dismissed.value.includes('welcome'))

function dismiss() {
  dismissed.value = [...dismissed.value, 'welcome']
}
</script>

<template>
  <div v-if="showBanner" class="bg-blue-50 p-4 rounded-lg flex justify-between">
    <p>Welcome to your new app! Here's how to get started...</p>
    <button @click="dismiss">Dismiss</button>
  </div>
</template>

Svelte

<script>
  import { localStorageStore } from '$lib/stores/localStorage'

  const dismissed = localStorageStore('ASCENT_DISMISSED_BANNERS', [])
  const showBanner = $derived(!$dismissed.includes('welcome'))

  function dismiss() {
    $dismissed = [...$dismissed, 'welcome']
  }
</script>

{#if showBanner}
  <div class="bg-blue-50 p-4 rounded-lg flex justify-between">
    <p>Welcome to your new app! Here's how to get started...</p>
    <button onclick={dismiss}>Dismiss</button>
  </div>
{/if}

Table Column Visibility

Let users customize which columns are visible in data tables:

React

function UsersTable({ users }) {
  const allColumns = ['name', 'email', 'role', 'created', 'status']
  const [visible, setVisible] = useLocalStorage(
    'ASCENT_USERS_COLUMNS',
    allColumns
  )

  const toggleColumn = (col) => {
    setVisible((prev) =>
      prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col]
    )
  }

  return (
    <>
      <div className="flex gap-2 mb-4">
        {allColumns.map((col) => (
          <label key={col} className="flex items-center gap-1">
            <input
              type="checkbox"
              checked={visible.includes(col)}
              onChange={() => toggleColumn(col)}
            />
            {col}
          </label>
        ))}
      </div>
      <table>
        <thead>
          <tr>
            {visible.map((col) => (
              <th key={col}>{col}</th>
            ))}
          </tr>
        </thead>
        <tbody>
          {users.map((user) => (
            <tr key={user.id}>
              {visible.map((col) => (
                <td key={col}>{user[col]}</td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </>
  )
}

Cross-Tab Synchronization

When a user changes a preference in one tab, other tabs should update. The storage event fires when localStorage is modified by another tab in the same origin:

React

import { useState, useEffect, useCallback } from 'react'

export function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    if (typeof window === 'undefined') return initialValue
    try {
      const item = window.localStorage.getItem(key)
      return item ? JSON.parse(item) : initialValue
    } catch {
      return initialValue
    }
  })

  // Listen for changes from other tabs
  useEffect(() => {
    const handler = (e) => {
      if (e.key === key && e.newValue !== null) {
        try {
          setStoredValue(JSON.parse(e.newValue))
        } catch {
          setStoredValue(e.newValue)
        }
      } else if (e.key === key && e.newValue === null) {
        setStoredValue(initialValue)
      }
    }
    window.addEventListener('storage', handler)
    return () => window.removeEventListener('storage', handler)
  }, [key, initialValue])

  const setValue = useCallback(
    (value) => {
      const valueToStore =
        value instanceof Function ? value(storedValue) : value
      setStoredValue(valueToStore)
      try {
        window.localStorage.setItem(key, JSON.stringify(valueToStore))
      } catch (error) {
        console.warn(`Error setting localStorage key "${key}":`, error)
      }
    },
    [key, storedValue]
  )

  return [storedValue, setValue]
}

Vue

import { ref, watch, onMounted, onUnmounted } from 'vue'

export function useLocalStorage(key, initialValue) {
  const storedValue = ref(initialValue)

  onMounted(() => {
    try {
      const item = window.localStorage.getItem(key)
      if (item) storedValue.value = JSON.parse(item)
    } catch (error) {
      console.warn(`Error reading localStorage key "${key}":`, error)
    }
  })

  watch(storedValue, (newValue) => {
    try {
      window.localStorage.setItem(key, JSON.stringify(newValue))
    } catch (error) {
      console.warn(`Error setting localStorage key "${key}":`, error)
    }
  })

  // Cross-tab sync
  function onStorage(e) {
    if (e.key === key && e.newValue !== null) {
      try {
        storedValue.value = JSON.parse(e.newValue)
      } catch {}
    }
  }

  onMounted(() => window.addEventListener('storage', onStorage))
  onUnmounted(() => window.removeEventListener('storage', onStorage))

  return storedValue
}

Svelte

import { writable } from 'svelte/store'
import { browser } from '$app/environment' // SvelteKit check; for Inertia use typeof window !== 'undefined'

export function localStorageStore(key, initialValue) {
  const isBrowser = typeof window !== 'undefined'

  let initial = initialValue
  if (isBrowser) {
    try {
      const item = window.localStorage.getItem(key)
      if (item) initial = JSON.parse(item)
    } catch {}
  }

  const store = writable(initial)

  if (isBrowser) {
    store.subscribe((value) => {
      try {
        window.localStorage.setItem(key, JSON.stringify(value))
      } catch (error) {
        console.warn(`Error setting localStorage key "${key}":`, error)
      }
    })

    // Cross-tab sync
    window.addEventListener('storage', (e) => {
      if (e.key === key && e.newValue !== null) {
        try {
          store.set(JSON.parse(e.newValue))
        } catch {}
      }
    })
  }

  return store
}

Error Handling and Storage Limits

localStorage has a ~5MB limit. When the quota is exceeded, setItem throws a QuotaExceededError:

function safeSetItem(key, value) {
  try {
    localStorage.setItem(key, JSON.stringify(value))
    return true
  } catch (error) {
    if (error.name === 'QuotaExceededError') {
      // Attempt cleanup: remove expired drafts or old preferences
      cleanupExpiredDrafts()
      try {
        localStorage.setItem(key, JSON.stringify(value))
        return true
      } catch {
        console.error('localStorage is full even after cleanup')
        return false
      }
    }
    console.warn('localStorage error:', error)
    return false
  }
}

function cleanupExpiredDrafts() {
  const now = Date.now()
  for (let i = localStorage.length - 1; i >= 0; i--) {
    const key = localStorage.key(i)
    if (key?.startsWith('ASCENT_DRAFT_')) {
      try {
        const draft = JSON.parse(localStorage.getItem(key))
        if (draft?.expiresAt && draft.expiresAt < now) {
          localStorage.removeItem(key)
        }
      } catch {
        localStorage.removeItem(key)
      }
    }
  }
}

Private/Incognito Mode

In most browsers, localStorage works in private/incognito mode but is cleared when the window closes. Safari historically had a 0-byte quota in private mode (fixed in modern versions). Always use try/catch to handle gracefully.

Source: SKILL.md on GitHub

1 warning17d4 checks · Risk SAFE
  • Gen Agent Trust Hub17d

    This skill provides safe architectural patterns and code examples for managing persistent UI state in React, Vue, and Svelte applications. It does not contain any malicious code or instructions.

  • Socket17d

    No alerts

  • Snyk17d

    Risk: LOW · No issues

  • Runlayer7mo

    17/17 files flagged

Signed by skilld at e79a3b5. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 3 days ago.

Activeupdated 8 months ago
Other metadata
metadata
{
  "author": "sailscastshq",
  "version": "1.1.0",
  "tags": "durable-ui, localStorage, url-state, query-params, form-persistence, multi-step-forms, click-outside, modal, dialog, backdrop, dropdown, state-management, web-dev"
}

README badge

README badge for sailscastshq/boring-stack/durable-ui