Inertia.js v3 Beta Deep Dive - Delaney Industries
Back to Blog
Web DevelopmentFramework Deep Dive

Inertia.js v3 Beta: A Deep Dive into What's New

Delaney Wright|Director|6 March 2026|28 min

Inertia.js v3 is currently in beta and it is a significant release. The adapter API has been overhauled, new hooks have been added, the prefetch and caching system is now built in, and the request model has been split into two distinct tools with clearly different jobs.

This article walks through the features that matter most in v3: how they work, how to use them correctly, and what to watch for when upgrading from v2. The focus is on the decisions you will make in code, not on repeating what the reference documentation already covers.

Topics covered: the useForm and useHttp split, optimistic updates, layout props, instant visits, the response lifecycle, HTTP error handling, prefetch and cache tags, client-side prop mutations, global config, WhenVisible, new React-specific APIs, and the breaking changes to handle before upgrading.

1) useForm vs useHttp

v3 introduces useHttp alongside the existing useForm. They look similar but serve completely different purposes, and the distinction is important.

The clearest way to think about it: useForm is for requests that navigate. useHttp is for requests that don't.

The return type of each method enforces this. useForm.post() returns void. You get the response via the next page's props. useHttp.post() returns Promise<TResponse>. The component owns the response directly.

tsx
// useForm navigates on success
// The void return is correct: the response arrives as the next page's props
const form = useForm({ email: '', password: '' })
form.post('/login')

// useHttp stays on the current page
// The typed response is the entire point
const http = useHttp<{ query: string }, { users: User[]; total: number }>({ query: '' })
const data = await http.post('/api/search')
// data is { users: User[], total: number }

Routing useHttp to the right controller

useHttp does not send X-Inertia headers. This means it bypasses Inertia's response handling entirely and goes straight to a raw HTTP response. Route it only to controllers that return response()->json(), not to controllers that call Inertia::render(). If you hit an Inertia controller with useHttp, the returned data will be the full Inertia page object. TypeScript won't catch this at runtime.

php
// routes/api.php
Route::post('/users/search', function () {
    $validated = request()->validate([
        'query' => ['required', 'string', 'min:1'],
        'role'  => ['nullable', 'in:admin,user'],
    ]);

    return response()->json([
        'users' => User::search($validated)->limit(20)->get(['id', 'name', 'email']),
        'total' => User::search($validated)->count(),
    ]);
    // 422 from validate() auto-populates http.errors client-side
    // No Inertia::render() this is a plain JSON controller
});

Precognition with useHttp

useHttp supports Precognition for real-time validation without a full form submission:

tsx
const http = useHttp<CreateUserForm>({ name: '', email: '' })
  .withPrecognition('post', '/users')

// Adds .valid(), .invalid(), .validate(), .touch() to the http object
// Real-time validation fires against /users with Precognition headers

The failure pattern to avoid: Using useForm to hit a JSON endpoint and wiring router.on('success') to get the response. That pattern requires everyone on the team to re-learn an Inertia-specific workaround. When the component needs to own the response, use useHttp.

2) Optimistic Updates

v3 adds a first-class optimistic update API. The pattern: immediately update the UI with the expected result, fire the server request, and roll back if it fails.

tsx
router.optimistic<{ todos: Todo[] }>((props) => ({
  todos: props.todos.filter(t => t.id !== id)
})).delete(`/todos/${id}`)

When optimistic updates are a good fit

They work well when all of these are true:

  • The operation is idempotent or trivially reversible (toggle, soft delete, reorder)
  • You can compute the exact post-operation state from current props and your input alone
  • A momentary incorrect state before a rollback is tolerable

They are not a good fit when any of these are true:

  • The operation has side effects: email sent, webhook fired, payment charged
  • The server generates a value you need immediately: ID, timestamp, computed field
  • Multiple users can mutate the same resource: your current state may already be stale
  • The operation is financial or irreversible

How the rollback works

