Skip to content

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:

blade
<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:

  1. On page load, data-persist checks localStorage for stored values
  2. If found, those values override the initial signal values
  3. Whenever a persisted signal changes, the new value is saved to localStorage
  4. 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_theme

You can customize this with modifiers (see below).

Modifiers

__session

Use sessionStorage instead of localStorage. Data clears when the browser tab closes:

blade
<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:

StorageLifetimeScope
localStoragePermanent (until cleared)All tabs on same origin
sessionStorageBrowser tab sessionSingle tab only

__as

Use a custom storage key instead of the signal name:

blade
<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:

blade
<!-- 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):

blade
<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:

blade
<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

blade
<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

blade
<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>
blade
<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

blade
<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)

blade
<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

blade
<!-- 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:

blade
<button data-on:click="
    localStorage.removeItem('_x_theme');
    localStorage.removeItem('_x_sidebarOpen');
    location.reload();
">
    Reset Preferences
</button>

Or clear all Hyper-persisted data:

blade
<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:

blade
<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:

php
// 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