Skip to content

Advanced Patterns

The navigation features covered so far handle most use cases, but sophisticated applications need more control. Timing modifiers let you throttle navigation during rapid user input. History control determines whether navigation adds to or replaces the browser history. Multiple navigation keys enable complex multi-region updates. This section covers these advanced patterns and shows how to combine them effectively.

Timing Modifiers

When users interact rapidly with your interface—typing in a search box, dragging a slider, clicking repeatedly—you don't want to trigger navigation on every single event. Timing modifiers control when navigation actually fires.

Debouncing Navigation

Debouncing delays navigation until the user stops interacting. Each new event resets the timer. This is perfect for search-as-you-type interfaces:

blade
<input
    type="search"
    data-bind="_search"
    data-on:input__debounce.300ms="@navigate({search: $_search, page: 1}, 'filters', {merge: true})"
    placeholder="Search contacts..."
/>

As the user types, navigation doesn't fire until 300ms after they stop typing. This prevents dozens of unnecessary requests while still feeling instant.

Syntax: __debounce.{duration}ms or __debounce.{duration}s

Examples:

  • __debounce.300ms - 300 milliseconds
  • __debounce.1s - 1 second
  • __debounce.500ms - Half a second

Throttling Navigation

Throttling limits navigation to fire at most once per time period, regardless of how many events occur. Unlike debouncing, throttling guarantees regular updates during continuous interaction:

blade
<input
    type="range"
    data-bind="_volume"
    data-on:input__throttle.500ms="@navigate({volume: $_volume}, 'slider', {merge: true})"
    min="0"
    max="100"
/>

As the user drags the slider, navigation fires at most once every 500ms. This provides regular feedback without overwhelming the server.

Syntax: __throttle.{duration}ms or __throttle.{duration}s

Throttling with leading edge (fire immediately on first event, then throttle):

blade
<button data-navigate__throttle.1s__leading="true">
    Click Me
</button>

Delayed Navigation

Simple delay adds a fixed pause before navigation executes:

blade
<a href="/modal" data-navigate__delay.500ms="true">
    Open Modal
</a>

The navigation fires 500ms after the click, giving time for animations or transitions to complete.

Syntax: __delay.{duration}ms or __delay.{duration}s

History Control

By default, navigation adds entries to the browser history using pushState. Users can use the back button to return to previous states. Sometimes you want different behavior.

Replace Instead of Push

Use __replace to replace the current history entry instead of adding a new one:

blade
<a href="/modal" data-navigate__replace="true">
    Open Modal
</a>

When the user clicks this link, the current page is replaced in history. Clicking back skips over this entry entirely, going to whatever page came before.

Use cases for replace:

  • Modal or drawer navigation within the same logical page
  • Tab switching where tabs aren't separate history entries
  • Temporary or ephemeral states that shouldn't clutter history
  • Replacing error pages with successful results

Programmatic History Control

Backend navigation can also control history behavior:

php
return hyper()->navigate(
    route('modal.show'),
    'modal',
    ['replace' => true]
);

This replaces the current history entry when navigating from the backend.

Combining Modifiers

Modifiers are stackable. Combine them to create sophisticated navigation behavior:

blade
<input
    type="search"
    placeholder="Search products..."
    data-bind="_search"
    data-navigate__merge__except.page__debounce.300ms__key.filters="true"
    data-on:input="@navigate({search: $_search, page: 1}, 'filters', {merge: true, except: ['page']})"
/>

This search input:

  1. Merges query parameters (__merge)
  2. Excludes the page parameter (__except.page)
  3. Debounces for 300ms (__debounce.300ms)
  4. Uses the filters navigation key (__key.filters)

The modifiers work together to create a smooth, efficient search experience.

Multiple Navigation Keys

Complex layouts often need different navigation contexts updating different page regions. Multiple navigation keys make this possible.

Multi-Region Layout

Consider a dashboard with a main content area, a sidebar, and a header:

blade
<div class="dashboard">
    <!-- Header navigation updates only the header -->
    <header data-navigate__key.header="true">
        <a href="/notifications">Notifications</a>
        <a href="/profile">Profile</a>
    </header>

    <!-- Sidebar navigation updates only the sidebar -->
    <aside data-navigate__key.sidebar="true">
        <a href="/recent">Recent Items</a>
        <a href="/favorites">Favorites</a>
    </aside>

    <!-- Main navigation updates the main content -->
    <main data-navigate__key.main="true">
        <a href="/dashboard">Dashboard</a>
        <a href="/reports">Reports</a>
    </main>
</div>

The backend controller responds to each key differently:

php
public function dashboard()
{
    $data = [
        'notifications' => $this->getNotifications(),
        'stats' => $this->getStats(),
        'recent' => $this->getRecentItems(),
    ];

    if (request()->isHyperNavigate('header')) {
        return hyper()->fragment('dashboard', 'header', $data);
    }

    if (request()->isHyperNavigate('sidebar')) {
        return hyper()->fragment('dashboard', 'sidebar', $data);
    }

    if (request()->isHyperNavigate('main')) {
        return hyper()->fragment('dashboard', 'main', $data);
    }

    // Direct visit: full page
    return view('dashboard', $data);
}

Each navigation key updates only its designated region, making the interface feel fast and responsive.

Detecting Multiple Keys

Check if the request matches any of several keys:

php
if (request()->isHyperNavigate(['pagination', 'filters', 'sort'])) {
    return hyper()->fragment('products.index', 'list', $data);
}

Or use the cleaner whenHyperNavigate() helper:

php
return hyper()
    ->whenHyperNavigate(['pagination', 'filters', 'sort'], function ($hyper) use ($data) {
        return $hyper->fragment('products.index', 'list', $data);
    }, function ($hyper) use ($data) {
        return $hyper->view('products.index', $data);
    });

Progressive Enhancement Patterns

Navigation should enhance the experience for JavaScript-enabled browsers while remaining functional without JavaScript. These patterns ensure graceful degradation.

Dual Response Pattern

Return different content based on the request type:

php
public function index()
{
    $contacts = Contact::paginate(10);

    return hyper()
        ->whenHyperNavigate('pagination', function ($hyper) use ($contacts) {
            // Pagination navigation: return just the list
            return $hyper->fragment('contacts.index', 'list', compact('contacts'));
        }, function ($hyper) use ($contacts) {
            // Direct visit or initial load: return full page
            return $hyper
                ->fragment('contacts.index', 'page', compact('contacts'))
                ->web(view('contacts.index', compact('contacts')));
        });
}

The web() method provides a fallback response for non-Hyper requests. This ensures:

  • Direct URL access works
  • Search engines can crawl the page
  • JavaScript-disabled browsers get full pages
  • Shared links work as expected

Conditional Enhancement

Wrap enhanced features in conditional checks:

blade
@ifhyper
    <!-- Enhanced navigation with live updates -->
    <div data-navigate__key.live="true">
        <!-- Real-time updating content -->
    </div>
@else
    <!-- Standard links for non-Hyper requests -->
    <div>
        <a href="/refresh">Refresh to see updates</a>
    </div>
@endifhyper

This pattern progressively enhances the experience while maintaining a functional baseline.

Frontend Navigation Actions with @navigate

The @navigate() action (introduced in Basics) becomes even more powerful when combined with timing modifiers and advanced options. This section covers sophisticated patterns for frontend-initiated navigation.

Conditional Navigation

Navigate to different destinations based on state:

blade
<div data-signals="{_paymentMethod: 'card', _termsAccepted: false}">
    <button data-on:click="
        $_termsAccepted
            ? @navigate({payment: $_paymentMethod}, 'checkout')
            : ($termsError = 'Please accept terms')
    ">
        Proceed to Checkout
    </button>
</div>

Chained Navigation Actions

Combine multiple actions in one expression:

blade
<button data-on:click="
    $loading = true;
    @navigate({status: 'active'}, 'filters', {merge: true});
    $loading = false
">
    Show Active Items