Only the keys your callback returns are snapshotted. The snapshot is a deep clone taken before the optimistic update is applied. On any non-2xx response, that snapshot replaces those specific keys. Other page props are untouched.

tsx
// Safe only 'todos' rolls back if this fails
router.optimistic<{ todos: Todo[] }>((props) => ({
  todos: props.todos.filter(t => t.id !== id)
})).delete(`/todos/${id}`)

// Dangerous snapshots everything, can overwrite data
// a concurrent request just updated
router.optimistic<PageProps>((props) => ({
  ...props  // Don't spread all props
})).delete(`/todos/${id}`)

Handling concurrent requests on the same item

Optimistic requests run on the async stream, which is unbounded and non-interruptible. Two rapid toggles on the same item both run concurrently with independent snapshots. If only the second fails, the rollback target is the first request's optimistic state, not the original. For resources where this matters, gate concurrent actions:

tsx
const [pending, setPending] = useState(false)

const toggle = (todo: Todo) => {
  if (pending) return
  setPending(true)
  router
    .optimistic<{ todos: Todo[] }>((props) => ({
      todos: props.todos.map(t =>
        t.id === todo.id ? { ...t, done: !t.done } : t
      )
    }))
    .patch(`/todos/${todo.id}`, {}, {
      preserveScroll: true,
      onFinish: () => setPending(false),
    })
}

3) Layout Props, Shared Data and Context

v3 adds setLayoutProps and useLayoutProps, which give page components a clean way to communicate upward to their persistent layout. There are now three distinct tools for sharing state and data, each with a specific job.

ToolDirectionWhen it resetsServer involvement
HandleInertiaRequests::share()Server → every pageEvery requestRequired
setLayoutProps / useLayoutPropsPage → persistent layoutOn component changeNone
React contextAncestor → descendantOn provider unmountNone

share() is for data that originates on the server and every page needs: auth user, permissions, feature flags. It re-evaluates on every Inertia request.

Layout props are for UI state a page communicates upward: page title, full-bleed vs padded layout, active nav item. Zero server cost, and they reset automatically when the page component changes.

Context is for UI state that doesn't survive navigation and shouldn't: modal open state, multi-step form progress.

tsx
// Page component sets layout config without a server round-trip
export default function ReportsIndex({ reports }) {
  setLayoutProps({
    pageTitle: 'Reports',
    fullBleed: true,
    activePath: '/reports',
  })
  return <ReportsTable reports={reports} />
}

// Layout component reads with defaults
export default function AppLayout({ children }) {
  const { pageTitle, fullBleed, activePath } = useLayoutProps({
    pageTitle: 'My App',
    fullBleed: false,
    activePath: null as string | null,
  })
  return (
    <div>
      <nav>
        <h1>{pageTitle}</h1>
        <NavItem href="/reports" active={activePath === '/reports'} />
      </nav>
      <main className={fullBleed ? '' : 'px-10 py-8'}>{children}</main>
    </div>
  )
}

Named layouts

When a page renders inside multiple named layouts, each can be targeted individually:

tsx
Dashboard.layout = {
  app: AppLayout,
  sidebar: SidebarLayout,
}

// Target each layout by name in the page component
setLayoutPropsFor('app', { pageTitle: 'Dashboard' })
setLayoutPropsFor('sidebar', { collapsed: true })

Timing: setLayoutProps runs synchronously during the page render. Internally the layout store uses microtask batching to deduplicate multiple calls in the same render cycle. There is no flash of default values. In React strict mode, effects and renders double-invoke; the batching handles this correctly, but it is worth knowing when debugging.

Building a Laravel and Inertia.js Application?

Our team builds production web applications with Laravel and Inertia.js. Talk to an expert today.

4) Instant Visits

Instant visits swap to the target component before the server responds. The user sees the target page skeleton immediately. When the server responds, the page fills in with real data.

When instant visits are worth the setup

