Documentation menu

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()}')\"&gt;+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's id to 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:

  1. $c->sync() is called (either explicitly or via auto-broadcast on scoped signals)
  2. php-via re-renders the view callable/template with fresh values
  3. The new HTML is diffed against the previous render and patched to the browser via SSE
  4. 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