Frontend
Live
Live components are classes on the server that render TSX and answer the browser's clicks and input. You write no client code: Live sends each action to the server and swaps in the new HTML.
Introduction#
A Live component keeps its state in public fields and its actions in public methods. Its page carries the state,
signed; when the user clicks a button with o:click="increment", the browser sends the state and the call, the
server runs increment() and renders the component again, and the browser patches the page with the new HTML.
Live follows Livewire 3: its components, directives, hooks and events work as Livewire's do, with o: in place of
wire:. The Live starter kit (npm create @orieljs@latest blog -- --live) is built with it, and is a good place to see
whole pages.
Live is part of @orieljs/. Import everything from there: @orieljs/ is published on its own too, but a
separate copy doesn't share the framework's per-request state.
The client script#
In the browser, @orieljs/ starts the components on the page. The Live kit starts it in
resources/, which the layout loads with vite():
import { Live } from '@orieljs/live-client'Live.start()Updates go to POST /, a route in the web middleware group, so your sessions, the signed-in user
and CSRF protection apply to them. The client sends the CSRF token from the XSRF-TOKEN cookie.
Components#
Creating components#
make:live writes a component in app/, and --test a test for it in tests/:
node oriel make:live Counternode oriel make:live Posts/CreatePost --testA component is the default export of its file. Give it state and actions:
import { Component } from '@orieljs/framework'export default class Counter extends Component { count = 0 increment() { this.count++ } decrement() { this.count-- } render() { return ( <div> <h1>{this.count}</h1> <button type="button" o:click="increment">+</button> <button type="button" o:click="decrement">-</button> </div> ) }}render() returns the component's HTML, with one root element. It can be async, to read the database.
Oriel registers the components in app/ at boot, by their path: app/ is named
posts.create-post. Live.component('counter', Counter) registers one that lives elsewhere.
Rendering components#
<Live> renders a component in any view, or in another component:
import Counter from './app/Live/Counter'const page = ( <main> <Live component={Counter} /> </main>)component also takes a registered name, <Live component="counter" /. Outside TSX, await live(Counter) gives the
component's first render: (await live(Counter)).toHtml() is its HTML.
Passing parameters#
The other props are the component's mount parameters. Each one whose name is a field of the component sets that field,
and mount() receives them all:
import Counter from './app/Live/Counter'const page = <Live component={Counter} count={3} />Full-page components#
A component can be a whole page: give it to a route as its action.
import { Route } from '@orieljs/framework'import Counter from '../app/Live/Counter'Route.get('/counter', Counter)The page is the component inside a layout view, components.layouts.app unless live.layout names another. The
layout receives the component's HTML as children, and static title as title. static layout picks a layout for
one component:
import { Component } from '@orieljs/framework'export default class Login extends Component { static override layout = 'components.layouts.auth' static override title = 'Log in' render() { return <form>...</form> }}The route's parameters are mount parameters. A parameter named after a model is bound
first, so post is the Post:
import { Component } from '@orieljs/framework'import type { Post } from '../Models/Post'export default class ShowPost extends Component { post!: Post render() { return ( <article> <h1>{this.post.title}</h1> </article> ) }}import { Route } from '@orieljs/framework'import ShowPost from '../app/Live/ShowPost'Route.get('/posts/{post}', ShowPost).name('posts.show')Properties#
A component's fields are its state. The page keeps them between requests, and render() reads them:
import { Component } from '@orieljs/framework'export default class TodoList extends Component { todos: string[] = [] todo = '' add() { this.todos.push(this.todo) this.reset('todo') } render() { return ( <form o:submit="add"> <input type="text" o:model="todo" aria-label="Todo" /> <button type="submit">Add</button> <ul> {this.todos.map((todo) => ( <li>{todo}</li> ))} </ul> </form> ) }}reset('todo') puts a field back to its first value, and reset() all of them.
A field holds a string, number, boolean, null, Date, an array or a plain object of those, a saved model, a
collection or an uploaded file. A model is kept as its class and key, and read from the database again on each update.
Anything else, such as an instance of your own class, throws when the component renders, naming the field.
Locked properties#
The browser can set a field: an input with o:model, or a crafted request. @Locked stops it, for a field your code
sets and the browser must not change, such as an ID:
import { Component, Locked } from '@orieljs/framework'export default class EditPost extends Component { @Locked id = 0 render() { return <div>{this.id}</div> }}A field whose name starts with _, and a field holding a model or a collection, can't be set from the browser either.
To edit a model's attributes, copy them to fields and save them in an action.
Computed properties#
@Computed caches a getter for the rest of the request. It isn't state, and isn't sent to the browser:
import { Component, Computed } from '@orieljs/framework'import { User } from '../Models/User'export default class ShowUser extends Component { userId = 1 @Computed get user() { return User.findOrFail(this.userId) } async render() { const user = await this.user return <h1>{user.name}</h1> }}Decorators work in your app, under Bun and Node, and in your tests: a new app's vitest.config.ts has the
decorators() plugin that lowers them for Node. Without decorators, static locked = ['id'],
static computed = ['user'] and static listeners = { 'post-created': 'updatePostList' } do the same.
Actions#
A public method is an action, which o:click, o:submit or any other o:<event> calls:
<button type="button" o:click="save">Save</button>Hooks, getters, @Locked methods, and methods whose name starts with _ or # aren't actions.
Passing parameters#
The call is written as the method's name, with its arguments in JSON (single quotes work too, as in 'Hello'):
<button type="button" o:click={`delete(${post.id})`}>Delete</button>this.$action() writes it for you, typed against the method:
import { Component } from '@orieljs/framework'import { Post } from '../Models/Post'export default class Drafts extends Component { async publish(id: number) { const post = await Post.findOrFail(id) post.published_at = new Date() await post.save() } async render() { const drafts = await Post.whereNull('published_at').get() return ( <ul> {drafts.map((post) => ( <li> {post.title} <button type="button" o:click={this.$action(this.publish, post.id)}> Publish </button> </li> ))} </ul> ) }}For post 5, this renders o:click="publish(5)"; this.$action(this.publish, String(post.id)) is a type error, since
publish() takes a number. Pass IDs and plain values: a model arrives as its attributes, and a date as its ISO string.
Rendering throws when the method isn't one of the component's actions.
In TSX, the o: attributes are type-checked: Views shows what TypeScript catches.
Event modifiers#
Modifiers follow the event, joined by -:
const form = ( <form o:submit="save"> <input type="text" o:keydown-enter="search" aria-label="Search" /> <a href="#" o:click-prevent="toggle"> Toggle </a> </form>)| Modifier | What it does |
|---|---|
prevent | Calls preventDefault() |
stop | Calls stopPropagation() |
self | Runs only when the event's target is the element itself |
once | Runs once |
window, document | Listens on the window, or the document |
passive, capture | Accepted, and change nothing: Live listens once for each type of event |
A key, such as enter, escape, space, tab or down | On a key event, runs only for that key |
o:submit always prevents the form's own submission.
Magic actions#
| Action | What it does |
|---|---|
$refresh | Renders the component again |
$set('title', 'Hello') | Sets a field |
$toggle('open') | Flips a boolean field |
$dispatch('post-created', { 'id': 1 }) | Dispatches an event, in the browser |
$commit | Sends the pending o:model values |
<button type="button" o:click="$toggle('open')">Menu</button>Confirming an action#
o:confirm asks before the action runs:
<button type="button" o:click={`delete(${tag.id})`} o:confirm="Delete this tag?"> Delete</button>The browser's confirm dialog shows the message; an empty o:confirm asks "Are you sure?". o:confirm-prompt asks
for a typed answer, and runs the action only when it matches the text after the |:
<button type="button" o:click="destroy" o:confirm-prompt="Type DELETE to confirm|DELETE"> Delete account</button>A dismissed dialog stops the event, so the actions around the element don't run either, and doesn't use up a -once
action. On a form, put o:confirm on the <form> with its o:submit. A button inside a form with its own o:click
needs type="button", or the browser submits the form when the dialog is dismissed.
Data binding#
o:model binds an input to a field. The value is sent with the component's next request:
const input = <input type="text" o:model="title" aria-label="Title" />| Directive | Sends the value |
|---|---|
o:model | With the next action |
o:model-live | As you type, after 150 ms without a key press |
o:model-live-debounce-500ms | As you type, after 500 ms |
o:model-blur | When the input loses focus |
o:model-change | When the input's value changes (Livewire's .lazy, which works too) |
o:model-number | With the next action, as a number |
Checkboxes, radio buttons and selects are bound the same way. A checkbox gives true or false, or with a field that
is an array, the values of the checked boxes.
A dotted name binds a nested value: o:model="form.title".
Lifecycle hooks#
| Hook | Runs |
|---|---|
boot() | On every request, first |
mount(params) | Once, when the component is first rendered |
hydrate() | On every update, before anything else changes |
updating(name, value), updated(name, value) | Before and after the browser sets a field |
updatingTitle(value, key), updatedTitle(value, key) | Before and after the browser sets title |
rendering(), rendered(html) | Before and after render() |
dehydrate() | On every request, last |
A hook can be async. updatedTitle() is named after the field in StudlyCase, and on a nested name, such as
form.title, updatedForm() runs with key set to 'title'.
import { Component } from '@orieljs/framework'export default class Search extends Component { search = '' updatedSearch(value: string) { this.search = value.trim() } render() { return <input type="search" o:model-live="search" aria-label="Search" /> }}Validation#
this.validate() checks fields against rules, and returns the valid values, typed from the rules. A failed
validation stops the action, and the component renders with its errors in this.errors:
import { Component, request, Str } from '@orieljs/framework'import type { User } from '../Models/User'export default class CreatePost extends Component { title = '' body = '' async save() { const data = await this.validate({ title: 'required|string|max:255', body: 'required|string', }) const user = (await request().user<User>())! await user.posts().create({ ...data, slug: Str.slug(data.title) }) this.redirect('/posts') } render() { return ( <form o:submit="save"> <input type="text" o:model="title" aria-label="Title" /> {this.errors.has('title') && <p class="error">{this.errors.first('title')}</p>} <textarea o:model="body" aria-label="Body" /> {this.errors.has('body') && <p class="error">{this.errors.first('body')}</p>} <button type="submit">Save</button> </form> ) }}addError(key, message) adds an error of your own, and resetErrorBag() clears them, or those of the keys you give.
Validation lists the rules.
A component's fields aren't a request's input, so an empty one is '', not null: nullable|integer passes an
empty age and returns ''. Start such a field at null (age: number | null = null) when it is optional.
Events#
this.dispatch() sends an event. Any component on the page with an @On method for it runs the method, with the
event's parameters:
import { Component } from '@orieljs/framework'export default class CreatePost extends Component { title = '' save() { // ... this.dispatch('post-created', { title: this.title }) } render() { return <form o:submit="save">...</form> }}import { Component, On } from '@orieljs/framework'export default class Dashboard extends Component { status = '' @On('post-created') updatePostList({ title }: Record<string, unknown>) { this.status = `Created ${String(title)}` } render() { return <p>{this.status}</p> }}dispatch(...).to(Dashboard) sends it only to the components of that class, and dispatch(...).self() only to the
component that sent it. A component listening for an event re-renders after its method runs.
Events are browser events on window, so your scripts can send and receive them:
import { Live } from '@orieljs/live-client'Live.dispatch('post-created', { title: 'Hello' })Live.on('post-created', ({ title }) => { console.log(title)})Loading states#
o:loading shows an element while the component's request runs:
const form = ( <form o:submit="save"> <button type="submit">Save</button> <span o:loading>Saving...</span> </form>)| Directive | While the request runs |
|---|---|
o:loading | Shows the element, which is hidden otherwise |
o:loading-remove | Hides the element |
o:loading-class="opacity-50" | Adds the classes (o:loading-class-remove removes them) |
o:loading-attr="disabled" | Sets the attribute |
o:loading-delay | Waits 200 ms first, so a quick request shows nothing |
o:target limits it to some actions or fields: o:target="save", or o:target="save,title".
Polling#
o:poll re-renders the component every 2.5 seconds, or calls a method:
const stats = ( <div o:poll-15s="refreshStats"> <p>Visitors today</p> </div>)o:poll-500ms and o:poll-15s change the interval. Polling pauses while the browser tab is in the background, unless
you add -keep-alive, and with -visible it runs only while the element is in view.
Offline state#
o:offline shows an element while the browser is offline, and takes the same -remove, -class and -attr forms as
o:loading:
<p o:offline>You are offline.</p>Nesting components#
A component can render other components with <Live>. Give each child in a list an o:key, so Live knows which is
which when the list changes:
import TodoItem from './app/Live/TodoItem'declare const todos: { id: number; title: string }[]const list = ( <ul> {todos.map((todo) => ( <Live component={TodoItem} o:key={todo.id} todo={todo} /> ))} </ul>)A child keeps its own state. When the parent renders again, a child that is already on the page isn't mounted again: send it new values with an event.
Lazy loading#
A slow component can render a placeholder first, and load once it scrolls into view:
import { Component } from '@orieljs/framework'import { Post } from '../Models/Post'export default class PostCount extends Component { static override lazy = true count = 0 async mount() { this.count = await Post.count() } placeholder() { return <p>Counting posts...</p> } render() { return <p>{this.count} posts</p> }}lazy on one <Live> makes that one lazy, lazy="on-load" loads it as soon as the page has loaded, and
lazy={false} turns static lazy off. Without placeholder(), the placeholder is the live.lazy_placeholder view,
or an empty <div>. Give the placeholder the same root element as render().
Pagination#
WithPagination gives a component a page, which paginate() uses:
import { Component, Pagination, WithPagination } from '@orieljs/framework'import { Post } from '../Models/Post'export default class Posts extends WithPagination(Component) { search = '' async updatedSearch() { await this.resetPage() } async render() { const posts = await Post.whereLike('title', `%${this.search}%`).latest().paginate(10) return ( <div> <input type="search" o:model-live="search" aria-label="Search" /> {posts.items().map((post) => ( <p>{post.title}</p> ))} <Pagination paginator={posts} /> </div> ) }}<Pagination> renders the page buttons. The actions nextPage, previousPage, gotoPage(n), setPage(n) and
resetPage move the page; they are async, so await them in your own methods. The page starts at 1.
File uploads#
In a WithFileUploads component, o:model on a file input uploads the file as soon as it is picked, and sets the
field to a TemporaryUploadedFile:
import { Component, TemporaryUploadedFile, WithFileUploads } from '@orieljs/framework'export default class ProfilePhoto extends WithFileUploads(Component) { photo: TemporaryUploadedFile | null = null async save() { const { photo } = await this.validate({ photo: 'required|image|max:1024' }) await photo.store('photos') this.reset('photo') } render() { return ( <form o:submit="save"> <input type="file" o:model="photo" aria-label="Photo" /> {this.photo?.isPreviewable() && <img src={this.photo.temporaryUrl()} alt="Preview" />} <button type="submit">Save</button> </form> ) }}With multiple, the field is an array of files. A temporary file has the methods of an uploaded
file, such as store() and storeAs(), and temporaryUrl(), a signed link that previews an image,
audio or video.
Temporary files go in live-tmp on your default disk, and Storage explains how they are
cleaned up. o:loading shows while a file uploads, and these browser events follow it, on the input:
live-upload-start, live-upload-progress (with detail.progress, from 0 to 100), live-upload-finish,
live-upload-error and live-upload-cancel. Their names have a -, so listen for them with addEventListener()
rather than an o: directive.
Navigating#
o:navigate on a link shows the next page without a full reload. Live fetches the page, merges its <head>, swaps
the <body> and starts its components:
const nav = ( <nav> <a href="/dashboard" o:navigate> Dashboard </a> <a href="/posts" o:navigate-hover> Posts </a> </nav>)The fetch starts on mousedown, or with -hover, when the pointer rests on the link. The new page's scripts run,
except a <head> script the page already has, and a <body> script marked data-navigate-once that an earlier page
ran. Unlike a full load, an inline script runs as soon as it is inserted, without waiting for an external script above
it; write the URLs in <head> absolute or from the root (/build/).
Some pages load in full instead: one that a redirect takes to another origin, and one whose tracked assets changed.
The tags vite() writes carry data-navigate-track, so after a deploy, the first page with the new build's files
loads in full, rather than run the new bundle beside the old one.
Redirects#
An action redirects with this.redirect(url), or this.redirectRoute(name, params) for a named route. Add
{ navigate: true } to go there as o:navigate does:
component.redirect('/posts', { navigate: true })A message an action flashes, session().flash('status', 'Saved.'), is in that update's response only, unless the
action redirects: then it is there on the next page.
Middleware on updates#
Each update runs the auth, verified, guest and can middleware of the route that rendered the page again, with
that route's parameters, before the component's actions. A user who signed out in another tab gets a 401, and one who
lost an ability a 403. The list is live.persistent_middleware.
Announcing status messages#
Screen readers announce a status region, role="status", when its text changes. When an action brings the same
message again, such as "Saved" after a second save, nothing changes and nothing is announced. o:announce makes Live
take the message out and put it back, so it is announced again:
import { Component } from '@orieljs/framework'export default class Settings extends Component { name = '' #saved = false save() { this.#saved = true } render() { return ( <form o:submit="save"> <input type="text" o:model="name" aria-label="Name" /> <button type="submit">Save</button> <p role="status" o:announce> {this.#saved ? 'Saved' : ''} </p> </form> ) }}Only actions do this: an update that only sends o:model values, refreshes or delivers an event leaves the region
alone. Keep a status for one response, in a #private field or a flashed message, so the next update clears it. Keep
the region to text: its children are taken out for 100 ms, so a button in it would lose focus.
The JavaScript API#
window.Live is the client, once Live.start() has run:
| Method | What it does |
|---|---|
Live.find(id), Live.first(), Live.all(), Live.getByName(name) | Finds components on the page |
Live.dispatch(name, params), Live.dispatchTo(component, name, params) | Dispatches an event |
Live.on(name, callback) | Listens for an event; returns the function that stops listening |
Live.navigate(url) | Goes to a page as o:navigate does |
Live.settled() | Resolves once no request or upload is running |
A component has get(name), set(name, value), which sends the value with the next request, call(method, params),
with the arguments in an array, call('publish', [5]), which resolves with the action's return value, and commit().
Configuration#
Live's settings are under live. A new app has no config/; add one to change them:
| Key | Default | What it is |
|---|---|---|
class_path | app/ | The folder whose components are registered at boot |
layout | components.layouts.app | The layout of full-page components |
lazy_placeholder | none | The view lazy components show before they load |
persistent_middleware | auth, verified, guest, can | The page route's middleware each update runs again |
temporary_file_upload.disk | Your default disk | Where uploads wait until they are stored |
temporary_file_upload.directory | live-tmp | The folder on that disk |
temporary_file_upload.rules | required, file, max:12288 | The rules every upload must pass |
temporary_file_upload.middleware | throttle:60,1 | The upload route's middleware |
temporary_file_upload.max_upload_time | 5 | Minutes a signed upload URL stays valid |
temporary_file_upload.preview_mimes | Images, audio and video | The types temporaryUrl() previews |
temporary_file_upload.cleanup | true | Deletes temporary files older than a day |
Testing#
Live.test(Counter) mounts a component in a test, calls its actions and checks its HTML and state. Live
tests covers it.