Worth doing when:

  • The target page has a meaningful skeleton using only shared props (auth user, app name)
  • Server latency is reliably above 300ms (database-heavy pages, external API calls)
  • You have time to build the skeleton thoughtfully

Not worth it when:

  • The target page needs page-specific data to render anything useful
  • The skeleton and real content are visually jarring in transition
  • You would just show a spinner anyway. A regular visit with a progress bar does the same job

The sharedProps mechanism

The server embeds a sharedProps list in every page response: the keys that came from Inertia::share(). The pageProps callback receives these automatically, so you can safely use auth and other shared data in the skeleton without guessing:

tsx
router.visit('/reports', {
  component: 'Reports/Index',
  pageProps: (currentProps, sharedProps) => ({
    ...sharedProps,    // auth, appName, locale ready immediately
    reports: null,     // shows skeleton while server responds
    stats: null,
  }),
})

Hard constraint: The target component must handle all page-specific props as nullable. Any non-null assertion on a page-specific prop will throw on the intermediate render. Type page-specific props as optional and your TypeScript compiler will enforce this for you.

5) The Response Lifecycle

Understanding the exact sequence of events on a successful navigation helps you place callbacks correctly and avoid subtle bugs.

typescript
onStart()
  → HTTP request fires
    → runCallbacks()         // deferred until response arrives for prefetch
    → onBeforeUpdate(page)   // after response parsed, before page.set()
    → page.set()             // React re-renders here
    → flushByCacheTags()     // invalidateCacheTags option, only on success
    → flush(currentUrl)      // auto-flush current URL's prefetch cache, always
    → flash event + onFlash() // only if flash non-empty AND changed
    → onSuccess(page)        // after re-render
→ onFinish()                 // always, success or error

What each step means in practice

onBeforeUpdate fires after the server responds and the response is parsed, but before page.set() triggers a React re-render. It receives the incoming Page object: fully parsed, not yet applied. Use it to normalise incoming data before React sees it, avoiding a second render pass.

typescript
router.visit('/users', {
  onBeforeUpdate: (page) => {
    // Normalise before React sees it no second render needed
    page.props.users = page.props.users?.map(normalizeUserFromApi) ?? []
  },
  onSuccess: (page) => {
    // Data is already normalised here
  },
})

invalidateCacheTags fires only on success. A 422 validation error does not invalidate caches. Plan your cache invalidation strategy around this.

Auto-flush: After any successful non-prefetch navigation, Inertia automatically flushes the prefetch cache for the current URL. You do not need invalidateCacheTags for the page you just navigated to. Use it only for related pages.

Flash fires only when flash is non-empty AND the flash value has changed since the last render. This means your toast notifications do not re-fire on every router.reload().

onBeforeUpdate and prefetch: For prefetch requests, onBeforeUpdate is wrapped and deferred until the prefetch cache entry is actually consumed. Don't rely on it firing at a specific time relative to the prefetch request itself.

6) HTTP Error Handling with onHttpException

v3 introduces onHttpException a per-request callback that fires on non-2xx responses. Returning false from it exits the response loop entirely. Page state is unchanged. The request's onFinish still fires.

If you don't return false, execution falls through to setPage() which navigates to whatever the server returned as the error page. This is the correct behaviour for errors like 401 and 500.

typescript
router.post('/payments', data, {
  onHttpException: (response) => {
    switch (response.status) {
      case 402:
        openPaymentModal(JSON.parse(response.data))
        return false  // Stay on current page, show modal
      case 409:
        showConflictDialog(JSON.parse(response.data))
        return false
      case 429:
        showRateLimitToast(response.headers['retry-after'])
        return false
      // 401, 403, 500 fall through
      // Inertia navigates to the server's error page
    }
  },
})

Do not intercept 401. Returning false on a 401 leaves the user on a page with stale data and no indication they are logged out. Let Inertia navigate to the server's 401 response.

Laravel Exception Handler: Shared Data on Error Pages

