State Management

Bidirectional state synchronization between Laravel and Alpine.js. Send state to the server, read it in your controllers, and push updates back to the frontend in real-time.

The State Flow

Gale synchronizes state between your frontend and backend in both directions:

Frontend → Backend

When you call $action(), Gale serializes your Alpine x-data state and sends it as JSON.

Access it via request()->state()

Backend → Frontend

Call gale()->state() to push updates back. Changes are streamed via SSE and merged into Alpine's reactive state.

Alpine's reactivity automatically updates the UI.

RFC 7386 JSON Merge Patch
State updates use RFC 7386 merge semantics. Only the keys you specify are updated—existing state is preserved.

Sending State to Server

Control which state properties are sent to the server when making requests.

The x-sync Directive

The x-sync directive gives you declarative control over which state properties get sent to the server. Add it to your component to enable state synchronization.

Default Behavior (v2.0+)
By default, Gale sends nothing to the server. You must use x-sync to enable state synchronization, or use include options per-request.
Syntax Behavior Use Case
x-sync Send all serializable state Sync everything from this component
x-sync="*" Send all serializable state (explicit) Same as empty directive
x-sync="['a','b']" Send only specified properties Whitelist specific keys
x-sync="a, b" Send only specified properties (shorthand) Cleaner syntax for simple lists
No directive Send nothing by default Use include option per-request
<!-- Sync all state -->
<div x-data="{ name: '', email: '', phone: '' }" x-sync>
    <button @click="$action('/save')">Save</button>
</div>

<!-- Sync only specific properties -->
<div x-data="{ name: '', email: '', _uiState: 'form' }"
     x-sync="name, email">
    <!-- Only name and email are sent -->
    <button @click="$action('/save')">Save</button>
</div>

Browser-Only State

Properties prefixed with an underscore (_) are never sent to the server, even with x-sync. Use this for UI state that doesn't need backend processing:

<div x-data="{
    name: '',              // Sent to server
    email: '',             // Sent to server
    _showDropdown: false,  // Browser only (underscore prefix)
    _editMode: false,      // Browser only
    _cachedResults: []     // Browser only
}" x-sync>
    <!-- Only name and email are sent -->
</div>
Common Browser-Only State
Use underscores for: dropdown visibility, modal states, hover effects, local caches, animation flags, and any UI-only toggles that the server doesn't need to know about.

Per-Request Control with include/exclude

For request-specific control, use the include or exclude options. These override x-sync for that specific request.

<!-- Include: Only send specific properties -->
<button @click="$action('/save', { include: ['name', 'email'] })">
    Save Contact
</button>

<!-- Exclude: Send everything except specified properties -->
<button @click="$action('/save', { exclude: ['password', 'tempData'] })">
    Save User
</button>

Reading State on Server

Access the frontend's Alpine state using the request()->state() macro.

Get All State

Call without arguments to get the entire state object:

// Frontend: x-data="{ count: 5, user: { name: 'John' } }"

$state = request()->state();
// Returns: ['count' => 5, 'user' => ['name' => 'John']]

Get a Single Value

Pass a key to get a specific value, with an optional default:

// Get a value
$count = request()->state('count');

// With a default if the key doesn't exist
$count = request()->state('count', 0);
$page = request()->state('page', 1);

Dot Notation for Nested Values

Access nested values using Laravel's familiar dot notation:

// Frontend: x-data="{ user: { profile: { name: 'John', email: null } } }"

$name = request()->state('user.profile.name');
// Returns: 'John'

$email = request()->state('user.profile.email', 'guest@example.com');
// Returns default since email is null
Tip
The state() method uses Laravel's data_get() helper under the hood, so you get the same behavior you're used to.

Updating State from Server

Use gale()->state() to push state updates to the frontend.

Single Value

return gale()->state('count', 42);

Batch Updates

Pass an associative array to update multiple keys at once:

return gale()->state([
    'count' => 42,
    'status' => 'complete',
    'loading' => false,
]);

Nested State Updates

Update nested properties using dot notation or nested arrays:

// Using dot notation (updates only the specified key)
return gale()
    ->state('user.profile.name', 'Jane')
    ->state('settings.theme', 'dark');

// Using nested arrays (RFC 7386 merge)
return gale()->state([
    'user' => [
        'profile' => ['name' => 'Jane']  // Other profile keys preserved
    ]
]);

RFC 7386 Merge Semantics

Understanding how state merges is crucial. Here are the key rules:

Current State Patch Sent Result Rule
{ a: 1 } { b: 2 } { a: 1, b: 2 } New keys added
{ a: 1 } { a: 2 } { a: 2 } Existing keys updated
{ a: 1, b: 2 } { a: null } { b: 2 } null deletes key
{ a: { b: 1 } } { a: { c: 2 } } { a: { b: 1, c: 2 } } Objects merge recursively
{ a: [1, 2] } { a: [3] } { a: [3] } Arrays replaced!
Arrays Are Replaced, Not Merged
This is the most common gotcha. Arrays are completely replaced. If you need to add to an array, read the current array first, modify it, then send the complete new array.
// Correct: Get current array, modify, send complete array
$items = request()->state('items', []);
$items[] = ['id' => uniqid(), 'title' => $title];

return gale()->state('items', $items);

Deleting State

Remove keys from frontend state using gale()->forget():

// Delete a single key
return gale()->forget('temporaryData');

