Skip to content

Inertia and Laravel

Set up bitboss-ui in an Inertia app, route Laravel validation errors into BbForm, and use app-side helpers for props, URL filters, pagination and awaitable forms.

On this page

An Inertia app sets up bitboss-ui like any Vite app, plus one line in createInertiaApp. The rest of this page is a toolkit of small composables that make Inertia pages thinner: typed page props, filters in the URL, awaitable forms.

Add the helpers you need from the app root:

bash
npx bitboss-ui add usePromiseForm useQueryObject
npx bitboss-ui helpers list

add also copies every file a helper imports, and prints the import line for each. The files are yours to edit. The full reference, source included, ships with the package in node_modules/bitboss-ui/dist/ai/guides/inertia-helpers.md.

What Inertia changes

In Inertia there is no client data layer. Data arrives as page props and changes through visits. There is no store to read, no query cache to invalidate, and no fetch to await. The server re-renders and the page props change underneath you.

That model is a good fit for this library, whose components already report state rather than owning it (see Fetching data). What it lacks is a typed surface for the props themselves, a place for filters to live, and a way to await a form submit. That is what the toolkit adds, so pages stay thin.

What to install

Inertia apps are Vite apps: set up the build plugin as in Installation, then install the runtime plugin inside createInertiaApp, after Inertia's own. The render root is also where the app's one <BbHost /> goes, beside the page: it renders every confirm(), toast() and on-demand dialog, and a page mounts none.

resources/js/app.ts
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { BbHost, bitbossUiPlugin } from 'bitboss-ui';

createInertiaApp({
    // resolve: …
    setup({ el, App, props, plugin }) {
        createApp({ render: () => [h(App, props), h(BbHost)] })
            .use(plugin)
            .use(bitbossUiPlugin)
            .mount(el);
    },
});

You register no link component. The build plugin imports Inertia's Link for you when it finds @inertiajs/vue3. So href on a BbButton or a dropdown item is an Inertia visit, with method, data and preserve-scroll passed through. method without href does nothing (to is a router link), and the ESLint plugin flags it. Set inertiaLinkName only when you register a link wrapper of your own.

Inertia SSR needs no extra Vite config: the plugin keeps the library in the server bundle. The stylesheet does need an import into its layer, as in Installation.

The helpers on the rest of this page pull in npm packages beyond your app's own @inertiajs/vue3 and vue. add prints the install command for any your app lacks, and never runs it:

bash
npm i @vueuse/core object-code query-string
npm i -D @inertiajs/core

Do not count on them arriving through bitboss-ui's own dependencies: that works by npm hoisting luck and breaks under pnpm or Yarn PnP. @vueuse/core backs the keyed contexts and a couple of the UI utilities. object-code powers the hash comparison behind isEqual. query-string backs the three URL helpers, and @inertiajs/core is a type-only import.

After upgrading bitboss-ui, your copies stay as they are. Run npx bitboss-ui helpers diff to see what changed, then npx bitboss-ui helpers update: it replaces the files you have not edited and leaves yours alone.

Reading page props

useProp and useOptionalProp give the page's props one typed surface, so a component reads useProp('customer') instead of reaching into usePage().props and asserting a type at every call site.

useLazyProp and useLazyPropGetter cover Inertia's lazy props, the ones the server only sends when asked. Reading the value of useLazyProp starts the partial reload, so a panel that loads its detail on open has no request-and-track code to write. useLazyPropGetter returns a getter in the shape a select takes as items, so the options load when the list first opens:

vue
<script setup lang="ts">
const { getter: fetchRoles } = useLazyPropGetter<Role[]>('roles');
</script>

<template>
    <BbSelect
        v-model="role"
        :items="fetchRoles"
        label="Role"
        item-text="name"
        item-value="id"
    />
</template>

Filters that live in the URL

Three helpers, in increasing scope.

useRouteQuery is a single query parameter as a writable ref. Reading tracks the current URL; writing merges the parameter and performs a replace visit, so the server re-renders with the new value.

ts
const status = useRouteQuery('status', 'open');

Writing the current value is a no-op, which is what makes it safe to sync from a server response without a visit loop.

useQuery reads the whole query string as an object. useQueryObject mirrors a whole object of filters into the URL, with one Inertia visit per change. It resets page each time, so results never hide on an out-of-range page.

