Patterns
This page covers common form patterns you'll encounter when building applications with Hyper. Each pattern demonstrates how to combine Datastar's reactive features with Hyper's Laravel integration to solve real-world problems.
Dynamic Form Fields
Allow users to add and remove form fields dynamically, such as multiple phone numbers or email addresses.
<div @signals(['phone_numbers' => [''], 'errors' => []])>
<label class="block font-semibold mb-2">Phone Numbers</label>
<template data-for="phone, index in $phone_numbers" data-for__key="index">
<div class="flex items-center gap-2 mb-2">
<input type="tel"
data-bind="phone"
placeholder="Enter phone number"
class="flex-1 border rounded px-3 py-2" />
<!-- Show remove button if more than one -->
<button type="button"
data-show="$phone_numbers.length > 1"
data-on:click="$phone_numbers.splice(index, 1)"
class="bg-red-500 text-white px-3 py-2 rounded hover:bg-red-600">
Remove
</button>
<!-- Show add button only on last item and if less than max -->
<button type="button"
data-show="index === $phone_numbers.length - 1 && $phone_numbers.length < 3"
data-on:click="$phone_numbers.push('')"
class="bg-blue-500 text-white px-3 py-2 rounded hover:bg-blue-600">
Add
</button>
</div>
<div data-error="`phone_numbers.${index}`" class="text-red-500 text-sm mb-2"></div>
</template>
<div data-error="phone_numbers" class="text-red-500 text-sm"></div>
<button type="button"
data-on:click="@postx('/contacts')"
class="mt-4 bg-green-600 text-white px-4 py-2 rounded">
Save Contact
</button>
</div>public function store()
{
$validated = signals()->validate([
'phone_numbers' => 'required|array|min:1|max:3',
'phone_numbers.*' => 'required|string|max:20',
]);
// Process phone numbers...
return hyper()->signals([
'phone_numbers' => [''],
'errors' => []
]);
}Key techniques:
- Use
data-forto loop through the array - Bind to array indices:
phone_numbers[${index}] - Show/hide buttons based on array length
- Validate both array and individual elements
Dependent Fields
Fields that depend on selections in other fields, like country → states → cities cascades.
<form @signals([
'country' => '',
'state' => '',
'states' => [],
'cities' => [],
'errors' => []
])>
<!-- Country Selection -->
<div>
<label>Country</label>
<select data-bind="country"
data-on:change="$state = ''; $cities = []; @get('/api/states?country=' + $country)">
<option value="">Select a country</option>
<option value="us">United States</option>
<option value="ca">Canada</option>
<option value="mx">Mexico</option>
</select>
<div data-error="country" class="text-red-500"></div>
</div>
<!-- State Selection (shown when states are available) -->
<div data-show="$states.length > 0">
<label>State</label>
<select data-bind="state"
data-on:change="$cities = []; @get('/api/cities?country=' + $country + '&state=' + $state)">
<option value="">Select a state</option>
<template data-for="state in $states">
<option data-attr:value="state.code" data-text="state.name"></option>
</template>
</select>
<div data-error="state" class="text-red-500"></div>
</div>
<!-- City Selection (shown when cities are available) -->
<div data-show="$cities.length > 0">
<label>City</label>
<select data-bind="city">
<option value="">Select a city</option>
<template data-for="city in $cities">
<option data-attr:value="city.id" data-text="city.name"></option>
</template>
</select>
<div data-error="city" class="text-red-500"></div>
</div>
<button type="button" data-on:click="@postx('/locations')">
Save Location
</button>
</form>public function getStates(Request $request)
{
$states = State::where('country', $request->country)->get();
return hyper()->signals(['states' => $states]);
}
public function getCities(Request $request)
{
$cities = City::where('country', $request->country)
->where('state', $request->state)
->get();
return hyper()->signals(['cities' => $cities]);
}Key techniques:
- Clear dependent fields when parent changes
- Load options via
@geton change events - Show fields conditionally based on available data
- Use
data-forto populate<option>elements
Inline Editing
Click-to-edit pattern for updating values without leaving the page.
<div @signals([
'name' => $contact->name,
'email' => $contact->email,
'_editing' => false,
'_originalName' => $contact->name,
'_originalEmail' => $contact->email,
'errors' => []
])>
<!-- Display Mode -->
<div data-show="!$_editing" class="p-4 border rounded hover:bg-gray-50">
<h3 data-text="$name" class="font-semibold text-lg"></h3>
<p data-text="$email" class="text-gray-600"></p>
<button type="button"
data-on:click="$_editing = true"
class="mt-2 text-blue-600 hover:underline">
Edit
</button>
</div>
<!-- Edit Mode -->
<div data-show="$_editing" class="p-4 border rounded">
<div class="mb-2">
<label class="block text-sm font-medium">Name</label>
<input type="text"
data-bind="name"
class="w-full border rounded px-3 py-2" />
<div data-error="name" class="text-red-500 text-sm"></div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium">Email</label>
<input type="email"
data-bind="email"
class="w-full border rounded px-3 py-2" />
<div data-error="email" class="text-red-500 text-sm"></div>
</div>
<div class="flex gap-2">
<button type="button"
data-on:click="@putx('/contacts/{{ $contact->id }}')"
class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700">
Save
</button>
<button type="button"
data-on:click="$name = $_originalName; $email = $_originalEmail; $_editing = false; $errors = {}"
class="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600">
Cancel
</button>
</div>
</div>
</div>public function update(Contact $contact)
{
$validated = signals()->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:contacts,email,' . $contact->id,
]);
$contact->update($validated);
return hyper()->signals([
'_editing' => false,
'_originalName' => $validated['name'],
'_originalEmail' => $validated['email'],
'errors' => []
]);
}Key techniques:
- Use local signal (
_editing) to toggle modes - Store original values for cancel functionality
- Show different UI based on editing state
- Reset to original values on cancel
Conditional Form Sections
Show or hide form sections based on user selections.
<form @signals([
'user_type' => 'individual',
'name' => '',
'company_name' => '',
'tax_id' => '',
'errors' => []
])>
<!-- User Type Selection -->
<div class="mb-4">
<label class="block font-semibold mb-2">User Type</label>
<label class="inline-flex items-center mr-4">
<input type="radio"
data-bind="user_type"
value="individual"
class="mr-2" />
Individual
</label>
<label class="inline-flex items-center">
<input type="radio"
data-bind="user_type"
value="business"
class="mr-2" />
Business
</label>
</div>
<!-- Common Fields -->
<div class="mb-4">
<label>Name</label>
<input type="text" data-bind="name" class="border rounded px-3 py-2 w-full" />
<div data-error="name" class="text-red-500"></div>
</div>
<!-- Business-Only Fields -->
<div data-show="$user_type === 'business'" class="space-y-4">
<div>
<label>Company Name</label>
<input type="text" data-bind="company_name" class="border rounded px-3 py-2 w-full" />
<div data-error="company_name" class="text-red-500"></div>
</div>
<div>
<label>Tax ID</label>
<input type="text" data-bind="tax_id" class="border rounded px-3 py-2 w-full" />
<div data-error="tax_id" class="text-red-500"></div>
</div>
</div>
<button type="button"
data-on:click="@postx('/users')"
class="mt-4 bg-blue-600 text-white px-4 py-2 rounded">
Create Account
</button>
</form>public function store()
{
$rules = [
'user_type' => 'required|in:individual,business',
'name' => 'required|string|max:255',
];
if (signals('user_type') === 'business') {
$rules['company_name'] = 'required|string|max:255';
$rules['tax_id'] = 'required|string|max:50';
}
$validated = signals()->validate($rules);
// Create user...
return hyper()->signals([
'user_type' => 'individual',
'name' => '',
'company_name' => '',
'tax_id' => '',
'errors' => []
]);
}Key techniques:
- Use
data-showto conditionally display sections - Apply conditional validation rules based on selections
- Clear hidden field values when section is hidden
Multi-Step Wizard
Guide users through a multi-step form with progress indicators.
<div @signals([
'step' => 1,
'name' => '',
'email' => '',
'password' => '',
'company' => '',
'errors' => []
])
data-computed:totalSteps="3"
data-computed:progress="($step / $totalSteps) * 100">
<!-- Progress Bar -->
<div class="mb-6">
<div class="flex justify-between text-sm mb-2">
<span>Step <span data-text="$step"></span> of <span data-text="$totalSteps"></span></span>
<span data-text="Math.round($progress) + '%'"></span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div data-attr:style="'width: ' + $progress + '%'"
class="bg-blue-600 h-2 rounded-full transition-all"></div>
</div>
</div>
<!-- Step 1: Personal Information -->
<div data-show="$step === 1">
<h2 class="text-xl font-bold mb-4">Personal Information</h2>
<div class="space-y-4">
<div>
<label>Name</label>
<input type="text" data-bind="name" class="border rounded px-3 py-2 w-full" />
<div data-error="name" class="text-red-500"></div>
</div>
<div>
<label>Email</label>
<input type="email" data-bind="email" class="border rounded px-3 py-2 w-full" />
<div data-error="email" class="text-red-500"></div>
</div>
</div>
<button type="button"
data-on:click="@postx('/register/validate-step-1')"
class="mt-4 bg-blue-600 text-white px-4 py-2 rounded">
Next
</button>
</div>
<!-- Step 2: Security -->
<div data-show="$step === 2">
<h2 class="text-xl font-bold mb-4">Set Your Password</h2>
<div class="space-y-4">
<div>
<label>Password</label>
<input type="password" data-bind="password" class="border rounded px-3 py-2 w-full" />
<div data-error="password" class="text-red-500"></div>
</div>
</div>
<div class="flex gap-2 mt-4">
<button type="button"
data-on:click="$step = 1"
class="bg-gray-500 text-white px-4 py-2 rounded">
Back
</button>
<button type="button"
data-on:click="@postx('/register/validate-step-2')"
class="bg-blue-600 text-white px-4 py-2 rounded">
Next
</button>
</div>
</div>
<!-- Step 3: Company Details -->
<div data-show="$step === 3">
<h2 class="text-xl font-bold mb-4">Company Details</h2>
<div class="space-y-4">
<div>
<label>Company Name (Optional)</label>
<input type="text" data-bind="company" class="border rounded px-3 py-2 w-full" />
<div data-error="company" class="text-red-500"></div>
</div>
</div>
<div class="flex gap-2 mt-4">
<button type="button"
data-on:click="$step = 2"
class="bg-gray-500 text-white px-4 py-2 rounded">
Back
</button>
<button type="button"
data-on:click="@postx('/register')"
class="bg-green-600 text-white px-4 py-2 rounded">
Complete Registration
</button>
</div>
</div>
</div>public function validateStep1()
{
signals()->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
]);
return hyper()->signals(['step' => 2, 'errors' => []]);
}
public function validateStep2()
{
signals()->validate([
'password' => 'required|min:8',
]);
return hyper()->signals(['step' => 3, 'errors' => []]);
}
public function register()
{
$validated = signals()->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => 'required|min:8',
'company' => 'nullable|string|max:255',
]);
User::create($validated);
return hyper()->signals([
'step' => 1,
'name' => '',
'email' => '',
'password' => '',
'company' => '',
'errors' => []
]);
}Key techniques:
- Use
stepsignal to track current step - Computed signals for progress calculation
- Validate each step separately before proceeding
- Allow navigation back to previous steps
Auto-Save
Automatically save form data as users type.
<div @signals([
'title' => $draft->title ?? '',
'content' => $draft->content ?? '',
'_saving' => false,
'_lastSaved' => null,
'errors' => []
])>
<div class="mb-2 text-sm text-gray-600">
<span data-show="$_saving">Saving...</span>
<span data-show="!$_saving && $_lastSaved"
data-text="'Last saved at ' + $_lastSaved"></span>
</div>
<div class="mb-4">
<label>Title</label>
<input type="text"
data-bind="title"
data-on:input__debounce.2s="$_saving = true; @patchx('/drafts/{{ $draft->id ?? 'create' }}')"
class="border rounded px-3 py-2 w-full" />
<div data-error="title" class="text-red-500"></div>
</div>
<div class="mb-4">
<label>Content</label>
<textarea data-bind="content"
data-on:input__debounce.2s="$_saving = true; @patchx('/drafts/{{ $draft->id ?? 'create' }}')"
rows="10"
class="border rounded px-3 py-2 w-full"></textarea>
<div data-error="content" class="text-red-500"></div>
</div>
</div>public function autosave(Draft $draft)
{
$validated = signals()->validate([
'title' => 'nullable|string|max:255',
'content' => 'nullable|string',
]);
$draft->update($validated);
return hyper()->signals([
'_saving' => false,
'_lastSaved' => now()->format('g:i A'),
'errors' => []
]);
}Key techniques:
- Use
__debouncemodifier to wait for typing to stop - Show saving indicator using local signal
- Display last saved timestamp
- Use PATCH request for updates
Search Forms
Live search with debouncing.
<div @signals([
'search' => '',
'results' => [],
'_searching' => false
])>
<div class="relative">
<input type="text"
data-bind="search"
data-on:input__debounce.300ms="$_searching = true; @get('/search?q=' + encodeURIComponent($search))"
placeholder="Search..."
class="border rounded px-3 py-2 w-full" />
<div data-show="$_searching" class="absolute right-3 top-3">
<svg class="animate-spin h-5 w-5 text-gray-500"><!-- Spinner icon --></svg>
</div>
</div>
<!-- Results -->
<div data-show="$results.length > 0" class="mt-4 border rounded">
<template data-for="result in $results">
<a data-attr:href="result.url"
class="block p-3 hover:bg-gray-50 border-b">
<h3 data-text="result.title" class="font-semibold"></h3>
<p data-text="result.excerpt" class="text-sm text-gray-600"></p>
</a>
</template>
</div>
<!-- No Results -->
<div data-show="$search.length > 0 && $results.length === 0 && !$_searching"
class="mt-4 text-center text-gray-500">
No results found
</div>
</div>public function search(Request $request)
{
$query = $request->get('q', '');
if (empty($query)) {
return hyper()->signals(['results' => [], '_searching' => false]);
}
$results = Post::where('title', 'like', "%{$query}%")
->orWhere('content', 'like', "%{$query}%")
->limit(10)
->get(['id', 'title', 'excerpt', 'slug'])
->map(function ($post) {
return [
'title' => $post->title,
'excerpt' => $post->excerpt,
'url' => route('posts.show', $post->slug),
];
});
return hyper()->signals([
'results' => $results,
'_searching' => false
]);
}Key techniques:
- Debounce search requests to reduce server load
- Show loading indicator while searching
- Display results reactively
- Handle empty results state

