Skip to main content
zerotal

Data props

Zerotal implements the full Inertia v3 server-side data-props protocol. These features let you control which props are sent, when they're evaluated, and how the client merges them — the foundation for fast pages, "load more" lists, and deferred content. They work with the stock @inertiajs/react / @inertiajs/vue3 clients; no extra client setup is required.

Import the helpers directly or use the Inertia facade:

// in a controller
import { inertia, optional, always, defer, merge, deepMerge } from "@zerotal/inertia";
// or
import { Inertia } from "@zerotal/inertia";

Tip — Not sure which wrapper to reach for? The short version: a bare value is always sent and always evaluated; a () => … function is always sent but only evaluated when included; optional() is sent only when a partial reload asks for it; defer() loads after first paint; merge()/scroll() combine new data with what the client already has.

Partial reloads

On a visit to the same page, the client can request a subset of props with only / except. The server evaluates and returns just those props; the client keeps the rest. Zerotal reads the X-Inertia-Partial-Data / X-Inertia-Partial-Except / X-Inertia-Partial-Component headers automatically — you don't write any special code for the route, you just make props lazy.

// client
router.reload({ only: ["users"] });

Lazy evaluation

Wrap optional data in a function so it's only evaluated when actually included:

// in a controller
return inertia("Users/Index", {
  users: () => User.all(), // evaluated every full visit, and on partial reloads that ask for it
  companies: () => Company.all(),
});

A function prop is always sent on a full visit but only evaluated when included, so excluding it from a partial reload also skips the query.

Optional and always props

optional(fn) — never sent on a normal visit; only when explicitly requested via only. Ideal for expensive data the page can load on demand.

// in a controller
return inertia("Users/Index", {
  users: optional(() => User.all()), // only when reloaded with only: ["users"]
});

always(value) — always sent, even when a partial reload's only/except would exclude it. (The shared errors bag uses this internally.)

ApproachFull visitPartial reloadEvaluated
User.all()alwaysoptionallyalways
() => User.all()alwaysoptionallyonly when needed
optional(() => User.all())neveroptionallyonly when needed
always(User.all())alwaysalwaysalways

Tiplazy(fn) is an alias of optional(fn), kept for parity with Inertia's historical name. New code should prefer optional.

Deferred props

defer(fn) excludes a prop from the initial render and tells the client to fetch it in a follow-up request — great for below-the-fold or slow data. Group props to control parallelism (each group is one request).

// in a controller
return inertia("Users/Index", {
  users: () => User.all(),
  permissions: defer(() => Permission.all()), // default group
  teams: defer(() => Team.all(), "attributes"), // grouped together…
  projects: defer(() => Project.all(), "attributes"), // …fetched in one request
});

The first response carries a deferredProps map; the client then partial-reloads each group. On the client, wrap the UI in <Deferred>:

// in a page component
import { Deferred } from "@inertiajs/react";

<Deferred data="permissions" fallback={<div>Loading…</div>}>
  <Permissions />
</Deferred>;

Error handling

Pass { rescue: true } so a thrown error is swallowed and the key reported in rescuedProps (the client renders the <Deferred rescue> slot) instead of failing the whole response:

// in a controller
permissions: defer(() => Permission.all(), "default", { rescue: true }),

Merging props

By default a reloaded prop replaces the client value. merge() / deepMerge() make the client combine the new data with what it already has — the basis for paginated "load more" lists. Merging only happens on partial reloads (full visits always replace).

// in a controller
return inertia("Feed", {
  posts: merge(() => Post.paginate(15, page)), // append at root
});

Chainable targeting:

// in a controller
merge(users).append("data").matchOn("data.id"); // append to users.data, replace items matching id
merge(items).prepend(); // prepend at root
deepMerge(chat).matchOn("messages.id"); // deep-merge the whole structure

These populate the page object's mergeProps / prependProps / deepMergeProps / matchPropsOn, which the client uses to merge correctly. Use the client's reset: ["posts"] option to clear a prop before merging fresh data (e.g. on a new search) — Zerotal honors the X-Inertia-Reset header.

Infinite scroll

scroll(paginator) is purpose-built for the client's <InfiniteScroll> component. It merges the paginator's data array and emits a scrollProps entry describing the current/next/previous page, so the client knows when (and which way) to load more:

// app/controllers/PostController.ts
import { inertia, scroll } from "@zerotal/inertia";

async index(ctx: HttpContext) {
  const page = Number(ctx.query("page", "1"));
  return inertia("Posts/Index", {
    posts: scroll(() => Post.paginate(15, page)),         // merges posts.data, emits scrollProps
    // scroll(() => Post.paginate(15, page), { pageName: "p", dataPath: "data" })
  });
}

This produces a page object like:

{
  "mergeProps": ["posts.data"],
  "scrollProps": {
    "posts": { "pageName": "page", "previousPage": null, "nextPage": 2, "currentPage": 1 }
  }
}

