Views
A view is the HTML a context renders for its tab. php-via renders it for the page load, again on every SSE connect, and whenever the tab syncs or a broadcast reaches it. Datastar morphs the new HTML into the page, so only the elements that differ change.
Defining a view
Call $c->view() once per context, with a callable or a template name:
// A closure that returns HTML needs nothing but php-via
$c->view(fn (): string => <<<HTML
<div id="counter">
<p>Count: <span data-text="{$count->ref()}">{$count->int()}</span></p>
<button data-on:click="@post('{$increment->url()}')">+1</button>
</div>
HTML);
// A template gets the context's signals and actions by name: pass only the rest
$c->view('counter.html.twig');
$c->view('todo.html.twig', ['todos' => $todos]);
// A callable as data runs on every render
$c->view('todo.html.twig', fn (): array => ['todos' => $repository->all()]);
The signature is view($view, $data = [], ?string $block = null, bool $shareRender = false, bool|string $viewTransition = false).
$data and block: go with a template name: a callable view with either throws,
and so does a string of HTML in place of a template name.
A template view needs a template engine. Config::withTemplateDir() sets up Twig, after
composer require twig/twig, and Config::withTemplateEngine() takes any
Rendering\TemplateEngine. Without one, view('page.html.twig') throws and names
the command. See Twig templates.
An update of the page finds its place by id, so give the view's top-level element an id, as
counter above. Without one, Datastar drops the update and logs
PatchElementsNoTargetsFound in the browser console, and dev mode logs a warning once per
route. A component needs none: php-via wraps it in an element with an id of its own.
Partial updates with block:
By default, every SSE update re-renders the entire template and sends it to the browser. When your template uses Twig blocks, you can tell php-via to send only a named block on updates, keeping the initial page load complete while making SSE patches smaller and faster:
// On initial load: renders the full template (layout, nav, content)
// On SSE updates: renders only {% block demo %}
// addTodo (action) is auto-injected; only pass non-signal data explicitly
$c->view('todo.html.twig', fn (): array => [
'todos' => self::$todos,
], block: 'demo');{# todo.html.twig #}
{% extends 'base.html.twig' %}
{% block content %}
<h1>My App</h1>
<p>Description and chrome that only renders on initial load.</p>
{% block demo %}
<div id="todo-list">
{# This block is all that gets sent on SSE updates #}
{# The root element needs a unique id so Datastar can find the morph target #}
{% for todo in todos %}
<li>{{ todo.text }}</li>
{% endfor %}
</div>
{% endblock %}
{% endblock %}Pass the data as an array, or as a callable that builds it on every render:
// Data fixed when the view is defined
$c->view('todo.html.twig', ['todos' => $todos], block: 'demo');
// Data read again on every render
$c->view('todo.html.twig', fn (): array => ['todos' => self::$todos], block: 'demo');When to use block:
- Use it when your template has a named block wrapping the dynamic content and the surrounding layout is static (header, description, nav).
- Give block content a root element with a unique
id: Datastar uses the root element'sidto find the morph target in the DOM. Any unique ID works (e.g.todo-list,game-grid). - Don't need it when only signals change: they reach the page without a render.
- Don't need it for components: each
component already patches only its own
<div>.
Twig renders single blocks. With a template engine that cannot, block: throws.
Advanced: the $isUpdate parameter
View callables receive (bool $isUpdate, string $basePath) as arguments.
Most views don't need these: block: handles the common case. When the part that
changes is rendered on its own, in PHP or in a separate template, $isUpdate lets
updates send only that part:
// Updates send only <div id="live">; Datastar morphs it by its id
$c->view(function (bool $isUpdate) use ($c, $data): string {
$live = $c->render('live.html.twig', $data);
if ($isUpdate) {
return $live;
}
return $c->render('page.html.twig', $data + ['live' => $live]);
});
On a page with components, each component has a namespace
($c->component($fn, 'cart')), so it gets the same id every time its page is built. A
revived tab's component updates then find the elements the browser still has, even when the page
returns '' on updates.
When a view renders
A view renders again, and its HTML goes to the tab, when:
- code of the tab calls
$c->sync(), - a broadcast reaches the tab:
$app->broadcast(),$c->broadcast(), or a write to a scoped signal of a scope the tab is in, - the tab's SSE stream connects, the first time and on every reconnect.
The signals an action changes go to the tab without a render. Each render reads the state as it is then, and Datastar compares the new HTML with the page and morphs only the nodes that differ.
Attributes the browser sets
A morph sets each element's attributes to those of the server's markup, and every stream connect,
the first and each reconnect, renders the view again. So an
attribute the browser set by itself goes: the open that showModal() puts on
a <dialog> closes the dialog at the next update. List such attributes in
data-preserve-attr, and Datastar leaves them as the browser has them:
<dialog id="confirm" data-preserve-attr="open">
<p>Delete {{ count }} rows?</p>
<button data-on:click="@post('{{ delete_url }}')">Delete</button>
</dialog>
<button data-on:click="document.getElementById('confirm').showModal()">Delete rows</button>
The dialog stays open and modal across updates and reconnects, and its content still updates. The
attribute takes several names separated by spaces, and it belongs in the markup the server renders:
Datastar reads it from the new HTML. data-ignore-morph instead keeps the whole element,
content included, as it is.
sync() and syncSignals()
$c->sync() renders the view and sends the changed signals.
$c->syncSignals() sends only the signals: the TAB signals that changed and the values of
the tab's scoped signals. An action's changed TAB signals go out by themselves when it returns, so
syncSignals() is for code where no action runs, such as a timer or a spawn()
task:
// Only a value changes: no need to render the view
$c->setInterval(function () use ($c, $clock): void {
$clock->setValue(date('H:i:s'));
$c->syncSignals();
}, 1000);Sharing one render
Each tab renders its own update. A view that is the same for every tab in its scope can pass
shareRender: true: when a broadcast reaches the scope, the first context renders the view,
and every other context with the same primary scope, route and component receives the same HTML. The
primary scope is the one scope() set. On a context whose primary scope is TAB,
shareRender: true throws when the view renders, and a full-document view never shares
its render.
Only views without anything per tab can share: no TAB signal ids, no visitor's name and no components,
whose wrapper ids are unique to their tab. A broadcast drops the shared render of every context it
reaches, so a view that reads state outside signals, such as $app->getClients(), is
current after each broadcast.
// Every tab on this route shows the same board, so it renders once per broadcast
$c->scope(Scope::ROUTE);
$c->view('board.html.twig', fn (): array => ['cells' => $board->cells()], block: 'board', shareRender: true);Views that render the whole document
A view that returns a whole document, from <!DOCTYPE html> to
</html>, takes the shell's place. It writes the tags that connect the page itself:
via_head right after <meta charset>, and via_foot before
</body>. A closure writes them with $c->viaHead() and
$c->viaFoot():
$c->view(fn (): string => <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
{$c->viaHead()}
<title>Counter</title>
</head>
<body>
<main id="counter">
<span data-text="{$count->ref()}">{$count->int()}</span>
<button data-on:click="@post('{$increment->url()}')">+1</button>
</main>
{$c->viaFoot()}
</body>
</html>
HTML);
A Twig layout writes {{ via_head() }} and
{{ via_foot() }}, and a custom shell
{{ via_head }} and
{{ via_foot }}. via_head writes the tab's id,
the SSE connect with its reconnect, the beacon that closes the context when the tab goes and, with
withDatastarRocket() or withImportMap() entries, the import map.
via_foot loads Datastar from Config::getDatastarUrl(). php-via logs a warning
once per shell or route that has no via_head, or loads a second Datastar next to it. See
Web components for custom shells.
Under a Content-Security-Policy with a nonce, have a middleware set the nonce as the
via.csp_nonce request attribute. Every tag via_head and via_foot
write carries it, and so do the Dev Bar's:
final class CspNonce implements MiddlewareInterface {
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {
return $handler->handle($request->withAttribute('via.csp_nonce', base64_encode(random_bytes(16))));
}
}
$app->middleware(new CspNonce());
The middleware sends the policy as a header on the response it gets back from
$handler->handle(), or the layout carries it in a <meta http-equiv>
tag. Datastar takes the nonce from data-nonce on <html>, which the default
shell writes, a custom shell with {{ via_html_attrs }} and a
Twig layout with {{ via_html_attrs() }}:
Content Security Policy shows the layout.
Patching HTML outside the view
$c->patchElements($html, $selector, $mode) sends HTML to the page without rendering the
view, for a modal, a toast or chunks of streamed output. The mode is a PatchMode case, one
per Datastar mode. Outer, the default, and Replace find their target by the id
of the HTML's top-level element. Inner, Prepend, Append,
Before, After and Remove need a selector, and Remove
needs no HTML:
use Mbolli\PhpVia\PatchMode;
$c->patchElements('<li>' . htmlspecialchars($line) . '</li>', '#log', PatchMode::Append);
$c->patchElements('<div id="toast">Saved</div>'); // Outer, by id
$c->patchElements(selector: '#toast', mode: PatchMode::Remove);
Patches wait for the tab's stream like sync(), and none is dropped: a client that falls
behind gets them all. A component's patches go to its page. The next render of the view morphs the
view's own elements back to its HTML, so patch elements outside the view, or keep what you add in the
state the view renders.
View transitions
viewTransition: true on view() or patchElements() asks Datastar to
apply the patch inside the browser's
View Transition API.
A CSS selector instead of true scopes the transition to that element. The page load and the
render a stream sends when it connects have no transition, since they show what the page already shows.
A browser without view transitions, or without scoped ones for a selector, applies the patch without one.
$c->view('gallery.html.twig', viewTransition: true); // every update animates
$c->view('board.html.twig', viewTransition: '#board'); // scoped to #board
$c->patchElements('<div id="toast">Saved</div>', viewTransition: true);
Give the elements that should move between states a view-transition-name in CSS; the
rest cross-fades. While a document transition runs, its overlay covers the page and takes the clicks,
and a new transition skips the one still running, so rows jump to their place instead of gliding. For a
scope that many visitors update, Config::withBroadcastThrottle() with an interval longer
than the transition lets each one finish: the Leaderboard example
uses 400 ms. Leave transitions off for views that update several times a second, such as a ticker.
Navigating between pages needs no server support: php-via loads every page in full, so
@view-transition { navigation: auto; } in the stylesheet animates it in browsers that
support transitions between documents.
Views in components
Each component defines its own view. A component's update patches only its own wrapper element, and a sync of the page renders a component again only when the page view embeds it or its signals changed:
$counter = $c->component(function (Context $c): void {
$n = $c->signal(0, 'n');
$inc = $c->action(fn () => $n->increment(), 'inc');
// n and inc are auto-injected into counter.html.twig
$c->view('counter.html.twig');
}, 'counter');
$c->view(fn (): string => $c->render('page.html.twig', ['counter' => $counter()]));Events and scripts
$c->dispatch() fires a CustomEvent on window, with a detail
php-via JSON-encodes, so no value breaks out of the script. For anything else, such as starting an
animation, $c->execScript() runs a script you write:
$c->action(function (Context $c) use ($msg): void {
$msg->setValue('Saved!');
$c->dispatch('toast', ['text' => 'Saved!']);
$c->execScript('document.querySelector(".card").animate([{opacity: 0.5}, {opacity: 1}], 300)');
}, 'save');<div id="toast" data-on:toast__window="el.textContent = evt.detail.text"></div>Next steps
- Signals: state that drives view updates
- Actions: triggers that cause re-renders
- Components: isolated views with their own signal scope
- Twig templates: binding signals in templates