Optimistic updates and useHttp in Inertia 3 with Vue: a like button and a live search

Build two everyday features with Inertia 3's new tools: a like button that updates instantly and rolls back on failure, and a debounced live search with useHttp. Laravel 13 back end, typed Vue 3 front end, tests included.

For your AI agent

Copies a prompt. Paste it into the AI agent you already have open.

Read https://laravel-vuejs.space/inertia-3-optimistic-updates-usehttp.md
Build two everyday features with Inertia 3's new tools: a like button that updates instantly and rolls back on failure, and a debounced live search with useHttp. Laravel 13 back end, typed Vue 3 front end, tests included.
Do not mutate pageProps. Build new objects ({ ...p }) as above.
Show the diff. Do not commit.

Next: Inertia 3.9: pause polling in background tabs and detect back-button visits

On this page · 10 sections
  1. What we are building
  2. The back end
  3. Part 1: an optimistic like button
  4. What happens on a click
  5. Four rules that make it reliable
  6. Stay on the page when it fails
  7. Part 2: a live search with useHttp
  8. Five things worth knowing
  9. Bonus: optimistic updates on a JSON endpoint
  10. Which one should you use?
  11. Test the server side
  12. Recap
  13. FAQ
  14. Next steps
  15. Sources

A like button that takes 300 ms to react feels broken, even when nothing is wrong. Until now you had two ways out: juggle local state by hand, or add a data library. Inertia 3 ships both fixes built in.

TL;DR

  • router.optimistic(fn) changes page props before the request and rolls back on any failure (500, 422, interrupted visit).
  • useHttp gives plain JSON requests useForm-style state: reactive data, processing, Laravel validation errors, typed responses.
  • Return the whole changed prop (the merge is shallow), call cancel() before a new search request, and catch the rejected promise.

What you'll learn

  • Optimistic updates with router.optimistic() and automatic rollback
  • Debounced JSON requests with useHttp
  • When to pick useHttp().optimistic() instead
  • How to test the endpoints with Pest

Who it's for: Laravel + Vue developers on Inertia 3

Tested with: Laravel 13.35 · inertia-laravel 3.5 · @inertiajs/vue3 3.9 · Vue 3.5 · TypeScript · Pest

What we are building#

One page with two features:

  • A like button that fills instantly, then confirms or rolls back.
  • A live search that queries a JSON endpoint as you type, without page visits or history entries.

Every snippet comes from a working Laravel 13 app that we tested in the browser, with a slow server and with a failing one. Still on Inertia 2? Start with our upgrade checklist.

The back end#

Posts can be liked by users, so we need a pivot table:

database/migrations/xxxx_create_post_likes_table.phpphp
Schema::create('post_likes', function (Blueprint $table) {
    $table->foreignId('post_id')->constrained()->cascadeOnDelete();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->timestamps();
    $table->primary(['post_id', 'user_id']);
});
app/Models/Post.phpphp
public function likes(): BelongsToMany
{
    return $this->belongsToMany(User::class, 'post_likes')->withTimestamps();
}

The index page sends each post with its like count and whether the current user liked it:

app/Http/Controllers/PostController.phpphp
public function index(Request $request): Response
{
    return Inertia::render('posts/Index', [
        'posts' => Post::query()
            ->withCount('likes')
            ->withExists(['likes as liked' => fn ($q) => $q->whereKey($request->user()->id)])
            ->latest()
            ->limit(20)
            ->get(['id', 'title', 'excerpt']),
    ]);
}

Liking is an ordinary Inertia endpoint: change the data, then back(). Inertia follows the redirect and sends fresh props.

app/Http/Controllers/PostLikeController.phpphp
public function store(Request $request, Post $post): RedirectResponse
{
    $post->likes()->syncWithoutDetaching([$request->user()->id]);

    return back();
}

public function destroy(Request $request, Post $post): RedirectResponse
{
    $post->likes()->detach($request->user()->id);

    return back();
}

Search is a JSON endpoint. It is not a page, so it returns response()->json():