next/previous page are derived from the paginator (currentPage/page, lastPage, or total+perPage). When the user scrolls up, the client sends X-Inertia-Infinite-Scroll-Merge-Intent: prepend and Zerotal prepends the new page instead of appending — no controller change needed. On the client:

// in a page component
import { InfiniteScroll } from "@inertiajs/react";

<InfiniteScroll data="posts">
  {posts.data.map((post) => (
    <Post key={post.id} post={post} />
  ))}
</InfiniteScroll>;

Once props

Chain .once() onto an optional/merge/defer prop so it's resolved a single time and remembered by the client across navigations. The client sends X-Inertia-Except-Once-Props with the keys it already holds; the server skips re-resolving them.

// in a controller
return inertia("Billing/Plans", {
  plans: optional(() => Plan.all()).once(),
});

History encryption

Encrypt the current page's browser history state (so sensitive data isn't readable from window.history after logout), or clear it:

// in a controller
import { Inertia } from "@zerotal/inertia";

Inertia.encryptHistory(); // encrypt this page's history entry
Inertia.clearHistory(); // e.g. in your logout action

Set a global default in config:

// config/inertia.ts
import { InertiaConfig } from "@zerotal/inertia";

export default InertiaConfig({ encryptHistory: true });

Danger — Without history encryption, sensitive props remain readable from window.history after a user logs out. Call Inertia.clearHistory() in your logout action (or set encryptHistory: true) for pages that render private data.

External & fragment redirects

Inertia.location(url) performs a full-page visit to an external URL — a 409 with X-Inertia-Location for Inertia requests, a 302 otherwise:

// in a controller
return Inertia.location("https://billing.stripe.com/session/abc");

