Nitro

JavaScript views

Solid

Write a component's view as a Solid 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 as reactive props, by their PHP names, and only the parts of the page that read a changed value update.

On this page

A Solid view is a .jsx or .tsx file your Vite builds with vite-plugin-solid. Its default export is a Solid 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). Signals, memos, effects, control flow and context work as they always do. Nitro takes care of state, the server and navigation, so there is no resource to fetch, no API route and no store to set up.

Solid 1.x

Nitro's Solid adapter uses Solid's public API only: render, hydrate, createStore, reconcile, createMutable, context and renderToString. A Solid release needs nothing from Nitro.

Setting it up

  1. Install Solid and its Vite plugin

    npm install solid-js vite-plugin-solid
    
  2. Add the plugins, and register your views

    nitroSolid() 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 solid from 'vite-plugin-solid';
    import nitro from './vendor/nitro/nitro/js/vite.js';
    
    export default defineConfig({
        plugins: [
            laravel({ input: ['resources/js/app.js'], refresh: true }),
            nitro(),
            solid(),
        ],
    });
    
    // resources/js/app.js
    import { nitroSolid } from 'nitro/solid';
    
    nitroSolid(import.meta.glob(['../views/solid/**/*.{jsx,tsx}', './solid/**/*.{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/solid or resources/js/solid, as .jsx, .tsx, .js or .ts. Subfolders work: #[View(solid: 'blog/editor')] is blog/editor.jsx.

Solid and React in one app

Both compile .jsx, each its own way, so tell each plugin which files are its own. Keep Solid's components (the view's children too) under a solid folder:

// vite.config.js: each plugin compiles its own folder's .jsx
plugins: [
    laravel({ input: ['resources/js/app.js'], refresh: true }),
    nitro(),
    react({ include: /resources[\\/](views|js)[\\/]react[\\/].*\.[jt]sx$/ }),
    solid({ include: /resources[\\/](views|js)[\\/]solid[\\/].*\.[jt]sx$/ }),
],

A component with a Solid view

The class is an ordinary Nitro component. #[View(solid: 'post-editor')] says its view is post-editor.jsx:

app/Nitro/Components/Solid/PostEditor.php Browser + server
<?php

namespace App\Nitro\Components\Solid;

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(solid: '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 reads what it uses from its props, where it uses it:

resources/views/solid/post-editor.jsx
import { For, Show } from 'solid-js';

export default function PostEditor(props) {
    function submit(event) {
        event.preventDefault();
        props.save();
    }

    function addFromInput(event) {
        if (event.key !== 'Enter') return;
        event.preventDefault();
        props.addTag(event.currentTarget.value);
        event.currentTarget.value = '';
    }

    return (
        <form class="editor" onSubmit={submit}>
            <input value={props.title} onInput={(e) => props.set('title', e.currentTarget.value)} placeholder="Title" />
            <Show when={props.errors.title}>{(messages) => <p class="error">{messages()[0]}</p>}</Show>

            <textarea value={props.body} onInput={(e) => props.set('body', e.currentTarget.value)} rows={8} />
            <p class="muted">{props.words} words</p>

            <ul class="tags">
                <For each={props.tags}>
                    {(tag, index) => <li>{tag} <button type="button" onClick={() => props.removeTag(index())}>×</button></li>}
                </For>
            </ul>
            <input placeholder="Add a tag" onKeyDown={addFromInput} />

            <button disabled={props.save.processing}>{props.save.slow ? 'Saving…' : 'Save'}</button>
            <Show when={props.unsaved.length}><span class="muted">Unsaved changes</span></Show>
            <Show when={props.savedAt}><span class="muted">Saved at {props.savedAt}</span></Show>
        </form>
    );
}

Place it like any component, from a Blade page or layout. The class's folder is part of its name:

Blade
<nitro:solid.post-editor :post="$post" />

Nothing else is needed. props.set('title', ...) writes the property, and the elements that read props.title update. props.addTag() runs in the browser, compiled from the PHP. props.save() runs on the server, checks its rules there, and comes back with what it changed.

Nothing to import from Nitro

The component arrives as the function's props (props.title), so the view imports nothing from Nitro. You import Solid's own helpers (For, Show, createSignal) from solid-js when you use them, as in any Solid component. useNitro() from nitro/solid 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 props.title , props.tags The values, by their PHP names. Arrays are arrays or objects, models are objects with their attributes, uploads are Upload objects
#[Computed] values props.words Worked out again when what they read changes
Browser methods props.addTag(tag) Run in the browser. They return the method's value
#[Server] methods props.save() Run on the server. They return a promise of the method's value, and carry the call's status
errors props.errors.title?.[0] The validation messages, by field, as $errors has them in Blade
unsaved props.unsaved.length The properties changed in the browser that the server hasn't seen yet
uploads props.uploads.cover?.progress Each property's upload while it runs: uploading , progress (0 to 100) and error
set(path, value) props.set('form.email', value) Writes a property, or a key under it
upload(path, files) props.upload('cover', files) Uploads files into an Upload property
dispatch(event, ...) props.dispatch('saved', id) Sends an event to the components listening for it
link(href, ...modifiers) {...props.link('/posts')} The attributes of a nitro:navigate link
visit(url) props.visit('/posts') Goes to a page without reloading

Reading props, the Solid way

The props are a Solid store. Reading props.title inside JSX, a createMemo or a createEffect tracks it, and only that place runs again when the title changes. The rest of the view stays as it is: a component's function runs once.

  • Don't destructure them: const { title } = props reads the title once, and never again. Read props.title where you use it, or use splitProps.
  • Deep reads are fine-grained too. props.form.email updates when the email changes, not when another key of form does. When the server sends a list back, the items that stayed the same keep their elements, so <For> doesn't re-create them.
  • The call's status is reactive: props.save.processing in JSX updates as the call starts and ends.
JSX
import { createEffect, createMemo, createSignal } from 'solid-js';

export default function PostEditor(props) {
    /** The view's own state, as in any Solid component. */
    const [said, setSaid] = createSignal('');

    /** props.words is tracked where it is read: the memo follows the component. */
    const readingTime = createMemo(() => Math.max(1, Math.round(props.words / 200)));

    createEffect(() => {
        document.title = props.title ? `Editing: ${props.title}` : 'New post';
    });

    async function submit(event) {
        event.preventDefault();
        setSaid((await props.save()) ?? '');
    }

    return (
        <form onSubmit={submit}>
            <input value={props.title} onInput={(e) => props.set('title', e.currentTarget.value)} />
            <p>{props.words} words, about {readingTime()} min to read</p>

            <button disabled={props.save.processing}>Save</button>
            <p>{said()}</p>
        </form>
    );
}

Keep in signals 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 Solid component. To change the component, there are two ways, and both write the property at once, in the browser:

  • props.set(path, value) writes a property, or a key inside one by its dotted path: props.set('form.email', ...), props.set('lines.0.quantity', ...). It is what nitro:model does in Blade.
  • A browser method, which is the PHP you wrote, compiled: props.addTag(tag), props.removeLine(index).
JSX
import { For } from 'solid-js';

export default function Checkout(props) {
    return (
        <>
            {/* A key inside a property: its path, as nitro:model writes it. */}
            <input value={props.form.email} onInput={(e) => props.set('form.email', e.currentTarget.value)} />
            <label>
                <input type="checkbox" checked={props.form.gift} onChange={(e) => props.set('form.gift', e.currentTarget.checked)} />
                It's a gift
            </label>

            <For each={props.lines}>
                {(line, index) => (
                    <div>
                        {line.name}
                        <input type="number" value={line.quantity}
                            onInput={(e) => props.set(`lines.${index()}.quantity`, e.currentTarget.valueAsNumber)} />
                        <button onClick={() => props.removeLine(index())}>Remove</button>
                    </div>
                )}
            </For>

            {/* Or the PHP's own method, compiled to run here. */}
            <button onClick={() => props.addLine('SKU-42')}>Add a line</button>
        </>
    );
}

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 updates the page once, not once per property.

A method is called with the arguments you give it: onClick={props.save} works as well as onClick={() => props.save()}. The event Solid passes on its own is left out, since a PHP method can't take one.

Calling the server

A #[Server] method returns a promise of its return value. While the call runs, the method itself carries its status, reactive like the rest of the props:

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 { createSignal, Match, Show, Switch } from 'solid-js';

export default function SaveButton(props) {
    const [notice, setNotice] = createSignal('');

    async function submit() {
        try {
            /** The method's return value; null when a rule failed (errors has the messages). */
            const said = await props.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: ${props.save.error}`);
        }
    }

    return (
        <>
            <button disabled={props.save.processing} onClick={submit}>
                <Switch fallback="Save">
                    <Match when={props.save.slow}>Still saving…</Match>
                    <Match when={props.save.processing}>Saving</Match>
                </Switch>
            </button>
            <Show when={props.save.failed}><p class="error">{props.save.error}</p></Show>
            <p>{notice()}</p>
        </>
    );
}

Rules that fail are not a failed call. When $this->validate() refuses the data, the promise resolves with null, and props.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

props.errors holds the messages by field, including nested ones (props.errors['lines.0.quantity']). They come from the server when a #[Server] method validates, and are cleared for a field once it passes. <Show when={props.errors.title}> shows a message only while there is one.

Unsaved changes

props.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: <Show when={props.unsaved.length}>Unsaved changes</Show>.

Uploading files

A property of type Upload (or an array with #[Uploads], for several files) takes files. props.upload('cover', files) sends them as soon as they are chosen, and the property becomes the uploaded file. props.uploads.cover follows the upload while it runs:

JSX
import { Show } from 'solid-js';

export default function Cover(props) {
    return (
        <>
            <label class="cover">
                <span>Cover image</span>
                <input type="file" accept="image/*" onChange={(e) => props.upload('cover', e.currentTarget.files)} />
            </label>

            <Show when={props.uploads.cover?.uploading}>
                <progress max="100" value={props.uploads.cover.progress} />
            </Show>
            <Show when={props.errors.cover}><p class="error">{props.errors.cover[0]}</p></Show>

            <Show when={props.cover}>
                {(cover) => (
                    <figure>
                        <img src={cover().temporaryUrl()} alt="" />
                        <figcaption>{cover().getClientOriginalName()}, {Math.round(cover().getSize() / 1024)} KB</figcaption>
                    </figure>
                )}
            </Show>
        </>
    );
}
  • 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 props.errors.cover, and the property keeps what it had. The promise resolves with null.
  • Several files: for an #[Uploads] array, props.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.

props.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 props.visit(url):

JSX
export default function PostNav(props) {
    return (
        <>
            <nav>
                <a {...props.link('/posts')}>All posts</a>
                <a {...props.link(`/posts/${props.postId}/preview`, 'prefetch.visible')}>Preview</a>
            </nav>

            <select onChange={(e) => props.visit(`/posts?status=${e.currentTarget.value}`)}>
                <option value="draft">Drafts</option>
                <option value="published">Published</option>
            </select>
        </>
    );
}

Events

props.dispatch('event', ...params) sends an event to every component that listens for it with #[On('event')], whatever its view is written in: Blade, Vue, React, Svelte or Solid. 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 updates.

An event dispatched without ->to() also reaches the window, so the view's own code can listen for it too. That's useful for a toast or an animation that needs no PHP:

JSX
import { createSignal, onCleanup, onMount, Show } from 'solid-js';

export default function PreviewBar(props) {
    const [lastSaved, setLastSaved] = createSignal(null);

    /** An event another component dispatched (without ->to()), heard in the view's own code: its values in order. */
    const heard = (event) => setLastSaved(event.detail.params[0]);
    onMount(() => window.addEventListener('post-saved', heard));
    onCleanup(() => window.removeEventListener('post-saved', heard));

    return (
        <>
            <button onClick={() => props.dispatch('preview-requested', 42)}>Preview</button>
            <Show when={lastSaved()}><p>Post {lastSaved()} was just saved.</p></Show>
        </>
    );
}

Solid components inside the view

The view is the root of a Solid tree, so it can render any Solid component: your own, or a library's. A component anywhere inside the view reaches the Nitro component with useNitro(), which returns the same reactive props the view gets, without passing them down. Read from it in place, as from props:

JSX
// resources/views/solid/post-editor.jsx
import TagList from '../../js/solid/components/TagList.jsx';

export default function PostEditor() {
    return <TagList />;
}

// resources/js/solid/components/TagList.jsx: an ordinary Solid component, inside the view
import { For } from 'solid-js';
import { useNitro } from 'nitro/solid';

export default function TagList() {
    const nitro = useNitro();

    return (
        <ul>
            <For each={nitro.tags}>
                {(tag, index) => <li>{tag} <button onClick={() => nitro.removeTag(index())}>×</button></li>}
            </For>
        </ul>
    );
}

Nitro components go around a Solid view, not inside it

A Solid view can't render <nitro:...> components. Compose them from Blade: a Blade page or component can hold several components with Solid, Vue, React, Svelte and Blade views side by side, and they talk through events and stores.

Context providers

Each component with a Solid view is its own Solid root, and the view is at its top. Put your providers in the view, around what it renders, and every component inside sees them:

JSX
import { I18nProvider } from '../../js/solid/i18n.jsx';
import { ThemeProvider } from '../../js/solid/theme.jsx';
import Toolbar from '../../js/solid/components/Toolbar.jsx';

export default function PostEditor(props) {
    /** The view is the root of its tree: its providers go around what it renders. */
    return (
        <I18nProvider>
            <ThemeProvider>
                <Toolbar />
                <input value={props.title} onInput={(e) => props.set('title', e.currentTarget.value)} />
            </ThemeProvider>
        </I18nProvider>
    );
}

For state that several views on a page share, create it in a module of its own (createRoot with a store, say) and import it in each view, or use Nitro's stores, which the server and other engines see too.

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/solid/newsletter.jsx
/* <php>
use App\Models\Subscriber;

new class extends Component {
    #[Validate('required|email')]
    public string $email = '';

    public bool $done = false;

    #[Server]
    public function subscribe(): void
    {
        $this->validate();
        Subscriber::firstOrCreate(['email' => $this->email]);
        $this->done = true;
    }
};
</php> */

import { Show } from 'solid-js';

export default function Newsletter(props) {
    return (
        <Show when={!props.done} fallback={<p>Thanks: you're on the list.</p>}>
            <form onSubmit={(e) => (e.preventDefault(), props.subscribe())}>
                <input type="email" value={props.email} onInput={(e) => props.set('email', e.currentTarget.value)} />
                <button disabled={props.subscribe.processing}>Subscribe</button>
                <Show when={props.errors.email}><p class="error">{props.errors.email[0]}</p></Show>
            </form>
        </Show>
    );
}

Place it by the view's name, <nitro:newsletter />. 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 Solid view rendered in it, and the browser hydrates it. Solid compiles a component differently for each, so turn on ssr in its plugin, and add the views to your render service:

JavaScript
// vite.config.js: Solid compiles views to hydrate, for the browser and for the render service
solid({ ssr: true }),

// resources/js/ssr.js
import { serve } from 'nitro/ssr';
import { renderSolid } from 'nitro/solid/server';

serve({
    solid: renderSolid(import.meta.glob(['../views/solid/**/*.{jsx,tsx}', './solid/**/*.{jsx,tsx}'], { eager: true })),
});
  • Code that needs the browser (window, localStorage, measuring an element) goes in onMount(), which runs only in the browser, as in any server-rendered Solid app.
  • On the server, a view only reads. It can't call methods or set() while it renders.
  • Several Solid views on a page hydrate apart: each is keyed by its component's id, so they don't mix up each other's elements.
  • A view that fails to render on the server is left to the browser, which renders it as it would without server rendering.

Coming from Blade

Blade Solid
nitro:model="title" value={props.title} onInput={(e) => props.set('title', e.currentTarget.value)}
nitro:model.blur="title" value={props.title} onChange={(e) => props.set('title', e.currentTarget.value)}
nitro:click="addTag('solid')" onClick={() => props.addTag('solid')}
nitro:submit="save" onSubmit={(e) => (e.preventDefault(), props.save())}
nitro:loading ( nitro:target="save" ) <Show when={props.save.processing}>
nitro:loading.delay <Show when={props.save.slow}>
nitro:loading.attr="disabled" disabled={props.save.processing}
nitro:dirty <Show when={props.unsaved.length}>
@error('title') {{ $message }} @enderror {props.errors.title?.[0]}
nitro:model on a file input onChange={(e) => props.upload('cover', e.currentTarget.files)}
<a href="/posts" nitro:navigate> <a {...props.link('/posts')}>
$dispatch('saved', $id) props.dispatch('saved', id)
$visit('/posts') props.visit('/posts')
{{ $this->words }} {props.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.
  • 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 Solid views' code before the page shows, so the page appears with them rendered, not filling in after it.

Next steps