JavaScript views
React
Write a component's view as a React function component. The component stays a PHP class: its properties, its methods compiled to run in the browser, its #[Server] methods and their rules. The view gets them all as props, by their PHP names, and re-renders when they change.
On this page
A React view is a .jsx or .tsx file your Vite builds with
@vitejs/plugin-react. Its default export is a function component, and its props are the Nitro
component: every property and #[Computed] value, every method, and what the component knows
about itself (validation messages, unsaved changes, uploads, a call's status). Hooks, context, effects and
any React library work as they always do. Nitro takes care of state, the server and navigation, so there is
no fetch to write, no API route and no store to set up.
React 18 and 19
Nitro's React adapter uses React's public API only: createRoot, hydrateRoot, useSyncExternalStore, context and renderToString. A React release needs nothing from Nitro.
Setting it up
-
Install React and its Vite plugin
npm install react react-dom @vitejs/plugin-react -
Add the plugins, and register your views
nitroReact()takes your views asimport.meta.glob()gives them; each view loads when a page first shows it.// vite.config.js import { defineConfig } from 'vite'; import laravel from 'laravel-vite-plugin'; import react from '@vitejs/plugin-react'; import nitro from './vendor/nitro/nitro/js/vite.js'; export default defineConfig({ plugins: [ laravel({ input: ['resources/js/app.js'], refresh: true }), nitro(), react(), ], }); // resources/js/app.js import { nitroReact } from 'nitro/react'; nitroReact(import.meta.glob(['../views/react/**/*.{jsx,tsx}', './react/**/*.{jsx,tsx}'])); -
Load them before Nitro starts
Put
@vitebefore@nitroScriptsin your layout, so the views are registered when the components start.<head> {{-- Your views first: they register with Nitro before its components start. --}} @vite('resources/js/app.js') </head> <body> {{ $slot }} @nitroScripts </body>
Views live in resources/views/react or resources/js/react, as .jsx,
.tsx, .js or .ts. Subfolders work: #[View(react: 'blog/editor')]
is blog/editor.jsx.
A component with a React view
The class is an ordinary Nitro component. #[View(react: 'post-editor')] says its view is
post-editor.jsx:
<?php
namespace App\Nitro\Components\React;
use App\Models\Post;
use Illuminate\Support\Facades\Gate;
use Nitro\Attributes\Computed;
use Nitro\Attributes\Locked;
use Nitro\Attributes\Server;
use Nitro\Attributes\Validate;
use Nitro\Attributes\View;
use Nitro\Component;
use Nitro\Upload;
#[View(react: 'post-editor')]
class PostEditor extends Component
{
#[Locked]
public int $postId;
#[Validate('required|min:3|max:120')]
public string $title = '';
public string $body = '';
/** @var list<string> */
public array $tags = [];
#[Validate('nullable|image|max:2048')]
public ?Upload $cover = null;
public string $savedAt = '';
public function mount(Post $post): void
{
$this->postId = $post->id;
$this->title = $post->title;
$this->body = $post->body;
$this->tags = $post->tags;
}
#[Computed]
public function words(): int
{
return str_word_count($this->body);
}
public function addTag(string $tag): void
{
$tag = strtolower(trim($tag));
if ($tag !== '' && ! in_array($tag, $this->tags, true)) {
$this->tags[] = $tag;
}
}
public function removeTag(int $index): void
{
array_splice($this->tags, $index, 1);
}
#[Server]
public function save(): string
{
$this->validate();
$post = Post::findOrFail($this->postId);
Gate::authorize('update', $post);
$post->update([
'title' => $this->title,
'body' => $this->body,
'tags' => $this->tags,
'cover' => $this->cover?->store('covers', 'public') ?? $post->cover,
]);
$this->cover = null;
$this->savedAt = now()->format('H:i');
$this->dispatch('post-saved', id: $post->id);
return "Saved at {$this->savedAt}";
}
}
The view takes what it uses from its props:
export default function PostEditor({ title, body, tags, words, savedAt, set, addTag, removeTag, save, errors, unsaved }) {
function submit(event) {
event.preventDefault();
save();
}
function addFromInput(event) {
if (event.key !== 'Enter') return;
event.preventDefault();
addTag(event.target.value);
event.target.value = '';
}
return (
<form className="editor" onSubmit={submit}>
<input value={title} onChange={(e) => set('title', e.target.value)} placeholder="Title" />
{errors.title && <p className="error">{errors.title[0]}</p>}
<textarea value={body} onChange={(e) => set('body', e.target.value)} rows={8} />
<p className="muted">{words} words</p>
<ul className="tags">
{tags.map((tag, index) => (
<li key={tag}>
{tag} <button type="button" onClick={() => removeTag(index)}>×</button>
</li>
))}
</ul>
<input placeholder="Add a tag" onKeyDown={addFromInput} />
<button disabled={save.processing}>{save.slow ? 'Saving…' : 'Save'}</button>
{unsaved.length > 0 && <span className="muted">Unsaved changes</span>}
{savedAt && <span className="muted">Saved at {savedAt}</span>}
</form>
);
}
Place it like any component, from a Blade page or layout. The class's folder is part of its name:
<nitro:react.post-editor :post="$post" />
Nothing else is needed. set('title', ...) writes the property and the view re-renders with it.
addTag() runs in the browser, compiled from the PHP. save() runs on the server,
checks its rules there, and comes back with what it changed: the new savedAt, the cleared
cover, the messages in errors.
Nothing to import from Nitro
The component arrives as the function's props, so the view imports nothing from Nitro. It doesn't import React either: the JSX transform Vite's React plugin uses needs no import. You import React's own hooks (useState, useEffect) from react when you use them, as in any React
component. useNitro() from nitro/react is for components deeper inside the view,
to reach the Nitro component without passing props down (see below).
The props
| Example | What it is | |
|---|---|---|
| Properties |
title
,
tags
|
The values, by their PHP names. Arrays are arrays or objects, models are objects with their attributes, uploads are
Upload
objects
|
| #[Computed] values |
words
|
Worked out again when what they read changes |
| Browser methods |
addTag(tag)
|
Run in the browser. They return the method's value |
| #[Server] methods |
save()
|
Run on the server. They return a promise of the method's value, and carry the call's status |
errors
|
errors.title?.[0]
|
The validation messages, by field, as
$errors
has them in Blade
|
unsaved
|
unsaved.length
|
The properties changed in the browser that the server hasn't seen yet |
uploads
|
uploads.cover?.progress
|
Each property's upload while it runs:
uploading
,
progress
(0 to 100) and
error
|
set(path, value)
|
set('form.email', value)
|
Writes a property, or a key under it |
upload(path, files)
|
upload('cover', files)
|
Uploads files into an
Upload
property
|
dispatch(event, ...)
|
dispatch('saved', id)
|
Sends an event to the components listening for it |
link(href, ...modifiers)
|
{...link('/posts')}
|
The attributes of a
nitro:navigate
link
|
visit(url)
|
visit('/posts')
|
Goes to a page without reloading |
The props are a snapshot. Each change makes a new one, so the view re-renders as it would for any new props,
and useMemo, useEffect and memo() compare them as usual. Functions keep
their identity from one render to the next, so passing save or set to a memoised
child doesn't re-render it.
A method is called with the arguments you give it: onClick={save} works as well as
onClick={() => save()}. The event React passes on its own is left out, since a PHP method
can't take one.
Hooks and the view's own state
The view is a function component, so the view's own state is useState, values worked out from
props are useMemo, and work that follows a value is useEffect with that prop in its
dependencies:
import { useEffect, useMemo, useState } from 'react';
export default function PostEditor({ title, words, set, save, errors }) {
/** The view's own state, as in any React component. */
const [said, setSaid] = useState('');
const readingTime = useMemo(() => Math.max(1, Math.round(words / 200)), [words]);
/** Props change when the component does: effects follow them like any props. */
useEffect(() => {
document.title = title ? `Editing: ${title}` : 'New post';
}, [title]);
async function submit(event) {
event.preventDefault();
setSaid((await save()) ?? '');
}
return (
<form onSubmit={submit}>
<input value={title} onChange={(e) => set('title', e.target.value)} />
{errors.title && <p className="error">{errors.title[0]}</p>}
<p>{words} words, about {readingTime} min to read</p>
<button disabled={save.processing}>Save</button>
<p>{said}</p>
</form>
);
}
Keep in useState what belongs to the view alone: whether a menu is open, the text of a tag not yet
added. What the server should see, or another component should hear about, belongs on the PHP class.
Changing the component
Props are read only, as in any React component. To change the component, there are two ways, and both write the property at once, in the browser:
set(path, value)writes a property, or a key inside one by its dotted path:set('form.email', ...),set('lines.0.quantity', ...). It is whatnitro:modeldoes in Blade.- A browser method, which is the PHP you wrote, compiled:
addTag(tag),removeLine(index).
export default function Checkout({ form, lines, set, addLine, removeLine }) {
return (
<>
{/* A key inside a property: its path, as nitro:model writes it. */}
<input value={form.email} onChange={(e) => set('form.email', e.target.value)} />
<label>
<input type="checkbox" checked={form.gift} onChange={(e) => set('form.gift', e.target.checked)} />
It's a gift
</label>
{lines.map((line, index) => (
<div key={line.sku}>
{line.name}
<input type="number" value={line.quantity}
onChange={(e) => set(`lines.${index}.quantity`, e.target.valueAsNumber)} />
<button onClick={() => removeLine(index)}>Remove</button>
</div>
))}
{/* Or the PHP's own method, compiled to run here. */}
<button onClick={() => addLine('SKU-42')}>Add a line</button>
</>
);
}
Controlled inputs work as you'd expect. set() writes and re-renders at once, in
the same event, so the caret stays where it was while typing. Don't change an array or object prop in place
(tags.push(...)): like any React state, it is a snapshot, and only set() or a method
changes the component.
The server sees these changes with the next #[Server] call, which sends them along. Until then
they are unsaved. A browser method that changes several properties gives React one render, not
one per property.
Calling the server
A #[Server] method returns a promise of its return value. While the call runs, the method itself
carries its status, so a button can follow it without a useState of your own:
| True while | In Blade | |
|---|---|---|
save.processing
|
The call is running |
nitro:loading
|
save.slow
|
It has been running for 200 ms |
nitro:loading.delay
|
save.failed
|
The request itself failed: an error on the server, or no connection | |
save.error
|
Why it failed, as a message |
import { useState } from 'react';
export default function SaveButton({ save }) {
const [notice, setNotice] = useState('');
async function submit() {
try {
/** The method's return value; null when a rule failed (errors has the messages). */
const said = await save();
setNotice(said ?? 'Please fix the highlighted fields.');
} catch {
/** The request itself failed: the server's error, or no connection. save.error says why. */
setNotice(`Not saved: ${save.error}`);
}
}
return (
<>
<button disabled={save.processing} onClick={submit}>
{save.slow ? 'Still saving…' : save.processing ? 'Saving' : 'Save'}
</button>
{save.failed && <p className="error">{save.error}</p>}
<p>{notice}</p>
</>
);
}
Rules that fail are not a failed call. When $this->validate() refuses the data,
the promise resolves with null, and errors has the messages by field. Only a request
that fails, such as a 500, a 403 or no network, rejects the promise and sets failed.
Validation messages
errors holds the messages by field, including nested ones (errors['lines.0.quantity']).
They come from the server when a #[Server] method validates, and are cleared for a field once
it passes. Show the first one with {errors.title?.[0]}.
Unsaved changes
unsaved lists the properties changed in the browser that the server hasn't seen yet. It is empty
again once a #[Server] call has taken them:
{unsaved.length > 0 && <span>Unsaved changes</span>}.
Uploading files
A property of type Upload (or an array with #[Uploads], for several files) takes
files. upload('cover', files) sends them as soon as they are chosen, and the property becomes the
uploaded file. uploads.cover follows the upload while it runs:
export default function Cover({ cover, upload, uploads, errors }) {
return (
<>
<label className="cover">
<span>Cover image</span>
<input type="file" accept="image/*" onChange={(e) => upload('cover', e.target.files)} />
</label>
{uploads.cover?.uploading && <progress max="100" value={uploads.cover.progress} />}
{errors.cover && <p className="error">{errors.cover[0]}</p>}
{cover && (
<figure>
<img src={cover.temporaryUrl()} alt="" />
<figcaption>{cover.getClientOriginalName()}, {Math.round(cover.getSize() / 1024)} KB</figcaption>
</figure>
)}
</>
);
}
- A preview:
cover.temporaryUrl()is a URL the browser can show for an image. - What it is:
getClientOriginalName(),getClientOriginalExtension(),getSize()andgetMimeType(), as on the server. - Refused files: a file the
nitro.uploads.rulesrefuse puts the message inerrors.cover, and the property keeps what it had. The promise resolves withnull. - Several files: for an
#[Uploads]array,upload('photos', files)sets the list. - Keeping them: the file is stored for good only when a
#[Server]method calls$this->cover->store(...), after its rules (image,max:2048) pass.
Links and visits
link(href, ...modifiers) gives the attributes of a nitro:navigate link: spread them
on an <a>, and the visit happens without a reload. (JSX can't write
nitro:navigate.keypress as an attribute name, which is why it is a function.) The modifiers are
the ones a Blade link takes: 'keypress', 'preserve-scroll', 'transition', and 'prefetch' or 'prefetch.visible' (with its times: 'prefetch.150ms'), which adds nitro:prefetch.
To go somewhere from code, call visit(url):
export default function PostNav({ postId, link, visit }) {
return (
<>
<nav>
<a {...link('/posts')}>All posts</a>
<a {...link(`/posts/${postId}/preview`, 'prefetch.visible')}>Preview</a>
</nav>
<select onChange={(e) => visit(`/posts?status=${e.target.value}`)}>
<option value="draft">Drafts</option>
<option value="published">Published</option>
</select>
</>
);
}
Events
dispatch('event', ...params) sends an event to every component that listens for it with
#[On('event')], whatever its view is written in: Blade, Vue, Solid, Svelte or React. To react to
an event, add an #[On] method to the component's class. It runs in the browser, changes the
properties, and the view re-renders.
An event dispatched without ->to() also reaches the window, so an effect can listen for it
too. That's useful for a toast or an animation that needs no PHP:
import { useEffect, useState } from 'react';
export default function PreviewBar({ dispatch }) {
const [lastSaved, setLastSaved] = useState(null);
/** An event another component dispatched (without ->to()), heard in the view's own code: its values in order. */
useEffect(() => {
const heard = (event) => setLastSaved(event.detail.params[0]);
window.addEventListener('post-saved', heard);
return () => window.removeEventListener('post-saved', heard);
}, []);
return (
<>
<button onClick={() => dispatch('preview-requested', 42)}>Preview</button>
{lastSaved && <p>Post {lastSaved} was just saved.</p>}
</>
);
}
React components inside the view
The view is the root of a React tree, so it can render any React component: your own, or a library's. A
component anywhere inside the view reaches the Nitro component with the useNitro() hook, which
returns the same props the view gets, without passing them down:
// resources/views/react/post-editor.jsx
import TagList from '../../js/components/TagList.jsx';
export default function PostEditor() {
return <TagList />;
}
// resources/js/components/TagList.jsx: an ordinary React component, inside the view
import { useNitro } from 'nitro/react';
export default function TagList() {
const { tags, removeTag } = useNitro();
return (
<ul>
{tags.map((tag, index) => (
<li key={tag}>{tag} <button onClick={() => removeTag(index)}>×</button></li>
))}
</ul>
);
}
Nitro components go around a React view, not inside it
A React view can't render <nitro:...> components. Compose them from Blade: a Blade page or component can hold several components with React, Vue, Solid, Svelte and Blade views side by side, and they talk through events and stores.
Providers: React Query, i18n, a theme
Each component with a React view is its own React root. nitroReact()'s wrap(view) puts
your providers around each one. Create a client once, outside wrap, and every view on the page
shares it. (With JSX in it, the file is app.jsx.)
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { IntlProvider } from 'react-intl';
import { nitroReact } from 'nitro/react';
import messages from './lang/messages.js';
/** One client for every view on the page, so their caches are shared. */
const queryClient = new QueryClient();
const locale = document.documentElement.lang;
nitroReact(import.meta.glob(['../views/react/**/*.{jsx,tsx}', './react/**/*.{jsx,tsx}']), {
/** Called for each view: the providers around it. */
wrap: (view) => (
<QueryClientProvider client={queryClient}>
<IntlProvider locale={locale} messages={messages[locale]}>{view}</IntlProvider>
</QueryClientProvider>
),
});
The PHP in the .jsx file
A small component can live in one file. Put its class at the top of the .jsx or .tsx
file, in a <php> block inside a comment, so the file stays plain JavaScript to your tools.
Nitro makes it a class named after the view. Component and Nitro's attributes need no
use; anything else does.
/* <php>
use App\Models\Invitation;
use Illuminate\Support\Str;
new class extends Component {
#[Validate('required|email')]
public string $email = '';
#[Computed]
public function domain(): string
{
return Str::after($this->email, '@');
}
#[Server]
public function invite(): void
{
$this->validate();
Invitation::send($this->email, invitedBy: auth()->user());
}
};
</php> */
export default function InviteForm({ email, domain, set, invite, errors }) {
return (
<div className="invite">
<input value={email} onChange={(e) => set('email', e.target.value)} />
{domain && <small>@{domain}</small>}
{errors.email && <p className="error">{errors.email[0]}</p>}
<button disabled={invite.processing} onClick={() => invite()}>Invite</button>
</div>
);
}
Place it by the view's name, <nitro:invite-form />. An error in the block names the line in
the .jsx file.
Server rendering
With server rendering on, the page's first HTML has the React view rendered
in it, and the browser hydrates it with hydrateRoot(). Add the views to your render service, with
the same providers:
// resources/js/ssr.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { serve } from 'nitro/ssr';
import { renderReact } from 'nitro/react/server';
serve({
react: renderReact(import.meta.glob(['../views/react/**/*.{jsx,tsx}', './react/**/*.{jsx,tsx}'], { eager: true }), {
/** Called for each render: the service renders for every visitor, so nothing is shared between them. */
wrap: (view) => <QueryClientProvider client={new QueryClient()}>{view}</QueryClientProvider>,
}),
});
- Code that needs the browser (
window,localStorage, measuring an element) goes inuseEffect(), which runs only in the browser, as in any server-rendered React app. - On the server, a view only reads. It can't call methods or
set()while it renders. - A view that fails to render on the server is left to the browser, which renders it as it would without server rendering.
- The first render matches. The server renders from the same snapshot the browser's first render reads, so hydration finds what it expects. Keep values that differ between the two (the time, a random id) out of the first render, or use
useId().
Coming from Blade
| Blade | React |
|---|---|
nitro:model="title"
|
value={title} onChange={(e) => set('title', e.target.value)}
|
nitro:model.blur="title"
|
defaultValue={title} onBlur={(e) => set('title', e.target.value)}
|
nitro:click="addTag('react')"
|
onClick={() => addTag('react')}
|
nitro:submit="save"
|
onSubmit={(e) => { e.preventDefault(); save(); }}
|
nitro:loading
(
nitro:target="save"
)
|
{save.processing && ...}
|
nitro:loading.delay
|
{save.slow && ...}
|
nitro:loading.attr="disabled"
|
disabled={save.processing}
|
nitro:dirty
|
{unsaved.length > 0 && ...}
|
@error('title') {{ $message }} @enderror
|
{errors.title?.[0]}
|
nitro:model
on a file input
|
onChange={(e) => upload('cover', e.target.files)}
|
<a href="/posts" nitro:navigate>
|
<a {...link('/posts')}>
|
$dispatch('saved', $id)
|
dispatch('saved', id)
|
$visit('/posts')
|
visit('/posts')
|
{{ $this->words }}
|
{words}
|
Good to know
- The view's root is rendered inside the component's element, a
<div>Nitro puts on the page. Style it from the inside. - Several React views on a page are separate React roots. Share state between them with Nitro's stores or events, or with a client created once (React Query, Zustand) that all of them import.
- TypeScript:
.tsxviews work as usual. The props aren't typed for you yet; describe them with an interface of your own. - A visit to a page loads its React views' code before the page shows, so the page appears with them rendered, not filling in after it.