Redirects whose target carries a URL fragment (/page#section) are automatically converted to a 409 + X-Inertia-Redirect on Inertia requests, so the client performs an Inertia visit that preserves the fragment.

Precognition

Precognition lets a form validate against the server's real rules without running the controller's side effects — perfect for live, inline validation. Register PrecognitionMiddleware and use a FormRequest; when the client sends Precognition: true, FormRequest.validate() short-circuits with a 204 (valid) or 422 (errors), optionally limited to the fields in Precognition-Validate-Only.

// bootstrap — register PrecognitionMiddleware globally
import { PrecognitionMiddleware } from "@zerotal/inertia";

// app/controllers/PostController.ts — unchanged; validate() becomes precognition-aware automatically
async store(ctx: HttpContext) {
  const data = await StorePostRequest.validate();
  // ...only runs on a real (non-precognition) submit
}

The middleware itself only stamps Vary: Precognition on precognitive responses; the validation short-circuit happens inside FormRequest.validate(). On the client, use the @inertiajs/react precognition useForm().validate(...) helpers as usual.

Page object reference

Each feature contributes fields to the JSON page object. Zerotal emits these automatically; you never build them by hand. Fields are omitted when empty.

FieldSet by
component, props, url, versionalways (core)
deferredPropsdefer(){ group: [keys] } on first load
rescuedPropsa rescued defer() prop that threw
mergeProps / prependProps / deepMergePropsmerge() / .prepend() / deepMerge()
matchPropsOn.matchOn(...)
scrollPropsscroll() — infinite-scroll pagination config
onceProps.once()
encryptHistory / clearHistoryInertia.encryptHistory() / clearHistory()
sharedPropskeys registered via Inertia.share() (+ built-ins)

Request headers Zerotal reads

X-Inertia, X-Inertia-Version, X-Inertia-Partial-Data, X-Inertia-Partial-Except, X-Inertia-Partial-Component, X-Inertia-Reset, X-Inertia-Except-Once-Props, X-Inertia-Error-Bag, and Precognition / Precognition-Validate-Only.

Shared props

Shared props are merged into every Inertia page automatically, so common data like the authenticated user and flash messages are always available to your components without each controller passing them explicitly.

// in a controller
import { sharedProps } from "@zerotal/inertia";

inertia() calls sharedProps() internally and spreads the result under your controller's props ({ ...sharedProps(), ...props }), so a prop you pass with the same key wins.

What's provided

// the shared bag sharedProps() returns
{
  auth: {
    user: ctx.user ?? null,   // reduced to plain scalars — never a live ORM model
  },
  flash: {
    success: session.get("success") ?? null,
    error:   session.get("error")   ?? null,
  },
  errors: always(session.get("errors") ?? {}),  // from a validation redirect
  old:    session.get("old")    ?? {},          // previous form input
}

errors is wrapped in always() so it survives partial reloads — the Inertia client always expects an errors bag. When the request carries an X-Inertia-Error-Bag header, errors are namespaced under that bag. The other shared props (auth/flash/old) are ordinary props, so a partial reload's only/except filter applies to them, matching Inertia's semantics.

Every page component can read these without the controller passing them:

// resources/js/pages/Page.tsx
import { usePage } from "@inertiajs/react";

export default function Page() {
  const { auth, flash, errors } = usePage<{
    auth: { user: { name: string } | null };
    flash: { success: string | null; error: string | null };
    errors: Record<string, string>;
  }>().props;

  return (
    <>
      {flash.success && <div className="toast">{flash.success}</div>}
      {auth.user ? <span>{auth.user.name}</span> : <a href={route("login")}>Sign in</a>}
    </>
  );
}

Why auth.user is a plain object

sharedProps() serializes the authenticated user to scalar fields only — it skips methods and array-valued properties. This is deliberate: a live model with an unloaded @hasMany relation throws when JSON.stringify touches the relation getter. The plain object is always safe to send. If a page needs related data, load it explicitly in the controller and pass it as a normal prop.

Warning — Don't pass a live ORM model as a prop expecting all its relations. JSON.stringify triggers relation getters, which throw if the relation wasn't eager-loaded. Eager-load (.with(...)) or map to a plain shape first.

Adding custom shared props

Register with Inertia.share

Register props once — typically in a provider's boot or in middleware — and they're merged into every page. Values may be plain values, factory functions (evaluated lazily per request), or any prop wrapper:

// in a provider's boot or middleware
import { Inertia } from "@zerotal/inertia";

// Single key, or a map:
Inertia.share("appName", "Acme");
Inertia.share({
  appName: "Acme",
  year: () => new Date().getFullYear(), // evaluated per request
  flags: Inertia.optional(() => FeatureFlag.all()), // only on partial reloads
});

Shared props are subject to partial-reload filtering just like page props (except errors, which is always()), and they appear in the page object's sharedProps list so the client can carry them over during instant visits.

Merge in the controller

// in a controller
return inertia("Dashboard", {
  notifications: await Notification.query().where("user_id", user.id).unread().get(),
  // auth, flash, errors, old are merged automatically — no need to repeat them
});

Share from middleware via the context

When several pages need the same extra data, set it on the context in a middleware and read it back in the controller — keeping the controller body clean:

// in a middleware:
ctx.setInternal("unreadCount", await Notification.unreadCount(ctx.user!.id));

// in the controller:
return inertia("Layout", {
  unreadCount: ctx.getInternal<number>("unreadCount"),
});

This pattern pairs well with a persistent layout that displays the value on every page.

Typed props

The props a controller passes are checked against the props the page component declares:

// resources/js/pages/Posts/Show.tsx
interface Props {
  post: Post;
  related: Post[];
  stats?: { views: number }; // optional — it arrives after first paint
}
export default function Show({ post, related, stats }: Props) { … }
// in a controller
return Inertia.render("Posts/Show", {
  post,
  related: [],
  stats: defer(() => computeStats()),
});

Inertia.render("Posts/Shwo", { … }); // ✗ not a page
Inertia.render("Posts/Show", { post }); // ✗ Property 'related' is missing
Inertia.render("Posts/Show", { post, relatd: [] }); // ✗ Did you mean 'related'?

Nothing is annotated to make this work. The component already declares its props, and resources/js/pages.generated.ts already holds an import() thunk per page — and an import() thunk carries the module's full type. The registry is written with satisfies, so those types survive, and one type-only line in that same file hands them to the server. Rebuild it with bun zt inertia:build (or just run zt dev, which rebuilds it on every change).

The check runs in the direction that costs nothing: the component declares the shape and the controller is checked against it.

Wrappers are unwrapped

The two sides genuinely differ — the controller passes merge(() => posts) and the component receives Post[] — so each prop accepts its value, a factory for it, or a wrapper carrying it, and the wrapper's payload is checked against the prop it fills:

Inertia.render("Posts/Show", { post, related: merge(() => [1, 2]) });
// ✗ number[] is not Post[]

Warningoptional() and defer() are only accepted where the component declares the prop as optional. They are absent on first paint by definition, so a component that types such a prop as required is wrong about its own contract. Types that accepted it anyway would launder that bug into something the compiler had signed off on. Add the ? — and handle the undefined.

Shared props are never required

auth, flash, errors and old are merged into every page, so a controller never has to pass them — even when the page component declares them. Props you register yourself with Inertia.share() are a runtime call that nothing can generate, so declare them once:

// resources/js/types.ts (next to the interface your pages read with usePage)
declare module "@zerotal/inertia" {
  interface SharedProps {
    appName: string;
    flags: Record<string, boolean>;
  }
}

They then become optional-but-accepted in every Inertia.render call: a page may still override one, and no page is forced to pass it.

What is not checked

  • A page name only known at runtime — an error page chosen by status code, a component from config — has nothing to check against. Use Inertia.render.dynamic(name, props), which takes any name and any props.
  • Vue pages. A .vue SFC resolves through a declare module '*.vue' shim that types the default export as DefineComponent<{}, {}, any>, so its props are not visible to TypeScript unless vue-tsc is in your typecheck path. Those pages fall back to accepting any props rather than failing on a shape nobody can see. The page name is still checked; only its props are not.
  • A component wrapped in React.memo() (or anything else that returns an object rather than a function) falls back the same way.
  • Before you rebuild the registry, every name and every prop bag compiles, as it always did.

Next steps