The basics
Views
Views hold your pages' HTML. Each is a TSX file in resources/ whose component takes the view's data as props
and is rendered to HTML on the server.
Introduction#
A view keeps your markup apart from the code that answers the request. It is a .tsx file that default-exports a
component, a function that receives the view's data and returns markup:
export default function Greeting({ name }: { name: string }) { return ( <html> <body> <h1>Hello, {name}</h1> </body> </html> )}As the file is resources/, the view() helper returns it by the name greeting:
Route.get('/', () => view('greeting', { name: 'James' }))The JSX runtime#
A new app's tsconfig.json points TSX at Oriel's JSX runtime:
{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "@orieljs/framework" }}There is no React here. An element such as <h1> is a plain description of the markup, and Oriel renders it to
HTML when the view renders. Nothing of a view runs in the browser: event handler props such as
onClick={() => …} are dropped, and a component has no state between renders. Node runs your TSX through tsx,
which the oriel command loads, and Bun runs it itself.
Writing views in React, Vue, Svelte, Solid or Angular#
For pages written in a JavaScript framework, the Inertia starter kits pair your routes with React, Vue, Svelte, Solid or
Angular pages. Their root view, resources/, is a TSX view like any other. Inertia covers
them.
Creating and rendering views#
Create a view by adding a file to resources/. Its default export must be a function, or rendering it fails with
"View [name] (path) must default-export a component function.". Return it from a route or a controller with the
view() helper, or with the View facade:
const greeting = view('greeting', { name: 'James' })const same = View.make('greeting', { name: 'James' })The first argument is the view's name, the file's path in resources/ without its extension. The second is its
data, which the component receives as its props.
A name finds the first file with the extension .tsx, .ts, .jsx, .js or .mjs, in that order. The folders to
search are paths in config/, resources/ in a new app. A view that doesn't exist is an error, "View
[name] not found.", and the request ends with a 500.
Oriel has no make:view command: a view is a file you create.
Nested view directories#
Views can sit in folders of resources/. A dot in the name is a folder, so resources/
is admin.profile:
const profile = view('admin.profile', data)As dots separate the folders, a folder whose name contains a dot can't be reached.
Creating the first available view#
View.first() returns the first view of a list that exists, which suits views an app or a package lets you
override:
const admin = View.first(['custom.admin', 'admin'], data)When none exists, the error names them all.
Determining if a view exists#
View.exists() returns true when a view of that name exists:
if (View.exists('admin.profile')) { // ...}Passing data to views#
The data you give view() becomes the component's props:
const res = view('greeting', { name: 'Victoria' })with() adds data to a view, a value or an object of values at a time, and returns the view so you can chain it:
const res = view('greeting').with('name', 'Victoria').with('occupation', 'Astronaut')Typing a view's data#
A view's component declares the type of its props, and TypeScript checks the component's code against it. view()
takes any data, as a view's name is a string that TypeScript can't follow to its file. To have the data checked,
export the props' type from the view and check the data with satisfies:
import { Route, view } from '@orieljs/framework'import type { GreetingProps } from '../resources/views/greeting'Route.get('/', () => view('greeting', { name: 'James' } satisfies GreetingProps))Sharing data with all views#
View.share() gives a value to every view your app renders. Call it in a service provider's boot():
import { ServiceProvider, View } from '@orieljs/framework'export class AppServiceProvider extends ServiceProvider { override boot(): void { View.share('key', 'value') }}Every view receives key as a prop, unless its own data has a key, which wins. share() also takes an object of
values.
View composers#
A view composer is a function that runs each time a view renders, before the view does, and gives it data. When
several routes return the same view and it always needs the same data, a composer fetches the data in one place.
Register composers in a service provider's boot():
import { Auth, ServiceProvider, View } from '@orieljs/framework'import { User } from '../Models/User'export class AppServiceProvider extends ServiceProvider { override boot(): void { View.composer('profile', async (view) => { view.with('count', await User.query().count()) }) View.composer('dashboard', async (view) => { view.with('user', await Auth.user()) }) }}A composer receives the view, whose with() adds data, and may be async: the view waits for it. It runs during the
request, so it can read the request, the session and the signed-in user.
composer() takes a function. A composer with more logic can be a class of your own, which the function calls. Oriel
has no folder for them; app/ keeps them together:
import type { View } from '@orieljs/framework/contracts'import { User } from '../../Models/User'export class ProfileComposer { async compose(view: View): Promise<void> { view.with('count', await User.query().count()) }}import { ServiceProvider, View } from '@orieljs/framework'import { ProfileComposer } from '../View/Composers/ProfileComposer'export class AppServiceProvider extends ServiceProvider { override boot(): void { View.composer('profile', (view) => new ProfileComposer().compose(view)) }}Composers run for views rendered by name: those view() and View.make() return, error pages and mail views. A
component that a view imports is a function the view calls, not a view, so composers don't run for it.
Attaching a composer to multiple views#
composer() takes a list of names, and * matches any part of a name:
View.composer(['profile', 'dashboard'], (view) => { // ...})View.composer('admin.*', (view) => { // ...})View.composer('*', (view) => { // ...})Laravel's view creators, which run as a view is made rather than as it renders, aren't in Oriel: use a composer.
Displaying data#
Put any expression in braces to display it:
export default function Welcome({ name }: { name: string }) { return ( <p> Hello, {name}. The current UNIX timestamp is {Math.floor(Date.now() / 1000)}. </p> )}Text is escaped as it is rendered: &, <, >, " and ' become entities, in content and in attribute values
alike, so a value can't add markup to your page. Entities in your data are escaped again, so & displays as
&. The e() helper escapes a string the same way.
Displaying unescaped data#
raw() marks HTML you trust, which is rendered as it is:
const article = <article>{raw(html)}</article>The text inside <script> and <style> is escaped too, so inline code goes through raw():
const script = <script>{raw("document.documentElement.classList.add('js')")}</script>Passing data to your scripts#
An object or an array in an attribute is written as JSON, and escaped like any other value. A script reads it back
with JSON.parse():
const element = <div id="profile" data-user={user}></div>A model is written without its hidden attributes, as in a JSON response.
Conditionals and loops#
TSX has no directives: conditions and loops are JavaScript expressions. && and the ternary operator choose what to
show, and map() repeats markup:
import type { Collection } from '@orieljs/framework'import type { Post } from '../../../app/Models/Post'export default function Index({ posts }: { posts: Collection<Post> }) { return ( <main> {posts.isEmpty() ? <p>No posts yet.</p> : <h1>{posts.count()} posts</h1>} <ul> {posts.map((post) => ( <li>{post.title}</li> ))} </ul> </main> )}null, undefined, true and false render nothing, and 0 renders as 0. A list renders each of its items, and
so does a collection.
Blade's other directives are function calls in a view: await Auth.check() for @auth,
app().environment('local') for @env, session('status') for @session. A view renders during the request, so
request(), session(), config() and the other helpers work in it.
Attributes#
Attributes take values as in HTML, with a few conveniences:
classtakes a string, a list, or an object whose keys are kept when their value is truthy, as Blade's@classdoes:class={['btn', { 'btn-primary': primary }]}.classNameis the same attribute.styletakes a string or an object:style={{ marginTop: 4 }}rendersmargin-top:4px.truewrites a boolean attribute such asdisabled, andfalse,nullandundefinedleave it out.aria-*anddata-*attributes writetrueandfalseas text.htmlForis writtenfor, andforworks too.
An <html> element is preceded by <!DOCTYPE html>, as TSX has no way to write one.
Components and layouts#
Components#
A component is a function that takes props and returns markup. Write it in a file of its own and import it into
the views that use it. children holds what you put between its tags:
import type { Renderable } from '@orieljs/framework'export default function Alert({ type = 'info', children }: { type?: 'info' | 'danger'; children?: Renderable }) { return ( <div class={['alert', `alert-${type}`]} role="alert"> {children} </div> )}const alert = <Alert type="danger">Your session has expired.</Alert>A component can be async: Oriel waits for it while it renders, so a component can query the database itself.
Layouts#
Most pages share a layout. A layout is a component that takes the page as its children. The Live starter kit keeps
its layouts in resources/:
import type { Renderable } from '@orieljs/framework'export default function Layout({ title = 'Todo Manager', children }: { title?: string; children?: Renderable }) { return ( <html lang="en"> <head> <title>{title}</title> </head> <body> <h1>Todos</h1> <hr /> {children} </body> </html> )}A view wraps its markup in the layout, and passes the title as a prop:
import type { Collection } from '@orieljs/framework'import type { Post } from '../../app/Models/Post'import Layout from './components/layout'export default function Tasks({ tasks }: { tasks: Collection<Post> }) { return ( <Layout title="Custom Title"> {tasks.map((task) => ( <div>{task.title}</div> ))} </Layout> )}Route.get('/tasks', async () => view('tasks', { tasks: await Post.all() }))Including subviews#
To reuse markup, import the component that holds it. A view can also render another view by name, as a child:
const page = <div>{view('shared.errors')}</div>The view renders with its own data only: unlike Blade's @include, it doesn't receive the data of the view around it,
so pass what it needs, as in view('shared.errors', { title }). Shared data and composers apply to it.
Forms#
CSRF field#
A form that posts to a route in the web group must send the CSRF token. csrfField() writes the hidden field (see
CSRF protection):
const form = ( <form method="post" action="/profile"> {csrfField()} ... </form>)Method field#
HTML forms can't send PUT, PATCH or DELETE. methodField() writes the hidden _method field that spoofs them
(see form method spoofing):
const form = ( <form action="/foo/bar" method="post"> {csrfField()} {methodField('PUT')} ... </form>)Validation errors#
After a failed validation, errors() returns the messages, by field, and old() the input the form
sent:
import { csrfField, errors, old } from '@orieljs/framework'export default function Create() { const error = errors().first('title') return ( <form method="post" action="/posts"> {csrfField()} <label for="title">Post Title</label> <input id="title" type="text" name="title" value={old('title', '')} class={{ 'is-invalid': error !== undefined }} /> {error && <div class="alert alert-danger">{error}</div>} </form> )}errors().has('title') tells you whether a field has messages, get('title') returns them all, and all() every
message of every field. On a page with several forms, errors('login') reads the login error bag.
Rendering TSX without a view file#
View.renderToString() renders TSX, a view or text to an HTML string, without a view file:
const html = await View.renderToString(<Alert>Saved.</Alert>)renderToStream() renders to a stream instead, which sends each part as soon as it is ready, while an async
component waits for its data:
const res = response().stream(renderToStream(<Page />), 200, { 'Content-Type': 'text/html; charset=utf-8' })Error views#
When Oriel answers an error with an HTML page, it looks for a view named after the status:
resources/ for a 404, then errors/ for any client error and errors/ for any
server error. The view receives the status, the message and the exception:
export default function NotFound({ message }: { status: number; message: string }) { return ( <html lang="en"> <body> <h1>{message}</h1> </body> </html> )}Error handling covers when they are used. Mail views are views as well: make:mail writes one under
resources/, as Mail explains.
Live components#
Live components render with the same TSX, and their o: attributes, the directives that wire an element to the
component, are type-checked. An o: name that is neither a directive nor a DOM event is a type error, and so is a
directive's value of the wrong type:
// @ts-expect-error: o:clik is neither a directive nor an eventconst typo = <button o:clik="save">Save</button>// @ts-expect-error: o:model takes the name of a propertyconst value = <input o:model={5} />const form = ( <form o:submit="save"> <input o:model-live="title" /> <a href="/posts" o:navigate> Posts </a> </form>)TypeScript checks a name with a - only when the type lists it. The listed modifier forms, such as o:model-live, are
checked; an unlisted one, such as the duration in o:model-live-debounce-500ms or an event's modifier in
o:keydown-enter, is accepted as written, and so is a misspelt one. A custom event isn't a DOM event, so spread it onto
the element, with -window for one a component dispatches, which fires on window:
<div {...{ 'o:saved-window': '$refresh' }} /. Live covers the directives.
Optimizing views#
A view has no compile step of its own. Each view file is imported the first time it renders, and the runtime keeps
the module for the life of the process, so Laravel's view:cache and view:clear aren't needed. A changed view
shows once the server restarts: node oriel serve --watch restarts it when a file in resources/ changes.