Skip to the content
OrielDocs
0.1.0-alpha.10GitHub

The basics

Views

Views hold your pages' HTML. Each is a TSX file in resources/views 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:

resources/views/greeting.tsx
export default function Greeting({ name }: { name: string }) {  return (    <html>      <body>        <h1>Hello, {name}</h1>      </body>    </html>  )}

As the file is resources/views/greeting.tsx, 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/views/app.tsx, is a TSX view like any other. Inertia covers them.

Creating and rendering views#

Create a view by adding a file to resources/views. 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/views 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/view.ts, resources/views 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/views. A dot in the name is a folder, so resources/views/admin/profile.tsx 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:

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

app/Providers/AppServiceProvider.ts
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():

app/Providers/AppServiceProvider.ts
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/View/Composers keeps them together:

app/View/Composers/ProfileComposer.ts
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())  }}
app/Providers/AppServiceProvider.ts
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:

resources/views/welcome.tsx
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 &amp; displays as &amp;. 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:

resources/views/posts/index.tsx
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:

  • class takes a string, a list, or an object whose keys are kept when their value is truthy, as Blade's @class does: class={['btn', { 'btn-primary': primary }]}. className is the same attribute.
  • style takes a string or an object: style={{ marginTop: 4 }} renders margin-top:4px.
  • true writes a boolean attribute such as disabled, and false, null and undefined leave it out. aria-* and data-* attributes write true and false as text.
  • htmlFor is written for, and for works 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:

resources/views/components/alert.tsx
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/views/components/layouts:

resources/views/components/layout.tsx
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:

resources/views/tasks.tsx
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:

resources/views/post/create.tsx
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/views/errors/404.tsx for a 404, then errors/4xx.tsx for any client error and errors/5xx.tsx for any server error. The view receives the status, the message and the exception:

resources/views/errors/404.tsx
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/views/mail, 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/views changes.

Privacy policy ·

esc