Skip to content

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.

blade
<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>
php
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-for to 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.

blade
<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>
php
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 @get on change events
  • Show fields conditionally based on available data
  • Use data-for to populate <option> elements

Inline Editing ​

Click-to-edit pattern for updating values without leaving the page.

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

blade
<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>
php
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-show to 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.

blade
<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>
php
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 step signal 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.

blade
<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>
php
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 __debounce modifier 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.

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