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).useHttpgives plain JSON requestsuseForm-style state: reactive data,processing, Laravel validationerrors, typed responses.- Return the whole changed prop (the merge is shallow), call
cancel()before a new search request, and catch the rejected promise.
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:
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']);
});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:
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.
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():
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'])
);
}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:
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:
<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:
- Inertia compares the returned props with the current ones and keeps a snapshot of the keys that changed (here only
posts). - 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.
- On success, the server’s props replace the optimistic ones.
only: ['posts']keeps that response small. - 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
postsarray, 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:
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.
<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: '' })makessearch.qreactive, sov-modelworks directly. For aGET, 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
awaitintry/catch, or use callbacks. A 422 does not reject. It resolves withundefinedand fillssearch.errors, which is why the template can showsearch.errors.qstraight 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,onCancelandonFinishare all available, and each instance tracks its ownprocessingstate.
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:
// 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(),
]);
}<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 on | Page props | The hook’s own data |
| Server responds with | A redirect (back()), then fresh props | JSON |
| Best for | Lists and counters other components read from props | Self-contained widgets talking to a JSON endpoint |
| Source of truth | The page props | The 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:
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
php artisan test --filter=PostLikesTestFor 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.useHttpgives JSON requests the same comfort asuseForm: 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#
- Upgrade from Inertia 2 to Inertia 3: the checklist, if you are not on v3 yet.
- Inertia 3.9: background polling and the navigate type: keep that like count fresh without a reload.
- Partial reloads in Inertia and Vue: more on
onlyand small responses. - Test a Laravel + Vue app: feature and browser tests.