ts
const filters = reactive({ search: '', status: null as string | null });
useQueryObject(filters, { only: ['invoices'] });

A text field bound straight to filters.search would visit on every keystroke, so name the keys that should wait for quiet in debounce:

ts
useQueryObject(filters, { only: ['invoices'], debounce: { search: 300 } });

A change to any other key visits at once and carries the waiting value with it, so nothing typed is lost. Copies added before this option existed are flagged by npx bitboss-ui helpers list; helpers update brings them up to date.

Bind these straight into the components. A BbSelect filter and a BbTable's v-model:sort are both plain refs, and once they are URL-backed the page is shareable, restorable and back-button-correct without any extra code:

vue
<BbSelect v-model="status" :items="statuses" label="Status" />
<BbTable v-model:sort="sort" :items="rows" :columns="columns" />

Sharing data down a page

Keyed contexts, instead of prop drilling. usePaginatedResponseContext publishes a paginated prop, so a toolbar, a table and a pager can each read it where they stand. Pass it as Laravel sends it: a $query->paginate() or ->simplePaginate() (page numbers at the top level), an API Resource collection (data, meta and links), or a { data, meta } you built. The context finds the page numbers in any of them. simplePaginate() sends no total, so the context counts the rows so far and treats the next page as the last.

Its page and perPage are the server's answer, not the URL: a clamped ?page=99, a capped ?per_page=500 or a redirect to the bare URL show what the server served, and nothing is written back after a visit, so a redirect is one visit, not two. items resets when the prop changes (Back and Forward included), not on every visit, so a partial reload of another prop keeps the rows' edits.

useTableSelectionContext does the same for a table's selection, which is what lets a bulk-action bar outside the table know what is selected inside it. useTableContext combines the paginated context and the selection under one key. These keyed contexts hold what the server sent; the table's own widget state (selection, sort, highlight) is useBbTableContext.

Forms that return promises

usePromiseForm is Inertia's useForm with promise semantics: post, put, patch and remove resolve on success and reject on validation errors. form is still the underlying Inertia form, so form.errors, form.isDirty and form.reset() all work.

The payoff is that you can await a submit, which is exactly what the library's own automatic loading states expect. Return the promise from the click handler:

resources/js/Pages/Customers/Edit.vue
<script setup lang="ts">
import { BbButton, BbTextInput } from 'bitboss-ui';
import { usePromiseForm } from '@/composables/inertia-helpers/usePromiseForm';

type CustomerDraft = { name: string };

const props = defineProps<{ customer: { id: number; name: string } }>();
const { form, put } = usePromiseForm<CustomerDraft>({
    name: props.customer.name,
});

// A 422 rejects the promise; form.errors already holds the messages.
const save = () =>
    put(`/customers/${props.customer.id}`).catch(() => {});
</script>

<template>
    <BbTextInput
        id="customer-name"
        v-model="form.name"
        :errors="form.errors.name"
        label="Name"
        name="name"
    />
    <BbButton @click="save">Save</BbButton>
</template>

BbButton shows its spinner and disables itself for the life of that promise, with nothing else written:

Always end the call with .catch() or await it in a try/catch. A bare put(…) that meets a 422 becomes an Uncaught (in promise) in the console. useConfirm's onClick takes the same promise.

Declare the draft as a type alias, like CustomerDraft above, not an interface. The generic is constrained to Record<string, FormValue>, and only a type alias gets the implicit index signature that satisfies it (nested shapes included). An interface fails to compile, however plain its fields.

A non-Inertia response (a 423 entity lock, a 419 CSRF mismatch) fires neither onSuccess nor onError, only onFinish. The helper settles the promise there too, so a failed visit releases the busy state instead of hanging the form forever.

onError means the server answered with validation errors, nothing else. A dropped connection arrives on onNetworkError and a server crash on onHttpException; wire the first to a toast at least, or a submit on a dead connection goes quiet.

When the fields sit in a BbForm, those validation errors reach them through the form's setErrors, as the next section shows. The rules for every error source, Laravel's included, are in Validated forms.

Laravel errors in a BbForm

When the fields live in a BbForm, hand Laravel's errors to the form in onError. Each message then shows under the field whose name matches its key. The values live in the form's model, so the visit is a plain router.put, wrapped in a promise that settles in onFinish:

