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