When rendering custom error pages via the exception handler, .toResponse($request) is required to run the HandleInertiaRequests middleware and inject shared data. Without it, your error page won't have auth.user, appName, or anything your layout needs and the layout will break.

php
// app/Exceptions/Handler.php
public function register(): void
{
    $this->renderable(function (ModelNotFoundException $e, Request $request) {
        if ($request->inertia()) {
            return Inertia::render('Errors/NotFound')
                ->toResponse($request)   // Runs HandleInertiaRequests, injects shared data
                ->setStatusCode(404);
        }
    });

    $this->renderable(function (AccessDeniedHttpException $e, Request $request) {
        if ($request->inertia()) {
            return Inertia::render('Errors/Forbidden', [
                'requiredPermission' => $e->getMessage(),
            ])->toResponse($request)->setStatusCode(403);
        }
    });
}

7) Cache Tags and Prefetch

v3 ships with a built-in prefetch and cache system. Links can prefetch on hover, focus, or mount. The cache can be tagged and selectively invalidated on mutations.

How auto-flush and cache tags work together

On every successful navigation, two flushes happen automatically:

  • Your explicit tags, flushed via the invalidateCacheTags option
  • The URL you just navigated to, always flushed automatically regardless of options

This means you only need invalidateCacheTags for related pages, not for the page you are submitting to.

typescript
// After creating a todo, invalidate the dashboard that shows a todo count.
// The /todos page itself is auto-flushed by the successful navigation anyway.
router.post('/todos', data, {
  invalidateCacheTags: ['dashboard'],
})
tsx
// Tag prefetch links so mutations know what to invalidate
<Link href="/dashboard" cacheTags={['dashboard']} prefetch="hover">
  Dashboard
</Link>

The Purpose: prefetch Header (undocumented server hook)

Every prefetch request includes a Purpose: prefetch header. Laravel controllers can read this to skip side-effectful work when a request is speculative rather than intentional:

php
Route::get('/dashboard', function (Request $request) {
    if ($request->header('Purpose') === 'prefetch') {
        // Skip: audit logging, analytics events, rate limit increments,
        // anything with side effects from being "viewed"
        return Inertia::render('Dashboard', DashboardData::lightweight());
    }

    return Inertia::render('Dashboard', DashboardData::full());
});

This matters particularly for rate-limited routes. A user hovering over five navigation links fires five prefetch requests against your limiter before they have clicked anything.

Prefetch cache defaults

typescript
// Defaults from packages/core/src/config.ts
{
  prefetch: {
    cacheFor: 30_000,   // 30-second TTL
    hoverDelay: 75,     // ms before hover triggers a prefetch
  }
}

// Override globally
createInertiaApp({
  defaults: {
    prefetch: { hoverDelay: 150 }, // more accessible for motor-impaired users
  },
})

// Override at runtime (useful in tests)
import { config } from '@inertiajs/core'
config.set('prefetch.hoverDelay', 0)

Building a Laravel and Inertia.js Application?

Our team builds production web applications with Laravel and Inertia.js. Talk to an expert today.

8) Client-Side Prop Mutations

v3 adds three router methods that write directly to Inertia's page state without any server request: replaceProp, appendToProp, and prependToProp. Internally each calls router.replace() with preserveScroll: true and preserveState: true hardcoded. You cannot override these.

typescript
// Replace accepts a value or an updater function
router.replaceProp('user', (current, props) => ({ ...current, name: newName }))

// Append to array
router.appendToProp('messages', { id: Date.now(), text: 'Hello', fromSelf: true })

// Prepend to array
router.prependToProp('notifications', { id: uuid(), text: 'New order received' })

Real-time data with WebSockets

These methods are well-suited for updating props from WebSocket or SSE events, updating the UI without any server round-trip:

typescript
Echo.private(`orders.${userId}`)
  .listen('OrderPlaced', (order) => {
    router.prependToProp('recentOrders', order)
    router.replaceProp('orderCount', (current) => current + 1)
  })