app/Http/Controllers/PostSearchController.phpphp
public function __invoke(Request $request): JsonResponse
{
    $validated = $request->validate([
        'q' => ['required', 'string', 'min:2', 'max:100'],
    ]);

    return response()->json(
        Post::query()
            ->where('title', 'like', '%'.$validated['q'].'%')
            ->orderBy('title')
            ->limit(8)
            ->get(['id', 'title'])
    );
}
routes/web.phpphp
Route::middleware(['auth', 'verified'])->group(function () {
    Route::get('posts', [PostController::class, 'index'])->name('posts.index');
    Route::get('posts/search', PostSearchController::class)->name('posts.search');
    Route::post('posts/{post}/like', [PostLikeController::class, 'store'])->name('posts.like');
    Route::delete('posts/{post}/like', [PostLikeController::class, 'destroy'])->name('posts.unlike');
});

Tip: CSRF just works

All routes live in web.php, so they share the session. Inertia’s HTTP client sends the XSRF-TOKEN cookie as a header, which means CSRF protection works for useHttp POSTs with no extra setup.

Part 1: an optimistic like button#

Put a shared type for the page props in its own file:

resources/js/types/post.tstypescript
export interface Post {
    id: number
    title: string
    excerpt: string
    likes_count: number
    liked: boolean
}

Now the button. router.optimistic() takes the current page props and returns the props that should change. Inertia applies them before sending the request:

resources/js/components/posts/LikeButton.vuevue
<script setup lang="ts">
import { router } from '@inertiajs/vue3'
import type { Post } from '@/types/post'

const props = defineProps<{ post: Post }>()

function toggleLike() {
    const liked = props.post.liked

    router
        .optimistic<{ posts: Post[] }>((pageProps) => ({
            posts: pageProps.posts.map((p) =>
                p.id === props.post.id
                    ? { ...p, liked: !liked, likes_count: p.likes_count + (liked ? -1 : 1) }
                    : p,
            ),
        }))
        [liked ? 'delete' : 'post'](`/posts/${props.post.id}/like`, {
            preserveScroll: true,
            only: ['posts'],
        })
}
</script>

<template>
    <button type="button" :aria-pressed="post.liked" @click="toggleLike">
        <span aria-hidden="true">{{ post.liked ? '♥' : '♡' }}</span>
        {{ post.likes_count }}
        <span class="sr-only">{{ post.liked ? 'Unlike' : 'Like' }}</span>
    </button>
</template>

What happens on a click#

According to the optimistic updates docs, and what we saw in the browser:

  1. Inertia compares the returned props with the current ones and keeps a snapshot of the keys that changed (here only posts).
  2. It merges the new value, and Vue re-renders straight away. With the server slowed down on purpose, the heart filled and the count went from 0 to 1 while the request was still pending.
  3. On success, the server’s props replace the optimistic ones. only: ['posts'] keeps that response small.
  4. On failure, Inertia restores the snapshot. When we made the endpoint return a 500, the heart went back to its previous state. A 422 rolls back too, and the validation errors are kept.

Four rules that make it reliable#

  • Return only what changes. The return value is merged shallowly, so return the whole posts array, not one post inside it.
  • Do not mutate pageProps. Build new objects ({ ...p }) as above.
  • Let the server have the last word. The optimistic value is a guess. The real count, including likes from other users, arrives with the response.
  • Fast double clicks are handled. Inertia tracks which props each optimistic update touched, and it does not let a response overwrite a prop until the last update that changed it has finished.

Gotcha: shallow merge

Returning { posts: [changedPost] } would replace the whole list with one post. Map over pageProps.posts and return the full array.

Stay on the page when it fails#

To keep the user on the page when the request fails, handle the error yourself. Inertia rolls back first, then you show a message:

resources/js/components/posts/LikeButton.vuetypescript
router.optimistic(/* … */).post(url, {
    preserveScroll: true,
    onHttpException: () => {
        toast.error('Could not save your like. Please try again.')
        return false // stay on the page, no error screen
    },
})