</button>

Array Parameters

Handle multi-select filters:

blade
<div data-signals="{_selectedTags: []}">
    <label>
        <input type="checkbox" data-bind="_selectedTags" value="php" />
        PHP
    </label>
    <label>
        <input type="checkbox" data-bind="_selectedTags" value="laravel" />
        Laravel
    </label>

    <button data-on:click="@navigate({tags: $_selectedTags, page: 1}, 'filters')">
        Apply Filters
    </button>
</div>

The tags parameter becomes ?tags=php&tags=laravel, which Laravel automatically converts to an array.

Update signals before navigating:

blade
<button data-on:click="
    $lastAction = 'filter-applied';
    $timestamp = Date.now();
    @navigate({category: 'electronics'}, 'filters', {merge: true})
">
    Apply Filter
</button>

Replacing History with @navigate

Use the replace option to avoid cluttering browser history:

blade
<!-- Modal or drawer navigation -->
<button data-on:click="@navigate('/user/settings', 'settings', {replace: true})">
    Settings
</button>

Back button skips over this navigation, going to the previous real page.

Form Submission with @navigate

Handle form submission explicitly:

blade
<form data-on:submit__prevent="
    @navigate({
        query: $_query,
        type: $_searchType,
        page: 1
    }, 'search', {merge: false})
">
    <input data-bind="_query" placeholder="Search..." />

    <select data-bind="_searchType">
        <option value="all">All</option>
        <option value="products">Products</option>
        <option value="posts">Posts</option>
    </select>

    <button type="submit">Search</button>
</form>

The __prevent modifier stops the form's default submission, letting @navigate() handle it with clean query parameters.

Real-World Pattern: Advanced Filter Panel

Here's a complete advanced filter implementation:

blade
<div data-signals="{
    _search: '{{ request()->search ?? '' }}',
    _category: '{{ request()->category ?? '' }}',
    _minPrice: {{ request()->minPrice ?? 0 }},
    _maxPrice: {{ request()->maxPrice ?? 1000 }},
    _tags: {{ json_encode(request()->tags ?? []) }},
    _sortBy: '{{ request()->sortBy ?? 'name' }}'
}">
    <!-- Search with debouncing -->
    <input
        type="search"
        data-bind="_search"
        placeholder="Search..."
        data-on:input__debounce.400ms="@navigate({
            search: $_search,
            category: $_category,
            minPrice: $_minPrice,
            maxPrice: $_maxPrice,
            tags: $_tags,
            sortBy: $_sortBy,
            page: 1
        }, 'filters')"
    />

    <!-- Category dropdown -->
    <select
        data-bind="_category"
        data-on:change="@navigate({
            search: $_search,
            category: $_category,
            minPrice: $_minPrice,
            maxPrice: $_maxPrice,
            tags: $_tags,
            sortBy: $_sortBy,
            page: 1
        }, 'filters')"
    >
        <option value="">All Categories</option>
        <option value="electronics">Electronics</option>
        <option value="books">Books</option>
    </select>

    <!-- Price range with throttling -->
    <div>
        <input
            type="range"
            data-bind="_minPrice"
            min="0"
            max="1000"
            data-on:input__throttle.500ms="@navigate({
                search: $_search,
                category: $_category,
                minPrice: $_minPrice,
                maxPrice: $_maxPrice,
                tags: $_tags,
                sortBy: $_sortBy,
                page: 1
            }, 'filters')"
        />
        <span data-text="'$' + $_minPrice"></span>
    </div>

    <!-- Clear all filters -->
    <button data-on:click="
        $_search = '';
        $_category = '';
        $_minPrice = 0;
        $_maxPrice = 1000;
        $_tags = [];
        $_sortBy = 'name';
        @navigate({page: 1}, 'clear')
    ">
        Clear Filters
    </button>
</div>

This implementation:

  • Debounces search input
  • Throttles price slider updates
  • Immediately responds to category changes
  • Clears all filters with one button
  • Always resets to page 1 when filters change
  • Maintains all filter state across navigations