DEV Community

Daiki Urata
Daiki Urata

Posted on Originally published at guren.dev

Inertia live props on Bun: one page updates when another one saves

A page that goes stale

An order page shows "Preparing". In the kitchen, someone marks the order ready. The customer's page still says "Preparing" until they refresh.

There are two common fixes. The page can poll, asking the server every few seconds whether anything changed. Or the component can subscribe to a channel and call a reload from the handler, which works, and which every page then rebuilds by hand: the channel name is written a second time in the component, two events close together cause two reloads, and nothing catches up after the connection drops.

What Inertia 3.11 added

Inertia 3.11 added live props. The server names, for each prop, the channels and events that mean "this changed". The browser subscribes, and when one of those events arrives, Inertia fetches that one prop again with a partial reload. Throttling, hidden tabs and reconnects are handled once, below the page component.

That release is the reason for this post. I maintain Guren, a Laravel-shaped fullstack TypeScript framework that runs on Bun and Hono and renders pages with Inertia and React, and when live props came out I wanted Guren apps to have them too. What Inertia needs from a backend is the declaration in the page it renders, and a transport that delivers the events. Since 2.36 Guren has both: a live() helper for controllers, and a transport that uses the WebSocket endpoint already built into the framework. Laravel apps get the same feature through the official adapter. This post is about what it looks like on a TypeScript backend.

The demo

A small diner app with two pages. The kitchen board lists orders and moves each one through its steps. The tracking page shows one customer their order.

The left window is the kitchen, the right one is the customer. Nobody touches the right window. In the recording its page changed 0.12 to 0.17 seconds after each click, with both browsers on one machine.

The code is at github.com/gurenjs/live-props-demo.

Building it

Start from a new app and add broadcasting:

bunx create-guren-app guren-diner --db sqlite
cd guren-diner
bunx guren add broadcasting
Enter fullscreen mode Exit fullscreen mode

The second command registers a broadcast manager with an in-memory driver, which is enough for one server process.

Live props subscribe over a socket, so the app needs a socket route:

// routes/web.ts
import { type BroadcastManager, getRequestContainer, Router } from '@guren/core'

export function registerWebRoutes(router: Router): void {
  router.get('/broadcasting/socket', (c, next) =>
    getRequestContainer(c).make<BroadcastManager>('broadcast').webSocketMiddleware()(c, next),
  )
}
Enter fullscreen mode Exit fullscreen mode

The browser needs the transport. In resources/js/app.tsx, the one added option is live:

// resources/js/app.tsx
void Promise.all([import('@guren/inertia-client'), import('@guren/inertia-client/live')]).then(
  ([{ startInertiaClient }, { liveTransport }]) =>
    startInertiaClient({
      pages,
      pageManifest,
      live: liveTransport(),
    }),
)
Enter fullscreen mode Exit fullscreen mode

Next, an event that says which order changed and which channel carries the news:

// app/Events/OrderUpdated.ts
import { Event } from '@guren/core'

export class OrderUpdated extends Event {
  static override eventName = 'OrderUpdated'

  constructor(readonly orderId: number) {
    super()
  }

  broadcastOn(): string[] {
    return [`orders.${this.orderId}`]
  }

  broadcastWith(): Record<string, unknown> {
    return { orderId: this.orderId }
  }
}
Enter fullscreen mode Exit fullscreen mode

The payload is the id and nothing else. The event tells the browser to ask again. It does not carry the order.

Now the controller. This is the line the feature is named after:

// app/Http/Controllers/OrderController.ts
async show(): Promise<Response> {
  const { id } = this.validateParams(OrderIdParams)
  const order = await Order.findOrFail(id)
  return this.inertia(
    pages.orders.Show,
    { order: live(order, { on: new OrderUpdated(order.id) }) },
    { title: `Order #${order.id} | Guren Diner` },
  )
}
Enter fullscreen mode Exit fullscreen mode

live() takes an event instance, so it can read the channel for this particular order off it. The same instance shape is what gets broadcast when the kitchen changes something:

async #setStatus(id: number, status: OrderStatus): Promise<void> {
  await Order.update({ id }, {}, { set: { status, updatedAt: new Date().toISOString() } })
  const broadcast = this.make<BroadcastManager>('broadcast')
  await broadcast.broadcastEvent(new OrderUpdated(id))
  await broadcast.broadcastEvent(new OrdersChanged())
}
Enter fullscreen mode Exit fullscreen mode

OrdersChanged is a second event on a shared orders channel. The kitchen board's list is a live prop too, so two kitchen screens stay in step with each other.

The React page did not change. It declares order in its Props and renders it, the same as before the prop was live.

What goes over the wire

The first response for /orders/2 carries the prop and, next to it, what to listen for:

{
  "component": "orders/Show",
  "props": {
    "order": { "id": 2, "customer": "Ben", "item": "Katsu curry", "status": "received" }
  },
  "liveProps": {
    "order": {
      "listeners": [{ "channel": { "name": "orders.2", "type": "public" }, "events": ["OrderUpdated"] }]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The browser subscribes to orders.2 on the socket. When OrderUpdated arrives, Inertia sends an ordinary partial reload for order. That request goes through the route's middleware and the controller like any other, which has a useful consequence: a live prop can never show a user more than the page itself would.

A few defaults are worth knowing. Each prop reloads at most once a second, so a burst of events becomes one request. A hidden tab makes no requests and catches up when it becomes visible. After a dropped connection, the transport reconnects and Inertia reloads every live prop, because events sent in the gap never arrived.

Things to get right

Pin the event's name. Without static eventName, the name is the class name, and a production bundler can rename a class when two modules declare the same one. The prop then stops refreshing and nothing reports an error. bunx guren check warns about an unpinned name.

Use a private channel for anything a stranger should not hear about. The demo has no login, so its channels are public: anyone can subscribe to orders.2 and learn that order 2 changed, though not what it changed to. With users, register an authorizer for the channel and it decides who may subscribe.

Skip the sender. The person who clicked already has the new state from their own response. Passing the request as except keeps their tab from reloading a second time:

await broadcast.broadcastEvent(new OrderUpdated(order.id), { except: this.ctx })
Enter fullscreen mode Exit fullscreen mode

When this is the wrong tool

  • Data that changes many times a second, such as cursor positions or typing indicators. A reload per event is too heavy. Subscribe to the channel directly and use the payload.
  • A host with no long-lived socket. On Cloudflare Workers, AWS Lambda and Vercel the socket route answers 501. The page still renders and works with the props it was given, but it does not update. Live props need a Bun server process.
  • More than one server process with the in-memory driver. An event broadcast on one process does not reach browsers connected to another. Use the Redis driver there.

Try it

bunx create-guren-app my-app
cd my-app
bun run dev
Enter fullscreen mode Exit fullscreen mode

The full guide, including deferred live props and private channels, is at Live Props. Guren itself is at github.com/gurenjs/guren.

Top comments (2)

Collapse
 
officialmailkr profile image
오피셜메일 •

이벤트에 주문 내용 대신 ID만 보내고 기존 조회 경로로 다시 읽는 구분이 이해하기 좋네요. 특히 끊긴 동안의 이벤트를 재연결 후 다시 조회로 보완한다는 부분이 핵심으로 보입니다. 연결이 끊겼을 때 화면에 마지막 갱신 시각이나 연결 상태도 보여주면, 사용자가 오래된 Preparing을 현재 상태로 오해하는 상황을 점검하기 좋겠어요.

Collapse
 
7nohe profile image
Daiki Urata •

Thanks, that is the part I would point at too. The event is only a hint, and the reload after a reconnect is what makes a missed event harmless.

Good point about the time in between. While the socket is down, the page keeps showing the last props it received, and nothing in the demo tells the user so. The transport knows when it is disconnected (that is how it tells Inertia to reload once it is back), so a small "reconnecting" badge next to the status is a few lines, and the order already carries an updatedAt the page could show. Worth adding to the demo.