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.

rulesreact.md

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

React Implementations

Complete React hook implementations for durable UI patterns. These hooks follow React conventions (useState API, useEffect for side effects) and are SSR-safe for use with Inertia.js server-side rendering.

useLocalStorage Hook

Reference implementation from the Ascent React template, extended with cross-tab sync:

// assets/js/hooks/useLocalStorage.js
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 (error) {
      console.warn(`Error reading localStorage key "${key}":`, error)
      return initialValue
    }
  })

  // Sync to localStorage when value changes
  const setValue = useCallback(
    (value) => {
      try {
        const valueToStore =
          value instanceof Function ? value(storedValue) : value
        setStoredValue(valueToStore)
        if (typeof window !== 'undefined') {
          window.localStorage.setItem(key, JSON.stringify(valueToStore))
        }
      } catch (error) {
        console.warn(`Error setting localStorage key "${key}":`, error)
      }
    },
    [key, storedValue]
  )

  // Cross-tab sync: listen for changes from other tabs
  useEffect(() => {
    const handler = (e) => {
      if (e.key === key) {
        try {
          const newValue = e.newValue ? JSON.parse(e.newValue) : initialValue
          setStoredValue(newValue)
        } catch {
          setStoredValue(initialValue)
        }
      }
    }
    window.addEventListener('storage', handler)
    return () => window.removeEventListener('storage', handler)
  }, [key, initialValue])

  return [storedValue, setValue]
}

API:

  • const [value, setValue] = useLocalStorage('key', defaultValue)
  • setValue(newValue) — direct value
  • setValue(prev => !prev) — functional update (same API as useState)
  • Returns defaultValue during SSR and when localStorage is unavailable

useQueryState Hook

Syncs a single URL query parameter with React state:

// assets/js/hooks/useQueryState.js
import { useState, useEffect, useCallback } from 'react'

export function useQueryState(key, defaultValue = '', options = {}) {
  const { replace = false } = options

  const [value, setValueState] = useState(() => {
    if (typeof window === 'undefined') return defaultValue
    const params = new URLSearchParams(window.location.search)
    return params.get(key) ?? defaultValue
  })

  // Update URL when value changes
  const setValue = useCallback(
    (newValue) => {
      const resolvedValue =
        newValue instanceof Function ? newValue(value) : newValue
      setValueState(resolvedValue)

      if (typeof window === 'undefined') return

      const url = new URL(window.location.href)
      if (resolvedValue && resolvedValue !== defaultValue) {
        url.searchParams.set(key, resolvedValue)
      } else {
        url.searchParams.delete(key)
      }

      const method = replace ? 'replaceState' : 'pushState'
      window.history[method]({}, '', url.toString())
    },
    [key, defaultValue, replace, value]
  )

  // Listen for back/forward navigation
  useEffect(() => {
    const handlePopState = () => {
      const params = new URLSearchParams(window.location.search)
      setValueState(params.get(key) ?? defaultValue)
    }
    window.addEventListener('popstate', handlePopState)
    return () => window.removeEventListener('popstate', handlePopState)
  }, [key, defaultValue])

  return [value, setValue]
}

API:

  • const [value, setValue] = useQueryState('tab', 'profile')
  • setValue('billing') — updates URL to ?tab=billing with pushState
  • useQueryState('search', '', { replace: true }) — uses replaceState (no history entry)
  • Default values are omitted from the URL for hygiene

useFormDraft Hook

Wraps Inertia useForm with localStorage persistence, restore banner, and auto-cleanup:

// assets/js/hooks/useFormDraft.js
import { useForm } from '@inertiajs/react'
import { useState, useEffect, useRef } from 'react'

export function useFormDraft(draftKey, initialData, options = {}) {
  const { ttl = 24 * 60 * 60 * 1000 } = options // Default: 24 hours
  const form = useForm(initialData)
  const [hasDraft, setHasDraft] = useState(false)
  const [draftMeta, setDraftMeta] = useState(null)
  const debounceRef = useRef(null)

  // Check for existing draft on mount
  useEffect(() => {
    try {
      const raw = localStorage.getItem(draftKey)
      if (raw) {
        const draft = JSON.parse(raw)
        if (draft.expiresAt && draft.expiresAt < Date.now()) {
          localStorage.removeItem(draftKey)
          return
        }
        // Check if draft has meaningful content
        const hasContent = Object.values(draft.data).some((v) =>
          typeof v === 'string' ? v.trim() : Boolean(v)
        )
        if (hasContent) {
          setHasDraft(true)
          setDraftMeta(draft)
        }
      }
    } catch {
      localStorage.removeItem(draftKey)
    }
  }, [draftKey])

  // Debounced auto-save
  useEffect(() => {
    clearTimeout(debounceRef.current)
    debounceRef.current = setTimeout(() => {
      const hasContent = Object.values(form.data).some((v) =>
        typeof v === 'string' ? v.trim() : Boolean(v)
      )
      if (hasContent) {
        try {
          localStorage.setItem(
            draftKey,
            JSON.stringify({
              data: form.data,
              savedAt: Date.now(),
              expiresAt: Date.now() + ttl
            })
          )
        } catch {}
      }
    }, 500)
    return () => clearTimeout(debounceRef.current)
  }, [form.data, draftKey, ttl])

  function restoreDraft() {
    if (draftMeta?.data) {
      form.setData(draftMeta.data)
      setHasDraft(false)
    }
  }

  function discardDraft() {
    localStorage.removeItem(draftKey)
    setHasDraft(false)
  }

  function clearDraft() {
    localStorage.removeItem(draftKey)
  }

  return {
    form,
    hasDraft,
    draftSavedAt: draftMeta?.savedAt ? new Date(draftMeta.savedAt) : null,
    restoreDraft,
    discardDraft,
    clearDraft
  }
}

