Common Pitfalls
A collection of bugs that are easy to hit and hard to diagnose. Each one describes what goes wrong, why it happens, and how to fix it.
Broadcast re-renders other clients with stale data
You call $app->broadcast() after an action, but other clients' views show outdated values.
Why: When broadcast() fires, every context in the scope re-runs its view
closure. If the view reads from a per-client signal (e.g. $votes->getValue()), each client
reads its own signal — which was only updated for the client that triggered the action.
Clients that didn't trigger the action still have stale signal values.
Fix: Read shared state directly inside the view closure instead of from a
per-client signal. $app->globalState() is always correct for all clients:
// ❌ stale for other clients
$c->view(function () use ($votes) {
$count = $votes->int(); // only correct for the client that called $votes->setValue()
});
// ✅ correct for everyone
$c->view(function () use ($app) {
$count = (int) ($app->globalState('vote_count') ?? 0);
}); Shared counter uses globalState + manual broadcast instead of scoped signals
You track shared state with $app->globalState() and a Signal,
manually keeping both in sync and calling $app->broadcast() yourself.
The action is verbose and error-prone.
Why: Scoped signals already are shared state. When a context has
a non-TAB scope, every $c->signal() call for the same name returns the same
Signal object across all contexts in that scope. Calling setValue()
updates the single shared instance and auto-broadcasts by default. Mirroring the value into
globalState creates two sources of truth you must synchronize manually.
// ❌ dual state — globalState + signal kept in sync manually
$c->scope(Scope::GLOBAL);
$count = $c->signal($app->globalState('count'));
$increment = $c->action(function () use ($app, $count): void {
$newVal = $app->globalState('count') + 1;
$app->setGlobalState('count', $newVal);
$count->setValue($newVal);
$app->broadcast(Scope::GLOBAL);
});
// ✅ scoped signal is the single source of truth
$c->scope(Scope::GLOBAL);
$count = $c->signal(0);
$increment = $c->action(
fn () => $count->setValue($count->int() + 1)
// auto-broadcasts because signal scope != TAB
); When is globalState appropriate? For server-side values
that are not signals — e.g. a status string read inside a view closure, or data shared across
different routes that don't have a common scope. If the value drives a data-text
or data-bind in the browser, use a scoped signal instead.
data-text uses the wrong signal ID
You write data-text="$votes.tab" in your template, but the counter never updates
(or updates with empty/undefined).
Why: Signal IDs are namespaced. A signal named votes inside a component
with namespace poll gets the ID poll.votes — but inside a component the dot
is rendered as-is in Datastar's signal store. If you hardcode the ID you'll likely get it wrong.
Fix: Always use $signal->id() in PHP and pass it to the template.
Never hard-code signal IDs in HTML:
$c->view(function () use ($votes, $twig) {
return $twig->render('poll.html.twig', [
'votes_id' => $votes->id(), // e.g. "poll.votes"
]);
}); <!-- ❌ hard-coded, will break if namespace changes -->
<span data-text="$poll.votes.tab">0</span>
<!-- ✅ dynamic, always correct -->
<span data-text="${{ votes_id }}.tab">0</span> Full-page patch overwrites component updates
You use $c->component() to build isolated sub-contexts but on every broadcast
the entire page is replaced, wiping out or replacing the fresher component patches.
Why: A page-level $c->view() with a Twig template re-renders the
whole page HTML on every broadcast and sends it as an elements patch with no selector —
Datastar morphs the entire body. Component patches target specific #c-... divs, but the
page patch can arrive after and overwrite them with stale embedded HTML.
Fix: If your page doesn't use components and the template has a named block
around the dynamic content, use block: to send only that block on updates:
// Preferred: send only the 'demo' block on SSE updates
$c->view(fn() => $c->render('page.html.twig', $data), block: 'demo'); If the page does use components, make the page-level view a no-op on updates instead. Components own their own patches:
$c->view(function (bool $isUpdate) use ($c, $data): string {
if ($isUpdate) {
return ''; // components handle their own patches
}
return $c->render('page.html.twig', $data);
}); Variable is null inside a view closure
You get a fatal "Call to a member function on null" inside a $c->view() closure.
Why: PHP closures don't capture outer variables automatically. The use
list on the outer page handler and the inner view closure are separate. A variable available in the
route handler (e.g. $twig, $app) is not available inside the view closure
unless explicitly listed in its use.
Fix: Add the variable to both the route handler's use and the inner
view closure's use:
// ❌ $twig is null inside the view closure
$app->page('/', function (Context $c) use ($shell): void {
$c->view(function () use ($twig) { // $twig was never captured by the outer closure
return $twig->render('home.html.twig');
});
});
// ✅
$app->page('/', function (Context $c) use ($shell, $twig): void {
$c->view(function () use ($twig): string {
return $twig->render('home.html.twig');
});
}); Scoped signals return stale values after broadcast
You create a signal with a non-TAB scope (e.g. Scope::routeScope('/')).
After calling broadcast(), some clients re-render with the old value.
Why: Scoped signals are shared objects. If the view is cached (default for ROUTE/SESSION/GLOBAL scopes), later clients reuse the cached HTML without re-rendering. The cached HTML contains the old value baked in. Only the first render after a cache miss picks up the new value.
Fix: Disable view caching for views that must always reflect current state:
$c->view('presence.html.twig', ['count' => count($app->getClients())], cacheUpdates: false); PatchElementsNoTargetsFound on partial block rendering
You use Twig block rendering to send only a fragment on updates
(e.g. $c->render('page.html.twig', $data, 'content')), but the browser
console shows PatchElementsNoTargetsFound and nothing updates.
Why: Datastar's merge-fragments mode reads the id from the
root element in the fragment and looks for a matching element in the DOM. If the block content
has no root element with an id, there's nothing to match against.
Fix: Make sure block content has a single root element with a unique id:
{# template.html.twig #}
{% block demo %}
<div id="todo-list">
{# ✅ Datastar reads id="todo-list" from the fragment and morphs that element #}
{% for item in items %}
<li>{{ item.text }}</li>
{% endfor %}
</div>
{% endblock %} $c->view(fn () => $c->render('template.html.twig', $data), block: 'demo'); Partial update replaces the entire page (duplicate id="content")
You render a block on update whose outermost element uses id="content", but so
does the <main> wrapper in your shell template. After the first broadcast,
the entire page (header, source panel, layout) disappears and only the fragment remains.
Why: Datastar's merge-fragments mode looks for a DOM element matching the
root element's id in the patch. If your fragment and the shell both use
id="content", Datastar replaces the outer <main> — wiping out
everything around the fragment.
Fix: Give the block's root element a unique ID that doesn't collide with the shell:
{# ❌ collides with <main id="content"> in the shell #}
{% block demo %}
<div id="content">...</div>
{% endblock %}
{# ✅ unique ID — Datastar morphs only this element #}
{% block demo %}
<div id="todo-list">...</div>
{% endblock %} Timer keeps firing after every client disconnects
You set up a $app->setInterval() timer on server start that broadcasts to a route.
Even when no clients are on that route, the timer keeps running and broadcasts uselessly.
Why: setInterval runs for the lifetime of the server, not per client.
It has no built-in awareness of connected clients.
Fix: Guard broadcasts with a client check, or use onClientConnect
and onClientDisconnect hooks instead of a timer for presence-style updates:
// Guard the timer
$app->setInterval(function () use ($app): void {
if (empty($app->getClients())) {
return;
}
$app->broadcast(Scope::GLOBAL);
}, 1000);
// Or eliminate the timer entirely with connect/disconnect hooks
$app->onClientConnect(function (Context $c) use ($app): void {
$app->broadcast(Scope::routeScope($c->getRoute()));
});
$app->onClientDisconnect(function (Context $c) use ($app): void {
$app->broadcast(Scope::routeScope($c->getRoute()));
}); Shell templates don't support Twig — use page-level inheritance instead
You try to use {% extends %}, {% block %}, or other Twig tags inside
a shell template (the HTML file passed to withShellTemplate() or
setShellTemplate()), but nothing happens or you get a parse error.
Why: Shell templates are processed by HtmlBuilder using a plain
str_replace(), not the Twig engine. They only understand the six literal
placeholders: {{ signals_json }}, {{ context_id }},
{{ base_path }}, {{ head_content }}, {{ content }},
and {{ foot_content }}. Twig tags are invisible to this processor and will be
emitted as raw text.
Fix: Don't put Twig logic in shell templates. Instead, move the layout into
a proper Twig base template and have your page templates extend it. The full HTML document
(including <!DOCTYPE html>, nav, footer) lives in the base template;
page templates fill in {% block content %}. HtmlBuilder detects a
rendered <html> response and passes it through unchanged:
{# templates/shells/base.html.twig #}
<!DOCTYPE html>
<html>
<head>
<meta data-signals='{"via_ctx":"{{ contextId }}","_disconnected":false}'>
<title>{% block title %}{% endblock %}</title>
</head>
<body>
<nav>...shared nav...</nav>
<main>{% block content %}{% endblock %}</main>
</body>
</html>
{# templates/pages/home.html.twig #}
{% extends 'shells/base.html.twig' %}
{% block title %}Home{% endblock %}
{% block content %}
<h1>Hello</h1>
{% endblock %}
The key variable is {{ contextId }} (injected automatically by
Context::render()) — not {{ context_id }} or
{{ via_ctx }} which are the HtmlBuilder placeholders.
Action returns HTTP 400 "Bad Request"
You click a button or trigger a keyboard shortcut and the action request returns
400 Bad Request. Nothing happens in the UI.
Why: Every action request includes a via_ctx signal that
identifies the server-side context. The server looks up this ID in its in-memory context
registry. A 400 means the ID is missing or doesn't match any active context. Common causes:
- The server was restarted — all contexts from the previous process are gone, but the browser still has stale action URLs and signal values from the old session.
- The SSE connection dropped and the context was cleaned up via
onDisconnect, but the UI wasn't refreshed. - The
via_ctxsignal was omitted from the request body or query params.
Fix:
- Stale context after restart or disconnect: Refresh the page. This creates a new context with a fresh ID. If this happens frequently, check that your SSE connection is stable — a flaky connection causes repeated context creation and teardown.
- Custom payload in e.g.
@post(): If you pass a custom object to actions like@post('/action', {some: 'payload'}), the custom object replaces the signal store —via_ctxand all other signals are no longer included. Either omit the second argument and let Datastar send the full store, or includevia_ctxexplicitly in your custom payload.
Debugging tips
- Add
<pre data-json-signals></pre>to your template to see the live Datastar signal store in the browser. Remove before deploying. - Check signal IDs with
var_dump($signal->id())— namespace prefixing can produce unexpected IDs. - Run the server with
LOG_LEVEL=debugto see every render, cache hit, and broadcast in the terminal output. - If the view closure is never called, confirm
$c->view()is actually reached — missing or mis-orderedusevariables silently skip execution if a required call throw for a null object.
See also
- Broadcasting — scope targets and update strategies
- Lifecycle — connect, disconnect, timers
- Views — caching behaviour and update cycles
- Signals — scope types and sharing state