Skip to the content
OrielDocs
0.1.0-alpha.10GitHub

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/framework. Import everything from there: @orieljs/live 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/live-client starts the components on the page. The Live kit starts it in resources/js/app.ts, which the layout loads with vite():

resources/js/app.ts
import { Live } from '@orieljs/live-client'Live.start()

Updates go to POST /oriel/live/update, 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/Live, and --test a test for it in tests/Feature/Live:

Terminal
node oriel make:live Counternode oriel make:live Posts/CreatePost --test

A component is the default export of its file. Give it state and actions:

app/Live/Counter.tsx
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/Live at boot, by their path: app/Live/Posts/CreatePost.tsx 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.

routes/web.ts
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:

app/Live/Auth/Login.tsx
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:

app/Live/ShowPost.tsx
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>    )  }}
routes/web.ts
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:

app/Live/TodoList.tsx
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:

app/Live/EditPost.tsx
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:

app/Live/ShowUser.tsx
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:

app/Live/Drafts.tsx
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>)
ModifierWhat it does
preventCalls preventDefault()
stopCalls stopPropagation()
selfRuns only when the event's target is the element itself
onceRuns once
window, documentListens on the window, or the document
passive, captureAccepted, and change nothing: Live listens once for each type of event
A key, such as enter, escape, space, tab or downOn a key event, runs only for that key

o:submit always prevents the form's own submission.

Magic actions#

ActionWhat it does
$refreshRenders 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
$commitSends 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" />
DirectiveSends the value
o:modelWith the next action
o:model-liveAs you type, after 150 ms without a key press
o:model-live-debounce-500msAs you type, after 500 ms
o:model-blurWhen the input loses focus
o:model-changeWhen the input's value changes (Livewire's .lazy, which works too)
o:model-numberWith 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#

HookRuns
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'.

app/Live/Search.tsx
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:

app/Live/CreatePost.tsx
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:

app/Live/CreatePost.tsx
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>  }}
app/Live/Dashboard.tsx
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>)
DirectiveWhile the request runs
o:loadingShows the element, which is hidden otherwise
o:loading-removeHides 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-delayWaits 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:

app/Live/PostCount.tsx
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:

app/Live/Posts.tsx
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:

app/Live/ProfilePhoto.tsx
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.

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:

app/Live/Settings.tsx
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:

MethodWhat 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/live.ts; add one to change them:

KeyDefaultWhat it is
class_pathapp/LiveThe folder whose components are registered at boot
layoutcomponents.layouts.appThe layout of full-page components
lazy_placeholdernoneThe view lazy components show before they load
persistent_middlewareauth, verified, guest, canThe page route's middleware each update runs again
temporary_file_upload.diskYour default diskWhere uploads wait until they are stored
temporary_file_upload.directorylive-tmpThe folder on that disk
temporary_file_upload.rulesrequired, file, max:12288The rules every upload must pass
temporary_file_upload.middlewarethrottle:60,1The upload route's middleware
temporary_file_upload.max_upload_time5Minutes a signed upload URL stays valid
temporary_file_upload.preview_mimesImages, audio and videoThe types temporaryUrl() previews
temporary_file_upload.cleanuptrueDeletes 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.

Differences from Livewire#

Privacy policy ·

esc