API:

const { form, hasDraft, draftSavedAt, restoreDraft, discardDraft, clearDraft } =
  useFormDraft('ASCENT_DRAFT_POST', { title: '', body: '' })

// Submit with auto-clear
form.post('/posts', { onSuccess: clearDraft })

Complete Page Example: Settings with Tabs

// assets/js/pages/settings/index.jsx
import { Head } from '@inertiajs/react'
import { useQueryState } from '~/hooks/useQueryState'
import AppLayout from '~/layouts/AppLayout'
import ProfileTab from './tabs/ProfileTab'
import SecurityTab from './tabs/SecurityTab'
import BillingTab from './tabs/BillingTab'
import TeamTab from './tabs/TeamTab'

const tabs = [
  { id: 'profile', label: 'Profile' },
  { id: 'security', label: 'Security' },
  { id: 'billing', label: 'Billing' },
  { id: 'team', label: 'Team' }
]

export default function SettingsPage() {
  const [activeTab, setActiveTab] = useQueryState('tab', 'profile')

  const TabComponent =
    {
      profile: ProfileTab,
      security: SecurityTab,
      billing: BillingTab,
      team: TeamTab
    }[activeTab] || ProfileTab

  return (
    <AppLayout>
      <Head title="Settings" />
      <div className="max-w-4xl mx-auto">
        <h1 className="text-2xl font-bold mb-6">Settings</h1>
        <nav className="flex gap-1 border-b mb-6">
          {tabs.map((tab) => (
            <button
              key={tab.id}
              onClick={() => setActiveTab(tab.id)}
              className={`px-4 py-2 -mb-px text-sm font-medium ${
                activeTab === tab.id
                  ? 'border-b-2 border-blue-500 text-blue-600'
                  : 'text-gray-500 hover:text-gray-700'
              }`}
            >
              {tab.label}
            </button>
          ))}
        </nav>
        <TabComponent />
      </div>
    </AppLayout>
  )
}

Complete Page Example: Filterable User List

// assets/js/pages/users/index.jsx
import { Head } from '@inertiajs/react'
import { useQueryState } from '~/hooks/useQueryState'
import { useMemo } from 'react'
import AppLayout from '~/layouts/AppLayout'

export default function UsersPage({ users }) {
  const [search, setSearch] = useQueryState('search', '', { replace: true })
  const [role, setRole] = useQueryState('role', '')
  const [sort, setSort] = useQueryState('sort', 'name')
  const [dir, setDir] = useQueryState('dir', 'asc')

  const filtered = useMemo(() => {
    let result = users

    if (search) {
      const q = search.toLowerCase()
      result = result.filter(
        (u) =>
          u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
      )
    }

    if (role) {
      result = result.filter((u) => u.role === role)
    }

    result = [...result].sort((a, b) => {
      const aVal = a[sort] || ''
      const bVal = b[sort] || ''
      const cmp = aVal.localeCompare(bVal)
      return dir === 'asc' ? cmp : -cmp
    })

    return result
  }, [users, search, role, sort, dir])

  const toggleSort = (field) => {
    if (sort === field) {
      setDir(dir === 'asc' ? 'desc' : 'asc')
    } else {
      setSort(field)
      setDir('asc')
    }
  }

  return (
    <AppLayout>
      <Head title="Users" />
      <div className="max-w-4xl mx-auto">
        <h1 className="text-2xl font-bold mb-6">Users</h1>
        <div className="flex gap-4 mb-4">
          <input
            type="text"
            value={search}
            onChange={(e) => setSearch(e.target.value)}
            placeholder="Search by name or email..."
            className="border rounded px-3 py-2 flex-1"
          />
          <select
            value={role}
            onChange={(e) => setRole(e.target.value)}
            className="border rounded px-3 py-2"
          >
            <option value="">All roles</option>
            <option value="owner">Owner</option>
            <option value="admin">Admin</option>
            <option value="member">Member</option>
          </select>
        </div>
        <table className="w-full">
          <thead>
            <tr className="text-left border-b">
              <th
                className="pb-2 cursor-pointer"
                onClick={() => toggleSort('name')}
              >
                Name {sort === 'name' && (dir === 'asc' ? '↑' : '↓')}
              </th>
              <th
                className="pb-2 cursor-pointer"
                onClick={() => toggleSort('email')}
              >
                Email {sort === 'email' && (dir === 'asc' ? '↑' : '↓')}
              </th>
              <th className="pb-2">Role</th>
            </tr>
          </thead>
          <tbody>
            {filtered.map((user) => (
              <tr key={user.id} className="border-b">
                <td className="py-2">{user.name}</td>
                <td className="py-2">{user.email}</td>
                <td className="py-2">{user.role}</td>
              </tr>
            ))}
          </tbody>
        </table>
        {filtered.length === 0 && (
          <p className="text-gray-500 text-center py-8">No users found.</p>
        )}
      </div>
    </AppLayout>
  )
}

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