resources/js/Pages/Customers/Edit.vue
<script setup lang="ts">
import { reactive } from 'vue';
import { router } from '@inertiajs/vue3';
import {
    BbButton, BbForm, BbTextInput, useBbFormContext, useToast,
} from 'bitboss-ui';

type Customer = { id: number; name: string; email: string };

const props = defineProps<{ customer: Customer }>();
const values = reactive({
    name: props.customer.name,
    email: props.customer.email,
});
const form = useBbFormContext('customer');
const { toast } = useToast();

const save = () =>
    new Promise<void>((resolve) =>
        router.put(`/customers/${props.customer.id}`, values, {
            onError: (errors) => form.setErrors(errors),
            onNetworkError: () =>
                toast({ text: 'No connection.', variant: 'destructive' }),
            onFinish: () => resolve(),
        }),
    );
</script>

<template>
    <BbForm
        id="customer"
        :model="values"
        @submit="save"
    >
        <BbTextInput
            id="customer-name"
            label="Name"
            name="name"
        />
        <BbTextInput
            id="customer-email"
            label="Email"
            name="email"
            type="email"
        />
        <BbButton type="submit">Save</BbButton>
    </BbForm>
</template>

Name the fields with Laravel's keys: address.city and lines.0.qty work as they are. A key no field owns lands in the form's unmatchedErrors instead of disappearing. The form moves focus to the first failing field on screen. Laravel stays the validator: add rules or a schema only for checks worth running before the request.

With Inertia's useForm, bind the object to the form with <BbForm :model="inertiaForm"> and declare every field in useForm({ … }): Inertia posts only the keys it was created with. Then hand the errors over with :field-errors="inertiaForm.errors": a message shows on its field for as long as Inertia holds it. inertiaForm.processing gives way to the form's own lock: while the promise returned from @submit runs, the fields are read-only and the submit button spins. The BbForm page covers the 422 from a plain fetch and Laravel Precognition.

Guarding a dirty form

useConfirmOnRouteLeave intercepts Inertia visits and asks through useConfirm before leaving. Like any other confirm, it renders in the <BbHost /> beside the page.

Bind disabled to !form.isDirty and the guard only appears when there is something to lose. Server redirects after a whitelisted action carry an X-Whitelisted-Action header, so a saving visit's own redirect is never blocked by the guard it just triggered, which is the failure this exists to prevent.

The dialog's words and look are BbConfirm's own options: title, text, yes, no (a label, or a full button config) and variant. They default to English ('Discard changes' / 'Keep editing', destructive), so a localized app passes its own:

ts
useConfirmOnRouteLeave({
    disabled: () => !form.isDirty,
    title: t('leave.title'),
    text: t('leave.text'),
    yes: t('leave.discard'),
    no: t('leave.stay'),
});

useHasChanged gives you the same dirty signal for state that is not an Inertia form.

Page chrome

Two more pieces are app-side code. The app layout is the left navigation, the footer and the page's one <main>. PageShell wraps each page in a single topbar, the content, and a right sidebar that opens a record without leaving the list: peek(record) opens it, stackItem(related) drills into a related record with a back arrow. Keep one topbar per page, and put app-wide utilities such as search or a user menu into it rather than stacking a second bar above. The code to copy is the Inertia layout scaffold recipe in the package, in node_modules/bitboss-ui/dist/ai/recipes/inertia/layout-scaffold.md.

The rest of the toolkit

The remaining helpers are small and independent. Take the ones you need:

HelperFor
useLoadingWrapping any async function with a loading ref
useAsyncFnThe same, with the result and error surfaced
useResourceContextSharing one resource's state across a page
useDeleteCmdThe delete-with-confirm command, wired once
useScreenSizesBreakpoint state for adaptive layouts
useIndexByIdTurning a list into a keyed lookup
useUntilAwaiting a reactive condition

None of them are required. The three that change how a page is written are useRouteQuery, usePromiseForm and the paginated context. Start there and add the rest when a page asks for them.

Adopt them in that order. Put one filter in the URL, convert one submit to a promise, then introduce a context only when two separate regions need the same page data. Copying the full toolkit up front adds dependencies and abstractions before the page has a use for them.