Actions
Actions are the verbs of reactive programming in Datastar and Hyper. They're expressions that perform operations—sending HTTP requests, manipulating signals, or executing client-side logic. Understanding actions is fundamental to building reactive Hyper applications, as they're how you connect user interactions to server-side processing and state updates.
What Are Actions? (Datastar)
In Datastar, an action is a special function prefixed with @ that executes when an event occurs. You've already seen actions in the counter app tutorial:
<button data-on:click="@postx('/counter/increment')">+</button>When this button is clicked, the @postx action (a Hyper enhancement) sends a POST request to /counter/increment.
The @ prefix: Datastar uses the @ symbol to identify actions for security. Instead of allowing arbitrary JavaScript execution in attributes, the @ prefix marks specific, controlled functions that run in a sandboxed environment. This prevents malicious code injection while giving you powerful reactive capabilities.
Key insight: Actions are the bridge between user interactions and your application's logic—whether that logic runs on the client (like @toggleAll) or the server (like @get).
Categories of Actions
Datastar provides two main categories of actions:
Signal/State Actions (Datastar)
These actions manipulate signals directly in the browser without making server requests:
@peek()- Access signals without subscribing to changes@setAll()- Set values for multiple signals at once@toggleAll()- Toggle boolean values across signals
Backend Actions (Datastar)
These actions make HTTP requests to your server:
@get()- Send GET request@post()- Send POST request@put()- Send PUT request@patch()- Send PATCH request@delete()- Send DELETE request
We'll explore each category in depth.
Signal/State Actions
Signal actions manipulate your frontend state directly without server communication. They're perfect for UI interactions that don't need backend processing.
@peek() - Access Without Subscribing
The @peek action lets you access a signal's value without creating a reactive subscription to it. This is useful when you want to read a value once without re-running the expression when that signal changes.
<div data-signals="{count: 0, multiplier: 2}">
<!-- This updates when count changes, but not when multiplier changes -->
<p data-text="$count * @peek(() => $multiplier)"></p>
<button data-on:click="$count++">Increment Count</button>
<button data-on:click="$multiplier++">Change Multiplier (no update)</button>
</div>How it works:
@peek(() => $multiplier)reads the multiplier value once- The expression only re-evaluates when
$countchanges - Changing
$multiplierdoesn't trigger a re-render - Use this for values you want to read but not react to
When to use @peek:
- Reading configuration values that rarely change
- Accessing signals in computations where you don't want reactive dependencies
- Performance optimization when you know a signal won't change during a user interaction
@setAll() - Bulk Signal Updates
The @setAll action sets the same value across multiple signals. You can target all signals or filter by pattern.
<div data-signals="{input1: '', input2: '', input3: '', keepMe: 'important'}">
<input data-bind="input1" />
<input data-bind="input2" />
<input data-bind="input3" />
<!-- Clear all signals starting with 'input' -->
<button data-on:click="@setAll('', {include: /^input/})">
Clear Inputs
</button>
<!-- Set all signals to a value -->
<button data-on:click="@setAll('reset')">
Reset All
</button>
</div>Filtering signals:
<!-- Include only signals matching pattern -->
@setAll(value, {include: /^form_/})
<!-- Include matching, exclude specific -->
@setAll(value, {include: /^form_/, exclude: /^form_keep/})When to use @setAll:
- Clearing form inputs after submission
- Resetting state across multiple related signals
- Applying default values to groups of signals
- Bulk state updates without server round-trip
@toggleAll() - Bulk Boolean Toggles
The @toggleAll action flips boolean values across multiple signals. Like @setAll, it supports pattern filtering.
<div data-signals="{
feature1: false,
feature2: false,
feature3: false,
config_readonly: true
}">
<label>
<input type="checkbox" data-bind="feature1" />
Feature 1
</label>
<label>
<input type="checkbox" data-bind="feature2" />
Feature 2
</label>
<label>
<input type="checkbox" data-bind="feature3" />
Feature 3
</label>
<!-- Toggle all features, but not config -->
<button data-on:click="@toggleAll({include: /^feature/})">
Toggle All Features
</button>
</div>When to use @toggleAll:
- "Select all" / "Deselect all" functionality
- Toggling groups of feature flags
- Mass enable/disable of options
- Inverting checkbox states
Backend Actions
Backend actions are the workhorses of Hyper applications. They make HTTP requests to your Laravel backend, automatically sending signals and processing responses. This is where Datastar's reactivity meets Laravel's server-side power.
Understanding Backend Actions (Datastar)
Datastar provides five HTTP actions corresponding to REST verbs:
<!-- GET request -->
<button data-on:click="@get('/users')">Load Users</button>
<!-- POST request -->
<button data-on:click="@post('/users')">Create User</button>
<!-- PUT request -->
<button data-on:click="@put('/users/1')">Update User</button>
<!-- PATCH request -->
<button data-on:click="@patch('/users/1')">Patch User</button>
<!-- DELETE request -->
<button data-on:click="@delete('/users/1')">Delete User</button>These are standard Datastar actions that work with any backend framework.
What happens when a backend action executes:
- Event triggers - The action runs (e.g., button click)
- Signals gathered - Datastar automatically collects all non-local signals
- Request sent - HTTP request to your URL with signals in the request body
- Headers added -
Datastar-Request: trueheader included automatically - Server processes - Your controller receives the request
- Response streamed - Server-Sent Events (SSE) stream back to browser
- Updates applied - Datastar processes SSE events, updating signals and DOM
Key insight: You don't manually send signals—Datastar does this automatically with every backend action. Your controller always has access to the complete frontend state.
The CSRF Challenge in Laravel
Laravel protects all state-changing requests (POST, PUT, PATCH, DELETE) with CSRF tokens to prevent cross-site request forgery attacks. This is critical security, but it creates a challenge with Datastar's standard actions.
The problem: Datastar's @post, @put, @patch, and @delete don't include Laravel's CSRF token by default, so Laravel rejects the request with a 419 error.
Manual solution (verbose):
<button data-on:click="@post('/users', {
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content
}
})">
Create User
</button>This works, but it's verbose and easy to forget. You'd need to add this configuration to every mutating action in your application.
Hyper's CSRF-Protected Actions
This is where Hyper's Laravel-specific enhancements shine. Hyper provides CSRF-protected versions of Datastar's mutating backend actions:
<!-- Hyper's CSRF-protected actions -->
<button data-on:click="@postx('/users')">Create User</button>
<button data-on:click="@putx('/users/1')">Update User</button>
<button data-on:click="@patchx('/users/1')">Patch User</button>
<button data-on:click="@deletex('/users/1')">Delete User</button>The x suffix indicates these are Hyper's enhanced versions. They work identically to Datastar's actions but automatically include the CSRF token from the <meta name="csrf-token"> tag that @hyper creates.
How it works:
- The
@hyperdirective creates:<meta name="csrf-token" content="your-token"> - Hyper's
@postx,@putx,@patchx,@deletexread this token automatically - The token is included in the
X-CSRF-TOKENheader with every request - Laravel validates the token and processes the request
When to use each:
- Use
@getfor fetching data (no CSRF needed for GET) - Use
@postx,@putx,@patchx,@deletexfor all mutating operations - Only use
@post,@put,@patch,@deletewhen you specifically need to bypass CSRF (rare cases, like public API endpoints)
The pattern is simple: default to the x versions for safety. CSRF protection should be the norm, not the exception.
@navigate() - Client-Side Navigation (Hyper Extension)
The @navigate action enables client-side navigation without full page reloads. This is a Hyper extension that creates SPA-like experiences while keeping your server-side routing.
<!-- Basic navigation -->
<a href="/dashboard" data-on:click__prevent="@navigate('/dashboard')">
Dashboard
</a>
<!-- Navigation with key for targeted updates -->
<a href="/sidebar" data-on:click__prevent="@navigate('/sidebar', 'sidebar')">
Update Sidebar
</a>
<!-- Navigation with query parameters -->
<button data-on:click="@navigate('/products?category=electronics')">
Electronics
</button>Navigation with options:
<!-- Merge with existing query parameters -->
<button data-on:click="@navigate('/products?page=2', 'main', {merge: true})">
Next Page (keep filters)
</button>
<!-- Only preserve specific parameters -->
<button data-on:click="@navigate('/products?page=1', 'main', {only: ['search', 'category']})">
Reset Page (keep search and category)
</button>
<!-- Preserve all except specific parameters -->
<button data-on:click="@navigate('/products', 'main', {except: ['page']})">
Reset Pagination
</button>
<!-- Replace instead of push history -->
<button data-on:click="@navigate('/products', 'main', {replace: true})">
Replace Current State
</button>JSON query parameter navigation:
<div data-signals="{search: '', category: ''}">
<!-- Navigate using signals as query parameters -->
<button data-on:click="@navigate({search: $search, category: $category}, 'filters')">
Apply Filters
</button>
<!-- Clear all query parameters -->
<button data-on:click="@navigate({})">
Clear Filters
</button>
</div>How navigation works:
@navigateis called with a URL and optional key- Hyper's global
hyperNavigate()function intercepts the navigation - Request sent to server with
Datastar-Navigateheader and the navigation key - Controller checks
request()->isHyperNavigate('key')to determine response - Server returns appropriate content (full page or partial fragment)
- Browser URL updates without page reload
- DOM patches applied to update the interface
We'll cover navigation in depth in the Navigation section. For now, understand that @navigate is available as a Hyper-specific backend action for client-side routing.
Action Anatomy: Complete Request Lifecycle
Let's understand what happens when a backend action executes by examining a complete example:
<div data-signals="{name: 'John', email: 'john@example.com', _clientOnly: 'stays-local'}">
<input data-bind="name" />
<input data-bind="email" />
<button data-on:click="@postx('/contacts')">Save</button>
</div>When the button is clicked:
- Event triggers - The
data-on:clickevent fires - Action executes -
@postx('/contacts')runs - Signals gathered - Datastar collects all non-local signals:
{name: 'John', email: 'john@example.com'}- Note:
_clientOnlyis not sent because it starts with_
- Note:
- Request sent - POST request to
/contactswith:- Body:
{name: 'John', email: 'john@example.com'} - Headers:
X-CSRF-TOKEN: <token>,Datastar-Request: true
- Body:
- Laravel processes - Your controller receives the request
- Response streamed - Server-Sent Events (SSE) stream back to browser
- UI updates - Datastar applies signal updates and DOM patches automatically
The controller side:
public function store()
{
// Signals are automatically available via Hyper's helper
$name = signals('name');
$email = signals('email');
// Or get all signals at once
$data = signals()->all(); // ['name' => 'John', 'email' => 'john@example.com']
// Create the contact
Contact::create($data);
// Return updated signals
return hyper()->signals([
'name' => '',
'email' => ''
]);
}The beauty of this system is that your controller always has access to the complete frontend state through the signals() helper, just like you access request data through request().
Actions with URL Parameters
Actions accept dynamic URLs built from JavaScript expressions:
<div data-signals="{userId: 42, action: 'approve'}">
<!-- Static URL -->
<button data-on:click="@get('/users/42')">Load User</button>
<!-- Dynamic URL using signals -->
<button data-on:click="@get('/users/' + $userId)">Load User</button>
<!-- With query parameters -->
<button data-on:click="@get('/users?id=' + $userId + '&action=' + $action)">
Load with Action
</button>
<!-- Using template literals (if supported) -->
<button data-on:click="@get(`/users/${$userId}/posts`)">
Load Posts
</button>
</div>With Laravel route helpers:
You can combine Laravel's route helpers with actions for centralized route management:
<button data-on:click="@postx('{{ route('users.store') }}')">
Create User
</button>
<button data-on:click="@deletex('{{ route('users.destroy', compact('user')) }}')">
Delete User
</button>This keeps your route definitions in routes/web.php while still enabling reactive updates.
Actions with Form Submissions
A common pattern is triggering backend actions when forms are submitted:
<form data-signals="{name: '', email: '', errors: []}"
data-on:submit__prevent="@postx('/contacts')">
<input data-bind="name" placeholder="Name" />
<div data-error="name"></div>
<input data-bind="email" placeholder="Email" />
<div data-error="email"></div>
<button type="submit">Save Contact</button>
</form>Understanding the modifier:
data-on:submit__prevent- The__preventmodifier callsevent.preventDefault()- This stops the browser's default form submission behavior (full page reload)
- The action runs instead, sending signals to your Laravel controller
- The form submission becomes reactive
Without __prevent, the browser would perform a traditional form submission with a full page reload.
Event Modifiers (Datastar)
Datastar provides several modifiers that change how events are handled. These are standard Datastar features that work with all actions:
Prevent Default
Calls event.preventDefault() before executing the action:
<!-- Prevent default form submission -->
<form data-on:submit__prevent="@postx('/save')">
<!-- Prevent default link navigation -->
<a href="/page" data-on:click__prevent="@get('/page')">Link</a>Stop Propagation
Calls event.stopPropagation() to prevent event bubbling:
<div data-on:click="alert('outer')">
<button data-on:click__stop="alert('inner')">
Click me (only shows 'inner', doesn't bubble to div)
</button>
</div>Once
Executes the action only the first time the event fires, then removes the listener:
<button data-on:click__once="@postx('/init')">
Initialize (works only once)
</button>After the first click, this button no longer responds to clicks.
Debouncing
Debouncing delays action execution until the user stops triggering the event for a specified duration. This is crucial for search inputs and prevents overwhelming your server.
<!-- Wait 300ms after user stops typing -->
<input data-bind="search"
data-on:input__debounce.300ms="@get('/search?q=' + $search)" />How debouncing works:
- User types "h" → timer starts (300ms countdown)
- User types "e" within 300ms → timer resets (300ms countdown restarts)
- User types "l" within 300ms → timer resets
- User types "l" within 300ms → timer resets
- User types "o" within 300ms → timer resets
- User stops typing → after 300ms of inactivity, action fires
Only one request is sent after the user finishes typing "hello", instead of five requests (one per keystroke).
Throttling
Throttling limits how often an action can execute, regardless of how many times the event fires:
<!-- Maximum once every 1 second -->
<button data-on:click__throttle.1s="@postx('/track')">
Track Click
</button>Even if the user clicks rapidly, the action only fires once per second.
Debounce vs Throttle:
- Debounce - Waits for quiet period after last event (use for search inputs)
- Throttle - Limits frequency of execution (use for scroll events, rapid clicks)
Combining Modifiers
You can chain multiple modifiers together:
<!-- Prevent default AND debounce -->
<form data-on:submit__prevent__debounce.500ms="@postx('/save')">
<input data-bind="draft" />
<button type="submit">Save Draft</button>
</form>Modifiers execute in the order they're specified.
Actions in Expressions
Actions aren't limited to event handlers. You can use them anywhere expressions are evaluated:
Combined with other expressions:
<!-- Update signal then call action -->
<button data-on:click="$loading = true; @postx('/process')">
Process
</button>
<!-- Conditional action execution -->
<button data-on:click="$confirmed ? @deletex('/item') : alert('Please confirm')">
Delete
</button>
<!-- Chained actions (sequential) -->
<button data-on:click="@postx('/step1').then(() => @postx('/step2'))">
Run Sequence
</button>In data-on: events:*
<button data-on:click="@postx('/save')">Save</button>
<input data-on:input="@get('/search')">
<div data-on-intersect="@get('/lazy-load')">Load when visible</div>Backend actions return promises, so you can chain them with .then(), .catch(), and .finally() if needed.
The @fileUrl Action (Hyper Extension)
While most actions make HTTP requests or manipulate signals, Hyper provides a special action for file handling: @fileUrl(). This action converts base64-encoded file signals into valid URLs for image previews.
<div data-signals="{profilePicture: null}">
<!-- File input automatically encodes to base64 -->
<input type="file" data-bind="profilePicture" accept="image/*" />
<!-- Convert base64 to data URL for preview -->
<img data-attr:src="@fileUrl($profilePicture)" alt="Preview" />
</div>What @fileUrl() does:
- If the signal contains a base64-encoded file array (from
data-bindon file input), it converts it to a data URL:data:image/png;base64,... - If the signal contains a file path from the server (like
/storage/avatar.jpg), it returns the path as-is - If the signal is empty or null, it returns an empty string
With fallback:
<img data-attr:src="@fileUrl($profilePicture, {fallback: '/images/default-avatar.png'})"
alt="Profile Picture" />With custom MIME type:
<img data-attr:src="@fileUrl($document, {defaultMime: 'image/jpeg'})"
alt="Document Preview" />This is particularly useful for showing image previews before upload, or displaying existing images from the server. We'll cover file uploads in detail in Forms: File Uploads.
The @dispatch Action (Hyper Extension)
Hyper provides a Livewire-style event dispatch system for component communication using native browser CustomEvent API. The @dispatch action enables decoupled, event-driven patterns perfect for coordinating independent components.
Basic example:
<div data-signals="{notifications: [] }">
<button data-on:click="@dispatch('notification', {message: 'Saved!', type: 'success'})">
Save
</button>
<div id="notifications" data-on:notification__window="
$notifications.unshift({
message: event.detail.message,
type: event.detail.type
})
">
<template data-for="notification in $notifications">
<div data-text="notification.message"></div>
</template>
</div>
</div>How it works:
@dispatchcreates a native browserCustomEvent- Event data is passed in
event.detail - Any element can listen using
data-on:<event-name>__windowfor global events - Events can be global (window) or targeted to specific elements
Global dispatch (default):
<!-- Dispatch to window -->
<div data-signals="{notifications: [] }">
<button data-on:click="@dispatch('notification', {message: 'Saved!', type: 'success'})">
Save
</button>
<div id="notifications" data-on:notification__window="
$notifications.unshift({
message: event.detail.message,
type: event.detail.type
})
">
<template data-for="notification in $notifications">
<div data-text="notification.message"></div>
</template>
</div>
</div>Targeted dispatch:
<!-- Dispatch only to specific selector -->
<button data-on:click="@dispatch('refresh', {}, {selector: '#dashboard'})">
Refresh Dashboard
</button>
<!-- Only this element receives the event -->
<div id="dashboard" data-on:refresh="@get('/dashboard/stats')">
Dashboard content
</div>Custom event options:
@dispatch('event-name', {data: 'value'}, {
selector: '#target', // Target specific elements
bubbles: true, // Event bubbles (default: true)
cancelable: true, // Can be canceled (default: true)
composed: true // Crosses shadow DOM (default: true)
})The dispatch system is covered comprehensively in the Events guide.
Loading States
Backend actions are asynchronous, so there's often a brief moment between clicking a button and receiving a response. You should provide loading feedback to users.
Manual loading state:
<div data-signals="{loading: false}">
<button data-on:click="$loading = true; @postx('/process')"
data-attr:disabled="$loading">
<span data-show="!$loading">Process</span>
<span data-show="$loading">Processing...</span>
</button>
</div>How this works:
- User clicks button
$loading = trueexecutes immediately (synchronous)- Button becomes disabled via
data-attr:disabled - Text changes to "Processing..."
- Action sends request (asynchronous)
- Server responds with
hyper()->signals(['loading' => false]) - Button re-enables, text changes back to "Process"
Automatic indicator (Datastar):
Datastar provides a built-in data-indicator attribute that automatically manages loading state:
<div data-signals="{}">
<button
data-on:click="@postx('/save')"
data-indicator="saving">
Save
</button>
<div data-show="$saving">
<span>Saving...</span>
</div>
</div>The data-indicator attribute creates a $saving signal that's automatically set to true during any backend action within that element, and false when complete. No manual state management needed.
Error Handling
Backend actions can fail for various reasons—network errors, validation failures, server errors. You need to handle these gracefully.
Network errors:
<div data-signals="{error: ''}">
<button data-on:click="@postx('/save').catch(err => $error = 'Network error')">
Save
</button>
<div data-show="$error" style="color: red;" data-text="$error"></div>
</div>Validation errors (automatic with Hyper):
When you use signals()->validate() in your controller, validation errors are automatically handled:
<form data-signals="{email: '', errors: []}"
data-on:submit__prevent="@postx('/subscribe')">
<input data-bind="email" type="email" />
<div data-error="email"></div>
<button type="submit">Subscribe</button>
</form>When signals()->validate() fails, Hyper automatically returns an errors signal in Laravel's error bag format, and Hyper's data-error="email" attribute displays it.
Server errors (500, etc.):
For server errors, you can catch them in your controller and return error signals:
public function process()
{
try {
// Your logic that might fail
$result = SomeService::process(signals('data'));
return hyper()->signals(['success' => true, 'result' => $result]);
} catch (\Exception $e) {
return hyper()->signals([
'error' => 'Processing failed: ' . $e->getMessage()
]);
}
}Common Patterns
Search as You Type
Debounced search is one of the most common reactive patterns:
<div data-signals="{query: '', results: []}">
<input data-bind="query"
placeholder="Search..."
data-on:input__debounce.300ms="@get('/search?q=' + $query)" />
<div data-show="$results.length > 0">
<template data-for="result in $results">
<div data-text="result.name"></div>
</template>
</div>
<div data-show="$query && $results.length === 0">
No results found
</div>
</div>Controller:
public function search()
{
$query = request('q');
$results = Product::where('name', 'like', "%{$query}%")
->limit(10)
->get(['id', 'name']);
return hyper()->signals(['results' => $results]);
}Inline Editing
Toggle between view and edit modes with actions:
<div data-signals="{editing: false, name: 'John Doe'}">
<!-- View mode -->
<div data-show="!$editing">
<span data-text="$name"></span>
<button data-on:click="$editing = true">Edit</button>
</div>
<!-- Edit mode -->
<div data-show="$editing">
<input data-bind="name" />
<button data-on:click="@putx('/users/1'); $editing = false">Save</button>
<button data-on:click="$editing = false">Cancel</button>
</div>
</div>Confirmation Before Destructive Actions
Require confirmation before deleting or performing irreversible operations:
<div data-signals="{confirmDelete: false}">
<!-- Initial state -->
<button data-show="!$confirmDelete"
data-on:click="$confirmDelete = true">
Delete
</button>
<!-- Confirmation state -->
<div data-show="$confirmDelete">
Are you sure?
<button data-on:click="@deletex('/items/1')">Yes, delete</button>
<button data-on:click="$confirmDelete = false">Cancel</button>
</div>
</div>Optimistic Updates
Update the UI immediately for instant feedback, then let the server confirm:
<div data-signals="{count: 0}">
<button data-on:click="$count = $count + 1; @postx('/increment')">
Increment: <span data-text="$count"></span>
</button>
</div>The count increases instantly when clicked (optimistic update). The server response confirms the new value or corrects it if there was an error.
Sequential Actions
Perform actions in sequence using promise chaining:
<button data-on:click="@postx('/step1').then(() => @postx('/step2'))">
Run Two-Step Process
</button>Bulk Operations with Signal Actions
Use signal actions for client-side bulk operations:
<div data-signals="{item1: false, item2: false, item3: false}">
<label><input type="checkbox" data-bind="item1" /> Item 1</label>
<label><input type="checkbox" data-bind="item2" /> Item 2</label>
<label><input type="checkbox" data-bind="item3" /> Item 3</label>
<!-- Toggle all items -->
<button data-on:click="@toggleAll({include: /^item/})">
Toggle All
</button>
<!-- Clear all selections -->
<button data-on:click="@setAll(false, {include: /^item/})">
Clear All
</button>
</div>What's Next?
Now that you understand all the actions available in Datastar and Hyper, explore related topics:
- Responses: Learn how to build sophisticated server responses with the
hyper()builder - Validation: Deep dive into validating signals and displaying errors with
data-error - Request Cycle: Understand the complete flow from action execution to UI update
- Navigation: Explore the
@navigateaction in depth for client-side routing - Forms: Building Forms: Apply backend actions to real-world form patterns

