Skip to content

Durable UI

Klean UI is the canonical source-owned implementation of Durable UI for The Boring JavaScript Stack. Components should not merely look correct in a screenshot; useful state, navigation context, focus, and in-progress work should survive the real conditions of an application.

Vue, React, and Svelte preserve the same outcomes with framework-native source.

Installation

Add the complete resilience layer from any conventional Boring Stack application:

bash
npx klean-ui add durable-ui

Klean detects Vue, React, or Svelte and writes eight readable files to assets/js/components/ui/durable-ui: seven focused framework-native utilities and their small browser core. There is no Durable UI runtime dependency, provider, initializer, manifest, or setup questionnaire.

Two kinds of resilience

State resilience puts state in the right durable home so preferences survive, useful views are shareable, and work can recover.

Interaction resilience makes overlays dismiss predictably, restores focus, cancels stale work, rolls failed optimistic changes back, and keeps feedback perceivable through navigation.

Where state belongs

StateConventional homeExamples
Ephemeral interactionLocal component stateopen disclosure, active drag, transient hover
Browser-only preferenceVersioned browser storagedensity, dismissed coach mark, sidebar preference
Shareable navigation contextURL query or pathfilters, search, sort, selected tab, pagination
Recoverable local workExpiring draft storagelong form, multi-step progress
Authoritative or cross-device dataServeraccount settings, permissions, billing state

Sensitive or server-owned data does not belong in browser storage. A durable value also needs a lifecycle: namespace, schema version, expiry where appropriate, migration or invalidation, and a clear success path.

The Klean durability surface

Klean components, state utilities, and blocks cover:

  • SSR-safe, namespaced, versioned storage with cross-tab synchronization;
  • typed URL state with clean defaults and deliberate push/replace history;
  • expiring form drafts with restore, discard, dirty-state honesty, and clear-on-success behavior;
  • multi-step recovery with schema evolution and intentional navigation;
  • Escape, outside-click, and backdrop dismissal with listener cleanup and scroll locking;
  • focus entry, containment, return, and recovery after destructive list changes;
  • optimistic toggles and list changes only when rollback is safe;
  • window and container scroll restoration plus asynchronous hash navigation;
  • accessible toast queues with deduplication, hover pause, manual dismissal, and Inertia flash integration;
  • debounced client or server search with URL synchronization and stale-request cancellation.

The APIs

NeedVue / ReactSvelteDurable default
Browser preferenceuseStoredStatecreateStoredStatenamespaced, versioned, SSR-safe, cross-tab
Shareable navigation stateuseQueryStatecreateQueryStateinferred type, clean default, Back/Forward
Recoverable formuseFormDraftcreateFormDraftexplicit restore/discard, expiry, native unload guard
Multi-step workuseWizardDraftcreateWizardDraftstep recovery, merged schema defaults, clear success
Return positionuseScrollRestorationcreateScrollRestorationsession storage, window/container/hash restoration
Reversible async changeuseOptimisticcreateOptimisticinflight guard, server resync, rollback, error
Remote or local searchuseSearchcreateSearchdebounce, minimum query, abort, stale-result guard

The names follow each framework. The behavior does not drift. Install the bundle once, then import only the focused source a flow needs.

A recoverable form

The draft key is the only required durability decision. The application still owns its fields, submission, messages, and server state.

NewInvoice.vue
<script setup>
import { reactive, ref } from 'vue'
import { useFormDraft } from '@/components/ui/durable-ui/useFormDraft.js'

const form = reactive({ customer: '', note: '' })
const saved = ref(false)

const { hasDraft, restore, discard } = useFormDraft('invoice:new', form, {
  clearWhen: () => saved.value
})

async function submit() {
  await saveInvoice(form)
  saved.value = true
}
</script>

<template>
  <aside v-if="hasDraft" aria-label="Recovered draft">
    <p>A saved invoice draft is available.</p>
    <button type="button" @click="restore">Restore</button>
    <button type="button" @click="discard">Discard</button>
  </aside>

  <form @submit.prevent="submit">
    <!-- ordinary application fields -->
  </form>
</template>

The saved draft expires after 24 hours by default. It is offered for deliberate restore or discard, ignores empty work, and is cleared after the application confirms success. A dirty form uses the browser's native unsaved-change guard; clear() updates the clean baseline after a successful submission.

URL state and server visits

useQueryState('page', 1) and createQueryState('page', 1) infer a number from the default, remove page=1 from the URL, and follow Back and Forward navigation. Use { history: 'replace' } for rapid filters or search and the default push history for meaningful view changes.

For server-owned collections, compose URL state with the framework's Inertia router or use Data Table, whose query helper already preserves scroll, restores focus, cleans defaults, and makes partial visits. Klean does not hide a server visit inside every state value.

useOptimistic / createOptimistic updates visible state immediately only when a rejected operation can restore the previous value. It blocks accidental duplicate work, accepts the server's confirmed value, exposes the failure, and calls the application's error handler so a Toast or inline Alert can make rollback perceivable.

useSearch / createSearch debounces by default, passes an AbortSignal to the application search function, and prevents an older result from replacing a newer query. Compose its query with URL state when the search should survive reload or be shareable. Keep transient picker searches out of the URL.

Scroll restoration

Scroll state uses sessionStorage, not localStorage: returning within one browser tab is useful, while reopening an old tab days later should not jump to stale coordinates. Window and scroll-container targets are supported. Hash destinations take priority and are retried while asynchronously rendered content arrives.

Durable does not mean global

Zero configuration does not mean every pattern runs globally without being invoked. A toast queue still needs a host in the application layout. A controlled Dialog still needs open state. A draft needs an intentional key and lifecycle.

Those are semantic integration points. Klean supplies one opinionated source implementation and safe conventions without requiring a provider hierarchy, registry manifest, state library, or parallel configuration language.

Accessibility and recovery

Durability is observable:

  • Back and Forward restore the view a URL represents.
  • Reloading does not destroy explicitly recoverable work.
  • Closing a layered surface returns focus to a sensible place.
  • Removing a focused row moves focus to a nearby stable target.
  • A rejected optimistic update restores both data and announcement state.
  • A stale search response cannot replace a newer result.
  • Navigation does not make a success or error message disappear before it can be perceived.

Each Klean component page documents the durability behaviors that apply. Button has a small contract; Dialog, Toast, Combobox, forms, and state utilities will carry more.

All open source projects are released under the MIT License.