Views
A view is the HTML a context renders for its client. php-via re-renders and patches it automatically whenever signals change — no manual DOM manipulation required.
Defining a view
Call $c->view() once per context. It accepts either a Twig template name or a callable:
// Twig template
$c->view('counter.html.twig', [
'count_id' => $count->id(),
'count_val' => $count->int(),
'inc_url' => $increment->url(),
]);
// Callable (inline)
$c->view(function () use ($count, $increment): string {
return "<p>Count: {$count->int()}</p>"
. "<button data-on:click=\"@post('{$increment->url()}')\">+1</button>";
});
Partial updates with block:
By default, every SSE update re-renders the entire Twig 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 %}
$c->view(fn () => $c->render('todo.html.twig', [
'todos' => self::$todos,
'addTodo' => $addTodo,
]), block: 'demo', cacheUpdates: false); {# 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 %}
The block: parameter works with both string and callable views:
// String view — block auto-applied on updates
$c->view('todo.html.twig', ['todos' => $todos], block: 'demo');
// Callable view — same behavior, block auto-applied when $c->render() is called
$c->view(fn () => $c->render('todo.html.twig', ['todos' => $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 for signal-only updates (
syncSignals()) — no HTML is re-rendered at all. - Don't need it for components — each
component already patches only its own
<div>.
Advanced: the $isUpdate parameter
View callables receive (bool $isUpdate, string $basePath) as arguments.
Most views don't need these — block: handles the common case. But for
advanced scenarios (e.g. returning an empty string when components handle their own patches),
you can use $isUpdate directly:
// Page with components — skip page-level re-render entirely
$c->view(function (bool $isUpdate) use ($c, $counter, $chat): string {
if ($isUpdate) {
return ''; // components handle their own patches
}
return $c->render('page.html.twig', [
'counter' => $counter(),
'chat' => $chat(),
]);
}); How updates work
When a signal changes inside an action:
$c->sync()is called (either explicitly or via auto-broadcast on scoped signals)- php-via re-renders the view callable/template with fresh values
- The new HTML is diffed against the previous render and patched to the browser via SSE
- Datastar morphs only the changed DOM nodes — no full page reload
sync() vs syncSignals()
$c->sync() re-renders the view and pushes signal values.
If only signal values changed (no structural HTML change), use $c->syncSignals() — it skips
view re-rendering and is faster:
// Only a signal label changed — no need to re-render the whole view
$c->action(function (Context $c) use ($status): void {
$status->setValue('Saved!');
$c->syncSignals();
}, 'save');
View caching
By default, php-via caches the last render per scope and skips re-rendering if no signals in the context changed. This is safe for most views. Disable it when the view reads external state that isn't tracked by signals:
// This view reads $app->getClients() — not a signal, so caching must be disabled
$c->view(function () use ($app, $twig): string {
$count = count($app->getClients());
return $twig->render('presence.html.twig', [
'count' => $count,
'person' => $count === 1 ? 'person' : 'people',
]);
}, cacheUpdates: false);
Views in components
Each component defines its own view. Component views update independently from the parent context — only the signals owned by that component need to change for it to re-render.
$counter = $c->component(function (Context $c): void {
$n = $c->signal(0, 'n');
$inc = $c->action(fn() => $n->setValue($n->int() + 1) & $c->sync(), 'inc');
$c->view('counter.html.twig', ['n_val' => $n->int(), 'inc_url' => $inc->url()]);
}, 'counter');
$c->view('page.html.twig', ['counter' => $counter()]);
Executing scripts on update
To run JavaScript as part of an update (e.g. open a modal or trigger an animation),
use $c->execScript() inside an action:
$c->action(function (Context $c) use ($msg): void {
$msg->setValue('Saved!');
$c->execScript('document.querySelector(".toast").classList.add("visible")');
$c->sync();
}, 'save');
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