Documentation menu

Components

Components are reusable UI blocks backed by their own sub-context. Each component has its own signals, actions and view, and shares its page's SSE stream. They're the building block for composable real-time UIs.

Creating a component

Define a component as a callable that configures a Context, as a page handler does:

$counterComponent = function (Context $c): void {
    $count = $c->signal(0, 'count');
    $inc   = $c->action(fn () => $count->increment(), 'inc');

    $c->view(fn (): string => <<<HTML
        <p>Count: <span data-text="{$count->ref()}">{$count->int()}</span>
            <button data-on:click="@post('{$inc->url()}')">+1</button></p>
        HTML);
};

A template view works the same way: $c->view('components/counter.html.twig') gets count and inc as variables.

Mounting a component

Mount a component inside a page handler with $c->component() and a namespace. The returned callable renders the component's current HTML. Call it inside the page's view closure, so every render of the page carries the component as it is now:

$app->page('/', function (Context $c) use ($counterComponent): void {
    $counter = $c->component($counterComponent, 'counter');

    $c->view(fn (): string => <<<HTML
        <main id="home">
            <h1>Welcome</h1>
            {$counter()}
        </main>
        HTML);
});

With Twig, pass it the same way: $c->view(fn (): string => $c->render('pages/home.html.twig', ['counter' => $counter()])), and print it with {{ counter | raw }}.

The page view renders again on a broadcast that reaches the page, on a reconnect and on a revival. Passing $counter() to $c->view('pages/home.html.twig', [...]) renders the component once, when the handler runs, so each of those renders would put the page-load HTML back. A component the page frame carries sends only its signals in that update, so rendering it with the page does not render it twice.

Each component's wrapper id is unique to its tab, so the HTML of a page that embeds components differs per tab and must not be shared: leave shareRender off on that page's view().

Namespaces

component() needs a namespace, unique on its page. It prefixes the component's signal ids and action URLs, so one component can be mounted several times, and it keeps the component's id the same each time the page is built, so a revived tab's component finds its elements. It takes letters, digits, _ and - only. Any other character, or a namespace the page already has, throws an InvalidArgumentException:

$counterA = $c->component($counterComponent, 'counter-a');
$counterB = $c->component($counterComponent, 'counter-b');

// counter-a has signal ID 'counter_a_count____dn', action '/_action/counter-a-inc'
// counter-b has signal ID 'counter_b_count____dn', action '/_action/counter-b-inc'

$c->view(fn (): string => <<<HTML
    <main id="counters">{$counterA()} {$counterB()}</main>
    HTML);

For a list, build each namespace from the item's key, such as 'item-' . $item->id, or 'item-' . md5($key) for a key with other characters.

Actions and state in a component

A component action runs for its own tab, whatever scope the component joined. The TAB signals it changed go to the tab when it returns, as for a page action. Inside a component:

  • $c->getPageContext() returns the page the component sits on, to tell which tab an action came from,
  • $c->isConnected() says whether the tab's stream is open,
  • getSessionId(), sessionData() and setSessionData() reach the tab's session,
  • tabState() and setTabState() keep keys of the component's own,
  • dispatch(), execScript() and patchElements() go to the page.

How a component updates

A component's sync() renders its view and patches only its own wrapper element. A sync of the page renders the page view, which carries the components it embeds, and renders the other components only when their signals changed. A component whose view reads other state, such as a database, calls sync() on its own context, or joins a scope and renders on its broadcasts.

Scoped components

Components can join scopes of their own, independent of the page. The website's presence indicator is a component in a scope of its own, so a visitor arriving or leaving renders the indicators and nothing else:

$presenceComponent = function (Context $c) use ($app): void {
    $c->scope('site:presence'); // only presence indicators join this scope

    $c->view(function () use ($app): string {
        $count  = count($app->getClients());
        $person = $count === 1 ? 'person' : 'people';

        return "<p>{$count} {$person} online</p>";
    });
};

$app->onClientConnect(fn () => $app->broadcast('site:presence'));
$app->onClientDisconnect(fn () => $app->broadcast('site:presence'));

A broadcast that reaches the page renders the page view, and with it every component the view embeds, so give a busy widget its own scope and broadcast to that. A shared counter needs no broadcast call: its signal joins the scope, and the write broadcasts it.

$counterComponent = function (Context $c): void {
    $count = $c->signal(0, 'count', 'home:counter'); // joins home:counter

    $c->action(fn () => $count->increment(), 'increment');

    $c->view('counter.html.twig');
};

Component lifecycle

Components follow the same lifecycle as contexts, and they go with their page. They support onCleanup(), setInterval(), spawn() and all other context methods:

$roomComponent = function (Context $c) use ($room, $app): void {
    $c->addScope('room:' . $room);
    // ... signals, view ...

    $c->onCleanup(function (Context $c) use ($room, $app): void {
        Room::$members[$room]--;
        $app->broadcast('room:' . $room);
    });
};

Next steps