Laravel Daily's

Optimistic UI in Inertia 3.x: Instant Feedback Without the Jank

hero image

A button should respond when a user clicks it.

That sounds obvious, but many interfaces still wait for a full server round-trip before changing. The result is familiar: a delayed checkbox, a frozen like button, or a spinner where a simple state change should appear.

Inertia 3.x gives Laravel and Vue developers a cleaner option. Its first-class optimistic updates apply a change immediately, send the request in the background, and restore the original state when the server rejects the action.

The server remains the source of truth. The interface simply does not make users wait for predictable work.

Optimistic UI: A Small Change With a Large Effect

Optimistic UI assumes that a request will succeed.

When a user likes a post, you update the heart and count immediately. When they complete a todo, you check it off without waiting. The request still goes to Laravel, but the interface moves ahead.

If Laravel confirms the action, Inertia replaces the temporary state with the server response. If the request fails, Inertia rolls back only the props touched by the optimistic update.

This matters because network latency is often more noticeable than application complexity. A fast PHP web framework can process a request quickly, but the browser still has to wait for the network. Optimistic updates hide that delay for interactions where the likely result is easy to predict.

Illustration of an optimistic update moving from a Vue interface to a Laravel server and back

Router Visits: Optimistic Updates for Inertia Pages

Use router.optimistic() when your Laravel endpoint performs a normal Inertia mutation. This is the right choice when the controller returns a redirect or another Inertia response.

Here is a simple like toggle in Vue:

<script setup>
import { router } from '@inertiajs/vue3'

const props = defineProps({
    post: Object,
})

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

    router
        .optimistic((pageProps) => ({
            post: {
                ...pageProps.post,
                liked_by_user: liked,
                likes: pageProps.post.likes + (liked ? 1 : -1),
            },
        }))
        .post(`/posts/${props.post.id}/like`)
}
</script>

<template>
    <button
        type="button"
        :aria-pressed="post.liked_by_user"
        @click="toggleLike"
    >
        {{ post.liked_by_user ? 'Unlike' : 'Like' }}
        {{ post.likes }}
    </button>
</template>

The callback receives the current page props. It should return a partial object containing only the values you want to update.

In this example, the UI changes before the request finishes:

  1. The like state flips.
  2. The count increases or decreases.
  3. Laravel receives the request.
  4. The server response replaces the optimistic values.
  5. A failed request restores the previous state.

The rollback is automatic. You do not need to keep a separate snapshot or write a second error handler to restore the count.

Your Laravel controller can remain conventional:

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

public function toggle(Request $request, Post $post): RedirectResponse
{
    $user = $request->user();

    $existingLike = $post->likes()
        ->where('user_id', $user->id)
        ->first();

    if ($existingLike) {
        $existingLike->delete();
    } else {
        $post->likes()->create([
            'user_id' => $user->id,
        ]);
    }

    return back();
}

The controller decides the final state. The Vue component predicts it.

For standard Inertia routes, return a redirect or an Inertia page response. A plain JSON response belongs with useHttp, which follows a different request lifecycle.

Todo Creation: Predicting New Client State

Optimistic updates also work well when adding items to a list.

Suppose a user adds a todo. The new record does not have a database ID yet, but the interface can still show it immediately with a temporary identifier.

<script setup>
import { ref } from 'vue'
import { router } from '@inertiajs/vue3'

const props = defineProps({
    todos: Array,
})

const name = ref('')

function addTodo() {
    if (!name.value.trim()) {
        return
    }

    const todoName = name.value.trim()

    router
        .optimistic((pageProps) => ({
            todos: [
                ...pageProps.todos,
                {
                    id: `temporary-${Date.now()}`,
                    name: todoName,
                    completed: false,
                },
            ],
        }))
        .post('/todos', {
            name: todoName,
        })

    name.value = ''
}
</script>

<template>
    <form @submit.prevent="addTodo">
        <input v-model="name" type="text" placeholder="Add a todo" />
        <button type="submit">Add todo</button>
    </form>

    <ul>
        <li v-for="todo in todos" :key="todo.id">
            {{ todo.name }}
        </li>
    </ul>
</template>

The temporary object is only a prediction. Once Laravel responds, the real todo replaces it with its actual ID, timestamps, and any server-generated fields.

This pattern works best when the client can construct a useful approximation. It becomes less suitable when creating the record triggers complex business rules, permissions, inventory changes, or server-side calculations.

useHttp: Optimistic Updates for JSON APIs

Not every request should trigger a page visit.

Inertia 3.x introduces useHttp for standalone HTTP requests. It provides reactive request state for JSON endpoints without changing the URL, replacing page props, or triggering the Inertia page lifecycle.

Use it for background actions, external APIs, and endpoints designed to return JSON.

<script setup>
import { useHttp } from '@inertiajs/vue3'

const likeRequest = useHttp({
    liked: false,
    likes: 42,
})

function toggleLike() {
    const nextLiked = !likeRequest.liked

    likeRequest
        .optimistic((data) => ({
            liked: nextLiked,
            likes: data.likes + (nextLiked ? 1 : -1),
        }))
        .post('/api/posts/1/like')
}
</script>

<template>
    <button
        type="button"
        :aria-pressed="likeRequest.liked"
        :disabled="likeRequest.processing"
        @click="toggleLike"
    >
        {{ likeRequest.liked ? 'Unlike' : 'Like' }}
        {{ likeRequest.likes }}
    </button>

    <p v-if="likeRequest.errors.message">
        {{ likeRequest.errors.message }}
    </p>
</template>

The optimistic callback receives the hook’s own data. It does not receive Inertia page props.

That distinction determines which tool you need:

