JavaScript views
Overview
A component's view can be a Vue, React, Solid or Svelte component instead of Blade. The PHP class doesn't change: its properties, its methods compiled to run in the browser, its #[Server] methods and their rules. Only the view does, and it works with the component by the same names, in the framework's own way.
On this page
Blade is Nitro's own view engine: Nitro compiles it, and renders it on the server and in the browser. A JavaScript view is a component of the framework you choose, built by your app's Vite like any other. Nitro gives it the component (its state, its methods, its server calls, its validation messages) and keeps them in step. You write the view the way that framework is written, and the PHP the way Nitro is.
v-model on the component's properties; useNitro() refs in <script setup>.
React
The component as props, re-rendered as it changes; useNitro() for the tree below.
Solid
Fine-grained props: only what reads a changed value updates.
Svelte
The component from $props(), reactive with runes.
When to use one
Blade is the default, and the right choice for most views. Reach for a JavaScript view when:
- A library you want is written for a framework: a rich text editor, a chart, a date picker, a drag-and-drop board, a map.
- Your team already writes one, and has components to reuse.
- A view is mostly interaction: many small pieces of state that live only in the browser, animation, canvas work.
You don't have to choose for the whole app. The choice is per component, and components with different views sit side by side on a page, talking through events and stores as any Nitro components do.
One component, any view
The class names its view with #[View]: vue:, react:,
solid: or svelte:. Everything else is the class you would write for Blade:
<?php
namespace App\Nitro\Components;
use App\Models\Post;
use Nitro\Attributes\Computed;
use Nitro\Attributes\Server;
use Nitro\Attributes\Validate;
use Nitro\Attributes\View;
use Nitro\Component;
#[View(vue: 'post-editor')]
class PostEditor extends Component
{
#[Validate('required|min:3|max:120')]
public string $title = '';
public string $body = '';
public array $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;
}
}
#[Server]
public function save(): string
{
$this->validate();
$post = auth()->user()->posts()->create([
'title' => $this->title,
'body' => $this->body,
'tags' => $this->tags,
]);
return "Saved as draft #{$post->id}";
}
}
Whichever view it has, the component behaves the same:
addTag()runs in the browser, compiled from the PHP. No request.save()runs on the server, with the browser's changes sent along and the state signed, so#[Locked]properties can't be forged. Its rules are checked there, and its value comes back to the view.wordsis worked out again whenbodychanges, wherever it changes.- Validation messages, unsaved changes, uploads, and a call's progress reach the view the same way.
Tested as one
Nitro's browser tests run one component against its Blade, Vue, React, Solid and Svelte views, with the same steps, and expect the same results from each: typing, methods, server calls, validation, uploads, links, server rendering and hydration.
What every view gets
Each framework's page shows these in its own idiom (refs in Vue, props in React and Solid, $props()
in Svelte), but the names and what they do are the same in all four:
| Example | What it is | |
|---|---|---|
| Properties |
title
,
tags
|
By their PHP names. Arrays, models as objects with their attributes, uploads as
Upload
objects, enums as their cases
|
| #[Computed] values |
words
|
Worked out again when what they read changes |
| Browser methods |
addTag(tag)
|
Run in the browser, compiled from the PHP |
| #[Server] methods |
save()
|
A promise of the method's value;
null
when its rules refused the data
|
| A call's status |
save.processing
save.slow
save.failed
save.error
|
slow
once the call has taken 200 ms;
failed
when the request itself failed
|
errors
|
errors.title?.[0]
|
The validation messages, by field, as
$errors
has them
|
unsaved
|
unsaved.length
|
The properties changed in the browser that the server hasn't seen yet |
uploads
|
uploads.cover?.progress
|
Each upload while it runs:
uploading
,
progress
,
error
|
set(path, value)
|
set('form.email', value)
|
Writes a property, or a key under one |
upload(path, files)
|
upload('cover', files)
|
Files into an
Upload
or
#[Uploads]
property
|
dispatch(event, ...)
|
dispatch('saved', id)
|
As
$dispatch
in Blade
|
link(href, ...modifiers)
|
link('/posts', 'prefetch')
|
A
nitro:navigate
link's attributes, to spread on an
<a>
|
visit(url)
|
visit('/posts')
|
A visit without a reload, as
$visit
in Blade
|
Several frameworks in one app
Each framework's page shows its own setup. To use several, add each one's Vite plugin and register each one's
views. React and Solid both compile .jsx, so tell each which folders are its own:
// vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
import react from '@vitejs/plugin-react';
import solid from 'vite-plugin-solid';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import nitro from './vendor/nitro/nitro/js/vite.js';
export default defineConfig({
plugins: [
laravel({ input: ['resources/js/app.js'], refresh: true }),
nitro(),
vue(),
svelte(),
// React and Solid both compile .jsx: give each its own folders.
react({ include: /resources[\\/](views|js)[\\/]react[\\/].*\.[jt]sx$/ }),
solid({ include: /resources[\\/](views|js)[\\/]solid[\\/].*\.[jt]sx$/ }),
],
});
// resources/js/app.js
import { nitroVue } from 'nitro/vue';
import { nitroReact } from 'nitro/react';
import { nitroSolid } from 'nitro/solid';
import { nitroSvelte } from 'nitro/svelte';
nitroVue(import.meta.glob(['../views/vue/**/*.vue', './vue/**/*.vue']));
nitroReact(import.meta.glob(['../views/react/**/*.{jsx,tsx}', './react/**/*.{jsx,tsx}']));
nitroSolid(import.meta.glob(['../views/solid/**/*.{jsx,tsx}', './solid/**/*.{jsx,tsx}']));
nitroSvelte(import.meta.glob(['../views/svelte/**/*.svelte', './svelte/**/*.svelte']));
Nitro's Vite plugin gives you nitro/vue, /react, /solid and
/svelte (and their /server parts for server rendering), keeps a single copy of each
framework in the bundle, and leaves a .vue or .svelte file's <php>
block to Nitro. Only the frameworks you register are bundled.
Side by side on a page
Components are placed from Blade, whatever their view. A page can hold a Vue desk, a React card and a Blade list together:
{{-- resources/views/nitro/pages/editor.blade.php: one page, three engines --}}
<x-layouts.app>
<h1>Editor</h1>
<div class="grid grid-cols-3 gap-6">
<nitro:vue.post-list /> {{-- picks a post, publishes it --}}
<nitro:react.post-preview /> {{-- hears both, and shows the post --}}
<nitro:posts.recent :limit="10" /> {{-- a Blade view, hearing the same events --}}
</div>
</x-layouts.app>
Each JavaScript view is its own app (its own Vue app, React root, Solid root or Svelte app) mounted in its
component's element. A JavaScript view can't place <nitro:...> components inside itself;
compose them from Blade, as above.
Components that talk to each other
Components with different views talk the way any Nitro components do: through their PHP. One dispatches an
event, the others listen with #[On]. None of them needs to know what the others are written in,
and their listeners run in the browser, so the views follow at once.
<?php
// app/Nitro/Components/Vue/PostList.php: #[View(vue: 'post-list')]
/** Picked: said at once, in the browser. */
public function pick(int $id): void
{
$this->post = $id;
$this->dispatch('post-picked', id: $id);
}
/** Published on the server; the others hear of it with the answer. */
#[Server]
public function publish(): void
{
$this->validate();
$post = Post::findOrFail($this->post)->publish();
$this->dispatch('post-published', post: $post->only('id', 'title', 'category'));
}
<?php
// app/Nitro/Components/React/PostPreview.php: #[View(react: 'post-preview')]
#[On('post-picked')]
public function show(int $id): void
{
$this->current = $id;
}
#[On('post-published')]
public function published(array $post): void
{
$this->published[$post['category']] = ($this->published[$post['category']] ?? 0) + 1;
}
- From a browser method, the event is sent in the browser: no request.
- From a
#[Server]method, it is sent with the answer, once the server has done its work. - From the view itself,
dispatch('event', ...)sends one, as$dispatchdoes in Blade. - To one component:
$this->dispatch(...)->to('react.post-preview'). - The view's own code can listen on
windowfor an event sent without->to().
For state that several components share and keep, rather than news of something that happened, use a store.
The PHP in the view's file
A small component can live in one file: its class in a <php> block at the top of a
.vue or .svelte file, or in a /* <php> ... </php> */ comment
at the top of a .jsx or .tsx file. Nitro makes it a class named after the view, and
an error in it names the line in the view. Each framework's page has an example.
The two ways mix freely in one app: a class with its view beside it for components that grow, both in one file for small ones.
Keeping them by framework
A component's name follows its folder, so putting classes in a folder per framework puts the framework in the tag where the component is placed:
{{-- app/Nitro/Components/Vue/PostEditor.php, React/ViewsChart.php, Solid/CommentThread.php, Svelte/TagPicker.php --}}
<nitro:vue.post-editor />
<nitro:react.views-chart />
<nitro:solid.comment-thread />
<nitro:svelte.tag-picker />
It is a convention, not a rule: feature folders (Posts/, Comments/) work the same.
The view's file is named by #[View], so moving a class doesn't move its view.
On a visit
When nitro:navigate visits a page, the browser loads the code of the JavaScript views the page
places before it shows the page, alongside its data. The page appears with its views already rendered, not
filling in one by one after it. A view's code loads once, and is kept for the next page that shows it.
Server rendering
Without it, the page's HTML has each JavaScript view's element and the component's state, and the browser renders the view. With server rendering, a small Node service renders the views into the page's first HTML, for search engines and a first paint that shows them, and the browser hydrates them. It is optional, falls back to the browser whenever it can't render, and needs no change to the views.
Framework upgrades
All four adapters share one core, which holds everything about a Nitro component that isn't a framework's own
reactivity. Each adapter only binds that core to its framework, through the framework's public API alone (Vue's
refs, React's useSyncExternalStore, Solid's stores, Svelte's $state). A new release of
Vue, React, Solid or Svelte needs nothing from Nitro beyond the tests that run each view against the others.
Another framework
nitro.views lists the engines: where each one's views are, their extensions, and the adapter that
knows the framework's files. Add an entry for another framework, with an adapter class of your own (extending
Nitro\View\ScriptEngine) or none, and register its browser side with
Nitro.engine(). See a view engine of your own.