Key distinction from optimistic updates: There is no server request here at all. It is a direct write to Inertia's page state. The data will be replaced on the next full Inertia navigation. Use these methods for real-time data from WebSockets or SSE, not as a shortcut for mutations that need server validation.

9) Global Config and Runtime Defaults

v3 adds a defaults option to createInertiaApp for setting application-wide configuration, and a visitOptions callback that runs before every visit.

visitOptions: Global Visit Hook

Every visit runs through this callback before sending. Use it to add default headers, navigation logging, or force async mode on known slow routes:

typescript
createInertiaApp({
  defaults: {
    visitOptions: (href, options) => {
      return {
        ...options,
        headers: {
          ...options.headers,
          'X-App-Version': import.meta.env.VITE_APP_VERSION,
        },
      }
    },
  },
})

Form and prefetch defaults

typescript
// Actual defaults from packages/core/src/config.ts
{
  form: {
    recentlySuccessfulDuration: 2_000, // ms wasSuccessful stays true
    forceIndicesArrayFormatInFormData: true,  // arrays as items[0], items[1]
    withAllErrors: false,              // first error per field or all errors
  },
  prefetch: {
    cacheFor: 30_000,   // 30-second TTL
    hoverDelay: 75,     // ms before hover triggers prefetch
  }
}

// Override globally in createInertiaApp
createInertiaApp({
  defaults: {
    form: { recentlySuccessfulDuration: 5_000 },
    prefetch: { hoverDelay: 150 },
  },
})

// Override at runtime useful in tests
import { config } from '@inertiajs/core'
config.set('form.recentlySuccessfulDuration', 0)
config.set('prefetch.hoverDelay', 0)

10) WhenVisible and Lazy Loading

The WhenVisible component defers a partial reload until an element enters the viewport. The default behaviour fires once, then disconnects the IntersectionObserver. With always it stays connected and refetches every time the element enters the viewport.

tsx
// Default load once, stop observing
<WhenVisible data="expensiveReport" fallback={<Skeleton />}>
  {({ fetching }) => (
    <ReportChart data={expensiveReport} refreshing={fetching} />
  )}
</WhenVisible>

// always refetch every time scrolled into view
<WhenVisible data="liveOrderCount" always>
  {({ fetching }) => (
    <StatCard value={liveOrderCount} loading={fetching} label="Orders Today" />
  )}
</WhenVisible>

The fetching slot prop lets you show a refresh indicator without hiding existing content. Data stays visible while updating.

usePrefetch hook

Returns { isPrefetching, isPrefetched, lastUpdatedAt, flush } for the current page's prefetch state. Useful for showing cache status indicators or wiring a manual refresh:

tsx
function DataPanel() {
  const { isPrefetching, isPrefetched, lastUpdatedAt, flush } = usePrefetch()

  return (
    <div>
      {isPrefetching && <Spinner size="sm" />}
      {isPrefetched && lastUpdatedAt && (
        <span className="text-xs text-gray-400">
          Cached {formatRelativeTime(lastUpdatedAt)}
          <button onClick={flush}>Refresh</button>
        </span>
      )}
    </div>
  )
}

11) New React-Specific APIsv3 only

strictMode in createInertiaApp

Rather than manually wrapping in setup(), you can enable React Strict Mode directly:

tsx
createInertiaApp({
  strictMode: true,
  // Wraps the entire app in React.StrictMode
})

In development, strict mode double-invokes effects and renders. setLayoutProps will fire twice per render cycle; the microtask batching in the layout store handles this correctly, but it is worth being aware of when debugging unexpected re-runs.

axiosAdapter: Still Available

Axios was removed as a dependency in v3, but the adapter is still exported for teams that need it. For example, to reuse existing Axios interceptors:

typescript
import { axiosAdapter } from '@inertiajs/core'
import axios from 'axios'

