Nitro

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

  1. Install React and its Vite plugin

    npm install react react-dom @vitejs/plugin-react
    
  2. Add the plugins, and register your views

    nitroReact() takes your views as import.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}']));
    
  3. Load them before Nitro starts

    Put @vite before @nitroScripts in 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:

app/Nitro/Components/React/PostEditor.php Browser + server
<?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:

resources/views/react/post-editor.jsx
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:

Blade
<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:

JSX
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 what nitro:model does in Blade.
  • A browser method, which is the PHP you wrote, compiled: addTag(tag), removeLine(index).
JSX
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
JSX
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:

JSX
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() and getMimeType(), as on the server.
  • Refused files: a file the nitro.uploads.rules refuse puts the message in errors.cover, and the property keeps what it had. The promise resolves with null.
  • 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.

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):

JSX
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:

JSX
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:

JSX
// 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.)

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.

resources/views/react/invite-form.jsx
/* <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:

JSX
// 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 in useEffect(), 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: .tsx views 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.

Next steps