Persistence
The data-persist attribute automatically syncs signal values with browser storage (localStorage or sessionStorage). This enables state that survives page reloads, browser restarts, and navigation—perfect for user preferences, form drafts, and UI state.
Basic Usage
data-persist (Hyper)
Add data-persist with a comma-separated list of signal names to persist:
<div data-signals="{theme: 'light', sidebarOpen: true}" data-persist="theme, sidebarOpen">
<button data-on:click="$theme = $theme === 'light' ? 'dark' : 'light'">
Toggle Theme
</button>
<button data-on:click="$sidebarOpen = !$sidebarOpen">
Toggle Sidebar
</button>
</div>When you change theme or sidebarOpen, the values are automatically saved to localStorage. On page reload, the persisted values are restored.
How it works:
- On page load,
data-persistchecks localStorage for stored values - If found, those values override the initial signal values
- Whenever a persisted signal changes, the new value is saved to localStorage
- Values are JSON serialized/deserialized automatically
Storage Key Format
By default, persisted signals use the _x_ prefix (same as Alpine.js for compatibility):
Signal name: theme
Storage key: _x_themeYou can customize this with modifiers (see below).
Modifiers
__session
Use sessionStorage instead of localStorage. Data clears when the browser tab closes:
<div data-signals="{tempData: ''}" data-persist__session="tempData">
<!-- tempData persists only for this browser session -->
<input data-bind="tempData" placeholder="Temporary input" />
</div>localStorage vs sessionStorage:
| Storage | Lifetime | Scope |
|---|---|---|
| localStorage | Permanent (until cleared) | All tabs on same origin |
| sessionStorage | Browser tab session | Single tab only |
__as
Use a custom storage key instead of the signal name:
<div data-signals="{count: 0}" data-persist__as.mycounter="count">
<!-- Stored as 'mycounter' instead of '_x_count' -->
<button data-on:click="$count++">Count: <span data-text="$count"></span></button>
</div>This is useful when:
- You need a specific key format for compatibility
- You want to share state with other code that reads localStorage
- You're migrating from another storage solution
Case Sensitivity
HTML attribute names are case-insensitive, so use lowercase in modifiers:
<!-- Use lowercase -->
<div data-persist__as.mykey="signal"> ✅
<!-- Avoid mixed case -->
<div data-persist__as.myKey="signal"> <!-- Becomes 'mykey' -->__prefix
Add a custom prefix to storage keys (for namespacing):
<div data-signals="{count: 0, name: ''}" data-persist__prefix.myapp="count, name">
<!-- Stored as '_x_myapp.count' and '_x_myapp.name' -->
</div>Useful for:
- Avoiding collisions with other apps on the same domain
- Grouping related storage keys
- Environment-specific storage (dev vs prod)
Supported Data Types
data-persist handles all JSON-serializable types:
<div data-signals="{
string: 'hello',
number: 42,
boolean: true,
array: [1, 2, 3],
object: {key: 'value'},
nullValue: null
}" data-persist="string, number, boolean, array, object, nullValue">
<!-- All types persist and restore correctly -->
</div>Non-Serializable Types
Functions, DOM elements, and circular references cannot be persisted. Only use plain data types.
Common Patterns
User Preferences
<div data-signals="{
theme: 'system',
fontSize: 'medium',
reducedMotion: false
}" data-persist="theme, fontSize, reducedMotion">
<div class="settings-panel">
<label>
Theme:
<select data-bind="theme">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
<label>
Font Size:
<select data-bind="fontSize">
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
</select>
</label>
<label>
<input type="checkbox" data-bind="reducedMotion" />
Reduce motion
</label>
</div>
</div>Form Draft Saving
<div data-signals="{
title: '',
content: '',
tags: []
}" data-persist="title, content, tags">
<form data-on:submit__prevent="@postx('/posts'); localStorage.removeItem('_x_title'); localStorage.removeItem('_x_content'); localStorage.removeItem('_x_tags')">
<input data-bind="title" placeholder="Post title" />
<textarea data-bind="content" rows="10" placeholder="Write your post..."></textarea>
<p class="text-sm text-gray-500">
Draft auto-saved to browser storage
</p>
<button type="submit">Publish</button>
</form>
</div>Sidebar State
<div data-signals="{sidebarCollapsed: false}" data-persist="sidebarCollapsed">
<button data-on:click="$sidebarCollapsed = !$sidebarCollapsed">
<span data-show="$sidebarCollapsed">Expand</span>
<span data-show="!$sidebarCollapsed">Collapse</span>
</button>
<aside data-class="{'w-64': !$sidebarCollapsed, 'w-16': $sidebarCollapsed}"
class="transition-all duration-200">
<nav data-show="!$sidebarCollapsed">
<!-- Full navigation -->
</nav>
<nav data-show="$sidebarCollapsed">
<!-- Icon-only navigation -->
</nav>
</aside>
</div>Table Column Preferences
<div data-signals="{
visibleColumns: ['name', 'email', 'status'],
sortBy: 'name',
sortDir: 'asc'
}" data-persist="visibleColumns, sortBy, sortDir">
<div class="column-toggles">
<label>
<input type="checkbox" value="name"
data-on:change="$visibleColumns = evt.target.checked
? [...$visibleColumns, 'name']
: $visibleColumns.filter(c => c !== 'name')" />
Name
</label>
<!-- More column toggles -->
</div>
<table>
<thead>
<tr>
<th data-show="$visibleColumns.includes('name')"
data-on:click="$sortBy = 'name'; $sortDir = $sortDir === 'asc' ? 'desc' : 'asc'">
Name
</th>
<!-- More headers -->
</tr>
</thead>
<!-- Table body -->
</table>
</div>Shopping Cart (Session Storage)
<div data-signals="{cartItems: []}" data-persist__session="cartItems">
<template data-for="item in $cartItems" data-for__key="id">
<div class="cart-item">
<span data-text="item.name"></span>
<span data-text="'$' + item.price"></span>
<button data-on:click="$cartItems = $cartItems.filter(i => i.id !== item.id)">
Remove
</button>
</div>
</template>
<p data-text="'Total: $' + $cartItems.reduce((sum, i) => sum + i.price, 0)"></p>
</div>Using __session for cart data means it clears when the user closes the tab, which may be desired behavior for temporary shopping sessions.
Multiple Apps on Same Domain
<!-- App 1 -->
<div data-signals="{count: 0}" data-persist__prefix.app1="count">
<!-- Stored as '_x_app1.count' -->
</div>
<!-- App 2 -->
<div data-signals="{count: 0}" data-persist__prefix.app2="count">
<!-- Stored as '_x_app2.count' -->
</div>Clearing Persisted Data
To clear persisted data programmatically:
<button data-on:click="
localStorage.removeItem('_x_theme');
localStorage.removeItem('_x_sidebarOpen');
location.reload();
">
Reset Preferences
</button>Or clear all Hyper-persisted data:
<button data-on:click="
Object.keys(localStorage)
.filter(k => k.startsWith('_x_'))
.forEach(k => localStorage.removeItem(k));
location.reload();
">
Clear All Saved Data
</button>Error Handling
data-persist handles storage errors gracefully:
- Storage unavailable (private browsing, disabled cookies): Signals work normally but don't persist
- Quota exceeded: Console warning, signals continue to work
- Invalid JSON (corrupted data): Falls back to initial signal value
Server-Side Considerations
Persisted state is client-side only. For important state that should sync with the server:
<div data-signals="{preferences: {}}"
data-persist="preferences"
data-on:change__debounce.1000ms="@patchx('/user/preferences')">
<!-- Changes persist locally AND sync to server after 1s of inactivity -->
</div>Or explicitly sync on navigation:
// Controller
public function loadPreferences()
{
$prefs = auth()->user()->preferences;
return hyper()->signals([
'preferences' => $prefs
]);
}
public function savePreferences()
{
auth()->user()->update([
'preferences' => signals('preferences')
]);
return hyper()->signals(['saved' => true]);
}Learn More
- Signals - Understanding reactive signals
- Display & Binding - Binding persisted data to UI
- Events - Triggering actions on persisted data changes
- Datastar Attributes - API reference