Tip: forms too

The same optimistic() method also works on useForm, and the <Form> component has an :optimistic prop. Use those for things like adding a todo to a list before the server confirms it.

Part 2: a live search with useHttp#

Search results should not be page props. You don’t want a history entry or a full props round trip on every keystroke. That is the job of useHttp: it makes a plain HTTP request and gives you useForm-style state.

resources/js/components/posts/PostSearch.vuevue
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3'
import { ref } from 'vue'

interface Result {
    id: number
    title: string
}

const search = useHttp<{ q: string }, Result[]>({ q: '' })
const results = ref<Result[]>([])
let timer: ReturnType<typeof setTimeout> | undefined

function onInput() {
    clearTimeout(timer)
    search.cancel() // drop the request for the previous keystroke

    if (search.q.trim().length < 2) {
        results.value = []
        return
    }

    timer = setTimeout(async () => {
        try {
            results.value = (await search.get('/posts/search')) ?? []
        } catch {
            // Cancelled by a newer keystroke, or a network/server error:
            // keep the previous results on screen.
        }
    }, 250)
}
</script>

<template>
    <label for="post-search" class="sr-only">Search posts</label>
    <input id="post-search" v-model="search.q" type="search" placeholder="Search posts…" @input="onInput" />

    <p v-if="search.errors.q">{{ search.errors.q }}</p>
    <p v-if="search.processing">Searching…</p>
    <ul v-else-if="results.length">
        <li v-for="r in results" :key="r.id">{{ r.title }}</li>
    </ul>
</template>

Five things worth knowing#

From the HTTP requests docs and from reading the source:

  • The data is the form. useHttp({ q: '' }) makes search.q reactive, so v-model works directly. For a GET, the data goes into the query string, so the request becomes /posts/search?q=et.
  • The generics type both sides. useHttp<{ q: string }, Result[]> types the form data and the resolved response.
  • The promise rejects on errors. HTTP errors (except 422), network errors and cancellations reject the promise, so wrap await in try/catch, or use callbacks. A 422 does not reject. It resolves with undefined and fills search.errors, which is why the template can show search.errors.q straight from Laravel’s validator.
  • Starting a new request does not cancel the old one. Call search.cancel() yourself, as above, or an old slow response can arrive after a newer one.
  • Need more callbacks? onSuccess(data, response), onHttpException, onNetworkError, onCancel and onFinish are all available, and each instance tracks its own processing state.

Gotcha: race conditions

Without search.cancel(), a slow response for “op” can land after the fast one for “optim” and overwrite the right results. One line prevents it.

In our browser test, typing “et” fired one request (the debounce absorbed the first keystroke) and showed 8 results.

Bonus: optimistic updates on a JSON endpoint#

useHttp has optimistic() too. It works on the hook’s own data instead of page props, which fits widgets that talk to a JSON API:

app/Http/Controllers/PostLikeController.phpphp
// route: POST posts/{post}/like/toggle
public function toggle(Request $request, Post $post): JsonResponse
{
    $post->likes()->toggle([$request->user()->id]);

    return response()->json([
        'liked' => $post->likes()->whereKey($request->user()->id)->exists(),
        'likes_count' => $post->likes()->count(),
    ]);
}
resources/js/components/posts/LikeToggle.vuevue
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3'
import type { Post } from '@/types/post'

type LikeState = { liked: boolean; likes_count: number }

const props = defineProps<{ post: Post }>()
const like = useHttp<LikeState, LikeState>({
    liked: props.post.liked,
    likes_count: props.post.likes_count,
})

async function toggle() {
    try {
        const server = await like
            .optimistic((data) => ({
                liked: !data.liked,
                likes_count: data.likes_count + (data.liked ? -1 : 1),
            }))
            .post(`/posts/${props.post.id}/like/toggle`)

        Object.assign(like, server) // trust the server's numbers
    } catch {
        // already rolled back for us
    }
}
</script>

