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:
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.
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:
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:
<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.
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.
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:
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:
<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:
<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:
<template>
<div class="grid max-w-sm gap-3">
<BbTextInput
id="promise-save-name"
v-model="form.name"
label="Customer name"
name="customer_name"
/>
<div class="flex items-center gap-3">
<BbButton @click="save">Save</BbButton>
<p class="m-0 text-sm text-[var(--bb-text-muted)]" role="status">
{{ savedName ? `Saved “${savedName}”.` : '' }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { BbButton, BbTextInput } from 'bitboss-ui';
import { delay } from '~/demo-data';
const form = reactive({ name: 'Northwind Traders' });
const savedName = ref('');
// Stands in for usePromiseForm's put(): a visit that settles after 1.5 s.
const put = (url: string) => delay(url, 1500);
// The handler returns the promise, so the button spins until it settles.
const save = () =>
put('/customers/42').then(() => {
savedName.value = form.name;
});
</script>
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:
<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:
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:
| Helper | For |
|---|---|
useLoading | Wrapping any async function with a loading ref |
useAsyncFn | The same, with the result and error surfaced |
useResourceContext | Sharing one resource's state across a page |
useDeleteCmd | The delete-with-confirm command, wired once |
useScreenSizes | Breakpoint state for adaptive layouts |
useIndexById | Turning a list into a keyed lookup |
useUntil | Awaiting 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.