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
-
Install Solid and its Vite plugin
npm install solid-js vite-plugin-solid -
Add the plugins, and register your views
nitroSolid()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 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}'])); -
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/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:
<?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:
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:
<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 } = propsreads the title once, and never again. Readprops.titlewhere you use it, or usesplitProps. - Deep reads are fine-grained too.
props.form.emailupdates when the email changes, not when another key offormdoes. 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.processingin JSX updates as the call starts and ends.
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 whatnitro:modeldoes in Blade.- A browser method, which is the PHP you wrote, compiled:
props.addTag(tag),props.removeLine(index).
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 |
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:
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()andgetMimeType(), as on the server. - Refused files: a file the
nitro.uploads.rulesrefuse puts the message inprops.errors.cover, and the property keeps what it had. The promise resolves withnull. - 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.
Links and visits
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):
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:
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:
// 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:
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.
/* <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:
// 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 inonMount(), 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:
.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 Solid views' code before the page shows, so the page appears with them rendered, not filling in after it.