<template>
    <button type="button" :aria-pressed="like.liked" @click="toggle">
        <span aria-hidden="true">{{ like.liked ? '♥' : '♡' }}</span> {{ like.likes_count }}
    </button>
</template>

We tested this one with the same slow and failing server. It updated instantly, confirmed with {"liked":true,"likes_count":1}, and rolled back cleanly on a 500.

Which one should you use?#

router.optimistic()useHttp().optimistic()
Works onPage propsThe hook’s own data
Server responds withA redirect (back()), then fresh propsJSON
Best forLists and counters other components read from propsSelf-contained widgets talking to a JSON endpoint
Source of truthThe page propsThe widget

If the change is part of the page, use router.optimistic(), so the page props stay the single source of truth. If the widget owns its data and talks to a JSON endpoint, use useHttp().optimistic().

Test the server side#

The optimistic part runs in the browser, but the endpoints still need tests. Here are three Pest tests that pass against the code above:

tests/Feature/PostLikesTest.phpphp
use App\Models\Post;
use App\Models\User;

it('likes and unlikes a post', function () {
    $user = User::factory()->create();
    $post = Post::factory()->create();

    $this->actingAs($user)->from('/posts')
        ->post("/posts/{$post->id}/like")
        ->assertRedirect('/posts');

    $this->actingAs($user)->get('/posts')
        ->assertInertia(fn ($page) => $page
            ->component('posts/Index')
            ->where('posts.0.liked', true)
            ->where('posts.0.likes_count', 1)
        );

    $this->actingAs($user)->delete("/posts/{$post->id}/like");

    expect($post->likes()->count())->toBe(0);
});

it('searches posts by title as JSON', function () {
    Post::factory()->create(['title' => 'Optimistic updates in Inertia 3']);
    Post::factory()->create(['title' => 'Queues in Laravel']);

    $this->actingAs(User::factory()->create())
        ->getJson('/posts/search?q=inertia')
        ->assertOk()
        ->assertJsonCount(1)
        ->assertJsonPath('0.title', 'Optimistic updates in Inertia 3');
});

it('validates the search term', function () {
    $this->actingAs(User::factory()->create())
        ->getJson('/posts/search?q=a')
        ->assertUnprocessable()
        ->assertJsonValidationErrors('q');
});

Run the tests

bash
php artisan test --filter=PostLikesTest

For the browser side, a Playwright or Pest browser test with a delayed route (page.route() plus a short wait) is the easiest way to see the optimistic state and the rollback, which is how we checked the examples in this post.

Recap#

  • router.optimistic(fn).post(…) updates page props right away and rolls back on any failure, including 422s and interrupted visits.
  • useHttp gives JSON requests the same comfort as useForm: reactive data, processing, Laravel validation errors and typed responses.
  • Return partial props (whole keys), call cancel() before a new search request, and catch the rejected promise.
  • Page data? router.optimistic(). Widget-owned JSON? useHttp().optimistic().

FAQ#

Does an optimistic update roll back on a validation error?

Yes. A 422 rolls back the optimistic props too, and the validation errors are kept so you can show them.

Why does my useHttp search show stale results?

Starting a new request does not cancel the previous one. Call search.cancel() before each new request so a slow old response cannot overwrite a newer one.

Does useHttp reject the promise on a 422?

No. A 422 resolves with undefined and fills errors. Other HTTP errors, network errors and cancellations reject, so wrap await in try/catch.

Do I need extra CSRF setup for useHttp POST requests?

No, as long as the routes are in web.php. Inertia sends the XSRF-TOKEN cookie back as the X-XSRF-TOKEN header.

Can I use optimistic updates with useForm or the Form component?

Yes. useForm has the same optimistic() method, and <Form> takes an :optimistic prop.

Next steps#

Sources#

Found this useful?

Ask questions, share what you built.

Join thousands of Laravel and Vue developers in our Facebook group, or get new posts in your Telegram feed. Sharing this article helps too.

Keep reading

Newsletter

Get the best of Laravel & Vue, once a month.

One short email with new guides, release notes worth reading and hand-picked community links. No spam, unsubscribe any time.