The UmimaClean Stack, Part 5: Transformers, Typed Routes, and Forms Without State

The layer between a model and a page - variants that encode who may see what, why depth is a property of the resource, Tuyau turning route names into compile-time checks, and Inertia 2 forms that need no useState at all.

A 13-part series on the UmimaClean stack. 1 — AdonisJS · 2 — VineJS · 3 — Lucid · 4 — Inertia · 5 — Transformers · 6 — Transmit · 7 — Leaflet · 8 — Midtrans · 9 — Fonnte · 10 — ExcelJS · 11 — Frontend · 12 — Security · 13 — Testing

Part 4 covered how Inertia removes the API layer. This part is about the thing that replaces it.

With no API, something still has to decide what a model looks like when it reaches the browser. That decision is the most security-relevant one in the app, and it lives in a layer of about 200 lines.

Why Not Just Pass the Model

The tempting version:

return inertia.render('staff/task/index', { orders })

Lucid models serialise to JSON, so this works. It also means every column on the row is now in the page source of the browser, including the ones the component does not render. “Not displayed” is not “not sent” — it is one View Source away.

For a staff task board, that is a real problem. The board is a list of unclaimed work visible to every staff member on shift. Passing the model would publish every waiting customer’s name, phone number, and home address to everyone who opens the page.

So every model goes through a transformer, and transformers do three things: pick fields, resolve relations, and normalise types.

Variants Encode Permission

A transformer is a class with named variants. OrderTransformer has four, and the differences between them are decisions, not conveniences.

toObject — the order’s own columns:

toObject() {
  return {
    ...this.pick(this.resource, ['id', 'orderNumber', 'customerName', 'customerPhone']),

    totalPrice: this.resource.totalPrice === null ? null : Number(this.resource.totalPrice),
    status: this.resource.status,
    type: this.resource.type,
    pickupDate: this.resource.pickupDate?.toISODate() ?? null,
    createdAt: this.resource.createdAt.toISO(),
  }
}

toQueue — what the task board gets:

/**
 * Deliberately the order number and the two fields the badge is drawn from,
 * and deliberately nothing else. A queue is a list of work that is up for
 * grabs, seen by every staff member on shift — it is not a place to publish
 * customer names, phone numbers and home addresses. Those appear once the
 * task has been claimed, attributed to the person who claimed it.
 */
toQueue() {
  return {
    ...this.pick(this.resource, ['id', 'orderNumber']),

    status: this.resource.status,
    type: this.resource.type,
  }
}

Four fields. That comment is the security model of the staff area, written where the code that implements it lives.

The rule it encodes: you see the customer’s details once you have taken the job under your own name. Not because the UI hides them until then — because the server does not send them until then. An unclaimed stop is an order number, a badge, and a distance. Claim it and the next render includes the address you now need.

toListItem — a row in an admin table:

/**
 * Both relationships are optional here. A caller that has not preloaded them
 * simply gets an order without them rather than a broken transform, which is
 * what lets the monitor and the reconciliation backlog share one variant.
 */
toListItem() {
  return {
    ...this.toObject(),
    address: AddressTransformer.transform(this.whenLoaded(this.resource.address)),
    transactions: TransactionTransformer.transform(this.whenLoaded(this.resource.transactions)),
  }
}

whenLoaded is the tolerance that makes sharing possible. The order monitor preloads transactions; the reconciliation backlog preloads them differently. Without it, one variant would throw for the other’s query and you would end up with two nearly identical variants that drift.

toDetail — everything a detail screen needs:

toDetail() {
  return {
    ...this.toObject(),

    user: UserTransformer.transform(this.whenLoaded(this.resource.user)),
    address: AddressTransformer.transform(this.whenLoaded(this.resource.address)),
    items: OrderItemTransformer.transform(this.whenLoaded(this.resource.items))
      ?.useVariant('toDetail')
      .depth(2),
    actions: OrderActionTransformer.transform(this.whenLoaded(this.resource.actions))
      ?.useVariant('toDetail')
      .depth(2),
    transactions: TransactionTransformer.transform(this.whenLoaded(this.resource.transactions)),
  }
}

Depth Belongs to the Resource

Nested relations serialise one level by default. An order line needs its item and its service — two levels down — so it asks for .depth(2).

The decision worth stealing is where that lives:

/**
 * The depth lives here rather than at each call site: how deep an order has
 * to be read to be useful is a fact about an order, not about the particular
 * controller asking for one.
 */

Put .depth(2) in the controller and every controller rendering an order detail has to know it. Miss it in one and that screen renders a receipt with no service names — a silent, screen-specific bug. Putting it in the transformer means the answer is given once by the thing that knows it.

Values, Not Labels

This one changed during the project and the change is instructive.

The app is entirely in Indonesian, so the first version had transformers send display strings: status: "Menunggu Pelunasan". Pages could print the prop directly, which felt clean.

It was a mistake, and the current comment says why:

/**
 * Statuses, types, money and dates all go out as they are stored. Screens
 * differ in how they print them — a badge, a table cell, a printed receipt —
 * and the moment the label is baked in here, every one of those screens is
 * matching on Indonesian prose to decide what to do.
 */

Two failures follow from label-as-wire-format.

Renaming a label breaks logic, not just wording. Any page doing order.status === 'Menunggu Pelunasan' to decide whether to show a pay button is now coupled to a translation string. Change the wording and the button disappears, with no compile error and no test failure unless one happens to assert on it.

One value cannot have two presentations. A badge wants a short label, a receipt wants a formal one, a chart legend wants something that fits. If the server picked, all three get the same string.

Sending 'awaiting_payment' and letting the page decide fixes both. The label maps live next to the enums:

export const OrderStatusLabel = {
	[OrderStatus.PICKUP_SCHEDULED]: 'Penjemputan Dijadwalkan',
	[OrderStatus.AWAITING_PAYMENT]: 'Menunggu Pelunasan',
	[OrderStatus.CLEANING_DONE]: 'Siap Diambil'
	// ...
} as const

The same rule applies to the realtime layer in Part 6 — broadcasts carry stored values too, for exactly the same reason.

Money and dates are normalised, not formatted. Number(totalPrice) because Postgres returns numeric as a string (Part 3); toISODate() and toISO() because ISO strings are unambiguous and the page formats them for display. Send "28 Juli 2026" and no page can sort by date.

Pagination

Lists go through a paginate helper that carries the metadata with them:

const orders = await this.orderService.getAllOrders(filters, user)

return inertia.render('customer/order/index', {
	orders: OrderTransformer.paginate(orders.all(), orders.getMeta()).useVariant('toListItem'),
	filters
})

filters goes back too. The page needs to render the search box with the current term in it and build page links that keep the filter — and the server already parsed and normalised those values, so echoing them back beats having the client re-parse its own query string.

Tuyau: Route Names Checked at Compile Time

The last place a frontend and backend can silently disagree is URLs. A component with href="/staff/trips" keeps compiling forever after that route is renamed.

Tuyau generates a typed registry from the Adonis routes at init (Part 1 covers the hook), and the client is three lines:

import { registry } from '@/generated/registry'
import { createTuyau } from '@tuyau/core/client'

export const client = createTuyau({
	baseUrl: window.location.origin,
	registry
})

export const urlFor = client.urlFor

Provided once at the root, so components take routes by name:

<Link route="customer.address.show" className="...">
	<IconArrowLeft className="size-5" />
</Link>
<Form route="customer.address.store" className="space-y-5">

Rename customer.address.store in start/routes.ts and every reference fails to compile. Not a 404 in production — a build error, immediately.

This is the same category of win as generated model schemas in Part 3: take a thing that was kept in sync by discipline and make it kept in sync by the compiler.

Forms With No State

This is the part of the frontend I did not expect to like.

Standard React form handling means controlled inputs: useState per field, an onChange per field, useState for errors, useState for submitting, a submit handler that serialises and posts. Inertia’s own useForm improves it, but you are still holding form state in the component.

Inertia 2’s <Form> component removes it entirely:

<Form route="customer.address.store" className="space-y-5">
	{({ errors, processing }) => (
		<>
			<Field data-invalid={errors.name ? 'true' : undefined}>
				<FieldLabel htmlFor="name">Nama Lengkap</FieldLabel>
				<Input
					id="name"
					name="name"
					type="text"
					autoComplete="name"
					defaultValue={address?.name}
					aria-invalid={!!errors.name}
				/>
				<FieldError>{errors.name}</FieldError>
			</Field>

			{/* ... */}

			<Button type="submit" disabled={processing}>
				Simpan
			</Button>
		</>
	)}
</Form>

Uncontrolled inputs — name and defaultValue, no value/onChange. The <Form> reads the DOM on submit, posts to the named route, and gives back errors and processing through a render prop.

No useState anywhere in this form. For a form with eight fields, that is eight state variables and eight handlers that do not exist.

What follows from it:

  • Errors come from the server, through the shared props from Part 4. No client validation to keep in sync with the VineJS rules.
  • processing disables the submit button, which is the entire double-submit story.
  • The browser does browser things. autoComplete="name" works. Password managers work. Autofill works. Controlled React inputs fight all three.

The map on this form is the one place state is needed, because the position is genuinely shared between a map and a form:

const [position, setPosition] = useState(
	address ? latLng(address.latitude, address.longitude) : latLng(-6.2088, 106.8456)
)

And it reaches the server through hidden inputs, so the map participates in the same plain form post as everything else:

<input type="hidden" name="latitude" value={value.lat} />
<input type="hidden" name="longitude" value={value.lng} />

State where state is real, and nowhere else. That is the rule, and Inertia’s <Form> is what makes “nowhere else” achievable rather than aspirational.

Generated Types Close the Loop

Pages import types the backend generated:

import type { Data } from '@/generated/data'
import type { InertiaProps } from '@/types'

type PageProps = InertiaProps<{
	address: Data.Address | null
}>

Data.Address is generated from AddressTransformer by the indexEntities hook. InertiaProps merges in the shared props, so user and flash are typed on every page without being declared.

Which means the chain from database to component is typed end to end, and every link is generated rather than written:

migration  →  database/schema.ts  →  model  →  transformer  →  Data.*  →  page props

Change a column, run the migration, and a page reading a field that no longer exists fails to compile. No API contract, no shared types package, no code generation step anyone has to remember — the dev server does it.

What I Would Do Differently

Some variants are close enough to merge. toListItem and toDetail share most of their body. Not worth the abstraction yet, but a third list variant would be.

whenLoaded returning nothing is quiet. A controller that forgets to preload gets a page with a missing section rather than an error. Correct for shared variants, and it means a preload bug looks like a UI bug. A dev-mode warning would be the right compromise.

Nothing enforces “use the right variant”. A controller can render toDetail for a queue screen and leak the fields toQueue exists to withhold. The type system does not know one variant is more privileged than another. Naming and the functional tests that assert on props are the actual guard, and both are conventions rather than guarantees.

That last one is the honest weak point of this layer. What it has going for it is that the decision is at least in one place, written down, with a reason attached — which is more than “the component does not render that field” ever gets you.


Next: Part 6 — Transmit: Realtime with Server-Sent Events, where two channels and no WebSocket keep a payment page honest.

© 2026 r3p.dev. All rights reserved.