// Your existing interceptors continue to work
axios.interceptors.request.use(/* ... */)

createInertiaApp({
  http: axiosAdapter,  // Inertia uses your Axios instance
})

// Note: Axios must now be installed directly.
// It is no longer a transitive dependency of Inertia.

12) Upgrading from v2: What to Check

The obvious breaking changes (missing methods, build errors, TypeScript errors) are in the upgrade guide. These are the ones that produce no error and silently stop working.

Renamed router events

Two event names changed. Listeners on the old names compile and run without error. They simply never fire:

typescript
// v2 names silently dead in v3
router.on('invalid', handler)    // now 'httpException'
router.on('exception', handler)  // now 'networkError'
bash
# Search your codebase before upgrading
grep -r "router.on('invalid'" resources/js
grep -r "router.on('exception'" resources/js

qs transitive dependency removed

If your own code imports qs (as opposed to using Inertia's use of it), it may resolve correctly in development but fail in production builds:

bash
# Check if your code imports qs directly
grep -r "from 'qs'" resources/js
grep -r "require('qs')" resources/js

# If found, install it explicitly
npm install qs

router.cancel() is deprecated

router.cancel() still works but only cancels the sync stream. If you were using it defensively before starting a new request, switch to the explicit form:

typescript
// Before: ambiguous
router.cancel()

// After: explicit about which streams you're cancelling
router.cancelAll({ async: false, prefetch: false })

ESM-only packages

All @inertiajs/* packages are ESM-only in v3. Any Node.js script, test helper, or build tool that require()s them will fail. Check your Jest or Vitest configuration for node_modules transform exclusions that include Inertia packages.

Before upgrading: Run the event name grep, check for qs direct imports, and verify your test and build configs do not require() Inertia packages in a CommonJS context.

Quick Reference

Common questions about v3 behaviour:

QuestionAnswer
Does useHttp send X-Inertia headers?No, it uses the raw HTTP client
Does optimistic rollback affect all props?No, only the keys your callback returns
Does replaceProp preserve scroll?Yes, hardcoded
When does invalidateCacheTags fire?Only on success, not on 422 or error responses
Does Inertia auto-flush the visited URL's cache?Yes, on every successful navigation
Does flash fire on partial reloads?Only if the flash value changed
Is onBeforeUpdate deferred for prefetch?Yes, fires when the prefetch is consumed
Does setLayoutProps reset on navigation?Yes, when preserveState is false
What stream do optimistic requests use?Async stream, unbounded and non-interruptible

What v3 Changes in Practice

The v3 API surface is larger, but the additions are purposeful. The useForm and useHttp split gives each request type a clear owner. The optimistic update API formalises a pattern teams were previously implementing inconsistently. Layout props, the prefetch system, cache tags, and client-side prop mutations each solve specific problems that previously required workarounds.

The areas that require the most attention:

  • Routing useHttp only to plain JSON controllers, not Inertia::render() controllers
  • Keeping optimistic update callback scope narrow: only return the keys you need
  • Nullable typing on page-specific props when using instant visits
  • Calling .toResponse($request) in the Laravel exception handler for shared data on error pages
  • Running the renamed event grep before upgrading from v2
  • Updating test and build configs for ESM-only packages

The beta is stable enough to evaluate thoroughly. The upgrade guide covers the mechanical changes. This article covers the decisions you will make once the upgrade is done.

At Delaney Industries, we build production web applications with Laravel and Inertia.js, from architecture through to deployment.

If you are building or upgrading a Laravel and Inertia.js application:

Delaney Wright

Director, Delaney Industries

Delaney Wright is the Director of Delaney Industries, a software development company based in Sleaford, Lincolnshire. Specialising in web development, web applications, AI integration and process automation for businesses across the UK.

Ready to Build with Laravel and Inertia.js?

We design and build production web applications with Laravel and Inertia.js: architecture, development, and deployment included.

Talk to an Expert
We use cookies to enhance your experience