Scenario Use
Mutate an Inertia page and receive a redirect router.optimistic()
Submit a form through an Inertia route useForm().optimistic()
Call a JSON endpoint without navigation useHttp().optimistic()
Refresh props on the current page router.reload()

The useHttp hook also tracks processing, errors, progress, and success state. It is useful when building a REST API with PHP and want the Vue side to retain a form-like request experience.

Validation: Let Laravel Reject the Prediction

Optimistic UI does not replace validation.

It only moves the visual response earlier.

Laravel continues to validate authorization, data, and business rules on the server. If validation fails with a 422 response, Inertia restores the optimistic state and preserves the validation errors.

For example, a comment requires content:

public function store(Request $request, Post $post)
{
    $validated = $request->validate([
        'body' => ['required', 'string', 'max:5000'],
    ]);

    $post->comments()->create([
        'user_id' => $request->user()->id,
        'body' => $validated['body'],
    ]);

    return back();
}

For an Inertia request, Laravel can redirect back with validation errors. For an XHR request that expects JSON, Laravel returns a 422 response containing the error messages. Inertia handles the response according to the request type.

Read the Laravel validation documentation for the full redirect and JSON behavior.

A comment form can still feel immediate without hiding errors. You might optimistically add a local comment with a “sending” state, then remove it if Laravel rejects the content. For a simpler implementation, submit through useForm and apply an optimistic list update:

<script setup>
import { useForm } from '@inertiajs/vue3'

const props = defineProps({
    comments: Array,
})

const form = useForm({
    body: '',
})

function submitComment() {
    form
        .optimistic((pageProps) => ({
            comments: [
                ...pageProps.comments,
                {
                    id: `temporary-${Date.now()}`,
                    body: form.body,
                    author: 'You',
                },
            ],
        }))
        .post('/posts/1/comments')
}
</script>

<template>
    <form @submit.prevent="submitComment">
        <textarea v-model="form.body" />
        <p v-if="form.errors.body">{{ form.errors.body }}</p>

        <button :disabled="form.processing">
            Comment
        </button>
    </form>
</template>

The optimistic callback should update only the props it owns. Avoid returning a complete page object when you only need to change comments.

router.reload(): Resync With Server Truth

After a successful Inertia mutation, the response normally includes fresh props. You do not need to call router.reload() after every optimistic request.

It is useful when a separate JSON request changes data currently displayed by the page. It is also useful when the server recalculates related data that your optimistic callback cannot predict.

import { router } from '@inertiajs/vue3'

function refreshPostStats() {
    router.reload({
        only: ['post'],
    })
}

router.reload() requests the current URL again. It preserves the current component and can fetch only selected props.

This makes it useful after a useHttp request:

http
    .optimistic((data) => ({
        liked: true,
        likes: data.likes + 1,
    }))
    .post('/api/posts/1/like', {
        onSuccess: () => {
            router.reload({
                only: ['post'],
            })
        },
    })

Use this pattern when the API response does not update the page props directly. Avoid using it as a reflex. The extra request adds work and can make a simple interaction less efficient.

Learn more about partial reloads in Inertia.

Bright illustration of a JSON API request paired with router.reload syncing fresh server data

Instant Visits: Faster Navigation, Different Problem

Optimistic updates and instant visits improve different parts of the experience.

Optimistic updates change data before a mutation finishes. Instant visits render the destination page component while a navigation request is still running.

<script setup>
import { Link } from '@inertiajs/vue3'
</script>

<template>
    <Link
        href="/dashboard"
        component="Dashboard"
    >
        Open dashboard
    </Link>
</template>

Inertia renders the target component with shared props first. The server then returns the page-specific props, which Inertia merges into the page.

The target component must handle missing page-specific data during that intermediate state:

<script setup>
import { computed } from 'vue'
import { usePage } from '@inertiajs/vue3'

const page = usePage()

const stats = computed(() => page.props.stats ?? {})
</script>

<template>
    <section>
        <h1>Dashboard</h1>

        <p v-if="stats.total">
            {{ stats.total }} projects
        </p>

        <p v-else>
            Loading statistics…
        </p>
    </section>
</template>

Instant visits are useful for navigation-heavy applications. They do not turn a server request into a client-only transition. Laravel still processes the request, and the final props still come from the server.

When Optimistic UI Makes Sense

Optimistic UI is a strong fit when:

  • The action is quick and reversible.
  • The expected result is easy to calculate.
  • Failure is uncommon.
  • The user benefits from immediate feedback.
  • The operation does not expose sensitive or irreversible state.

Likes, bookmarks, todo completion, follow buttons, reactions, and dismissible notifications usually fit this model.

Wait for server truth when:

  • The action charges money.
  • The result depends on inventory or availability.
  • Authorization may change the outcome.
  • The server performs complex calculations.
  • The action is destructive or difficult to reverse.
  • A temporary state could mislead the user.

You can also use a hybrid approach. Update a button immediately, but display a subtle pending indicator. For a complex result, show a local “processing” state instead of guessing the final value.

The goal is not to make every request optimistic. The goal is to remove unnecessary waiting without weakening trust.

A Smoother Inertia DX

Inertia 3.x brings optimistic updates, useHttp, instant visits, and partial reloads into a consistent developer experience. Laravel remains responsible for validation and server truth. Vue remains responsible for clear, reactive interfaces.

That division keeps the architecture familiar while removing much of the jank around ordinary interactions.

Start with one predictable action, such as a todo toggle or post like. Let Inertia apply the temporary state, allow Laravel to confirm or reject it, and use router.reload() only when a broader resync is needed.

The best interfaces do not hide the server. They make the wait feel proportional to the work.

Previous
SSR with Laravel, Vue, and Inertia: Faster First Paint, Same Elegant DX
Next
Structured Outputs in the Laravel AI SDK: Typed, Valid JSON From Any LLM