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()andsetSessionData()reach the tab's session,tabState()andsetTabState()keep keys of the component's own,dispatch(),execScript()andpatchElements()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
- Scopes: share component state across users
- Views: how component views update independently
- Composition: components as classes
- Lifecycle: connect, disconnect, and timer hooks
- API: component(): full method signature