// Delete multiple keys
return gale()->forget(['error', 'warning', 'tempValue']);

// Delete nested keys
return gale()->forget('form.errors');
How Deletion Works
Under the hood, forget() sends a null value. Per RFC 7386, null values remove keys from the target object.

Component State

Work with named components using x-component for targeted state updates and cross-component communication.

The x-component Directive

Register a component with a name to enable targeted state updates:

<!-- Register component with a name -->
<div x-data="{ items: [], total: 0 }"
     x-component="cart"
     x-sync>
    <!-- Cart contents -->
</div>

Updating Component State

Use gale()->componentState() to update a specific named component:

// Update the 'cart' component specifically
return gale()->componentState('cart', [
    'items' => $cartItems,
    'total' => $cartTotal,
]);

Including Component State in Requests

Use includeComponents to send state from other named components:

<!-- Include cart and shipping components in checkout -->
<button @click="$action('/checkout', {
    includeComponents: ['cart', 'shipping']
})">
    Place Order
</button>

<!-- Include only specific properties -->
<button @click="$action('/checkout', {
    includeComponents: {
        cart: ['items', 'total'],
        shipping: true
    }
})">
    Place Order
</button>

On the server, access component state via request()->state('_components.cart.items').

Connection State ($gale)

The $gale magic property provides real-time connection state for loading indicators, error handling, and retry feedback.

Property Type Description
$gale.loading boolean True if any request is in progress
$gale.activeCount number Number of active requests
$gale.retrying boolean True if a request is retrying after failure
$gale.retriesFailed boolean True if all retries exhausted
$gale.error string|null Most recent error message
$gale.errors array All accumulated error messages
$gale.clearErrors() function Clear all error messages
<div x-data="{ query: '' }" x-sync>
    <input type="text" x-model="query"
           @input.debounce.300ms="$action('/search')">

    <!-- Loading indicator -->
    <span x-show="$gale.loading">Searching...</span>

    <!-- Retry indicator -->
    <span x-show="$gale.retrying" class="text-amber-500">
        Reconnecting...
    </span>

    <!-- Error display -->
    <div x-show="$gale.error" class="text-red-500">
        <span x-text="$gale.error"></span>
        <button @click="$gale.clearErrors()">Dismiss</button>
    </div>
</div>

Per-Scope Loading ($fetching)

Use $fetching() to track loading state for specific actions or scopes, enabling granular loading indicators within a component.

<div x-data="{ name: '', email: '' }" x-sync>
    <!-- Save button with scoped loading -->
    <button @click="$action('/save', { scope: 'save' })"
            :disabled="$fetching('save')">
        <span x-show="!$fetching('save')">Save</span>
        <span x-show="$fetching('save')">Saving...</span>
    </button>

    <!-- Delete button with different scope -->
    <button @click="$action('/delete', { scope: 'delete' })"
            :disabled="$fetching('delete')">
        <span x-show="!$fetching('delete')">Delete</span>
        <span x-show="$fetching('delete')">Deleting...</span>
    </button>
</div>

Practical Examples

Live Search with Debounce

<!-- Frontend -->
<div x-data="{ query: '', results: [] }" x-sync="query">
    <input type="text"
           x-model="query"
           @input.debounce.300ms="$action('/search')"
           placeholder="Search...">

    <span x-show="$gale.loading">Searching...</span>

    <ul>
        <template x-for="result in results" :key="result.id">
            <li x-text="result.title"></li>
        </template>
    </ul>
</div>
// Backend
Route::post('/search', function () {
    $query = request()->state('query', '');

    if (strlen($query) < 2) {
        return gale()->state('results', []);
    }

    $results = Product::query()
        ->where('title', 'like', "%{$query}%")
        ->limit(10)
        ->get(['id', 'title']);

    return gale()->state('results', $results);
});

Shopping Cart with Cross-Component Updates

<!-- Cart header component -->
<div x-data="{ count: 0, total: 0 }"
     x-component="cart-header">
    Cart (<span x-text="count"></span>) - $<span x-text="total"></span>
</div>

<!-- Product listing -->
<div x-data="{ productId: 123 }" x-sync>
    <button @click="$action('/cart/add')">
        Add to Cart
    </button>
</div>
// Backend - Update both components
public function addToCart(Request $request)
{
    $productId = $request->state('productId');
    $cart = Cart::add($productId);

    return gale()
        ->componentState('cart-header', [
            'count' => $cart->count(),
            'total' => $cart->total(),
        ])
        ->toast('Added to cart!');
}

Quick Reference

Method / Directive Description
x-sync Enable state synchronization for a component
x-component="name" Register a named component
request()->state() Get all frontend state as array
request()->state('key', $default) Get specific key with optional default
gale()->state('key', $value) Update a single state key
gale()->state([...]) Update multiple state keys
gale()->forget('key') Delete state key(s)
gale()->componentState('name', [...]) Update a named component's state
$gale.loading Check if any request is active
$fetching('scope') Check if specific scope is loading

Best Practices

Keep State Minimal

Only include what's necessary in x-data. Large state objects increase request payload size.

Always Use Defaults

Provide defaults with request()->state('key', default) to handle missing keys gracefully.

Validate User Input

Never trust frontend state. Use Laravel's validation for any user-provided data.

Batch Related Updates

Group related state changes in a single state([]) call for cleaner code and fewer SSE events.

On this page