Signals

Server-owned reactive values — declare once, bind many, over one SSE connection

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

What is a signal?

A signal is a server-owned named value. You declare it once, bind it {{ signal('balance') }}in as many templates as you like, and one app.emit('balance', ...)updates every binding at once — over a single shared SSE connection.

Reach for a signal when the same live value appears in more than one place — a balance in the topbar and in a modal, an unread badge, a connection-status pill — and you want them all in lockstep from one push. That cardinality — one value, many bindings — is the thing a plain OOB swap cannot express: an OOB swap names one DOM id; a signal names a value.

New in 0.8.0
from chirp import App

app = App()


@app.signal("balance", initial=lambda: wallet.balance)
async def balance():  # push-only: driven by app.emit, yields nothing
    if False:
        yield 0


# ... later, in a mutation handler:
app.emit("balance", new_balance)   # every {{ signal('balance') }} updates
{{ signal_connect() }}            {# one shared connection wrapper #}
<header>
  <span class="token">{{ signal('balance') }}</span>          {# topbar #}
</header>
<main id="main">
  {% block content %}{% end %}    {# a modal here also binds {{ signal('balance') }} #}
</main>
</div>{# close the signal_connect() wrapper #}

Both bindings paint the current value server-side (no empty-then-fill flash), then swap in lockstep on everyapp.emit("balance", ...)— from one connection.

When to use a signal

A signal is for live values that appear more than once: a balance, a ticker, an unread badge, a status pill. For other shapes of real-time update, reach for a sibling type:

You want… Use
One named value mirrored in many places, kept in sync from one push signal() (this page)
A post-load event channel — notifications feed, chat tail, log stream [[docs/build-apps/streaming-updates/server-sent-events|EventStream]]
Slow initial-load data filled in after the shell paints Suspense
The first paint to stream in section-by-section Stream

For data-mutation-to-block fan-out keyed by document scope, see the Reactive System; signals are the thin, name-keyed surface built on the same bus.

The three pieces

A signal-powered page wires three things:

  1. A producer@app.signal(name, ...) (a live value) or @app.derived(name, on=(...)) (a value computed from other signals).
  2. The pushapp.emit(name, value)from a mutation handler, or an async sourcegenerator that yields successive values.
  3. The bindings{{ signal('name') }} / {{ signal_block('name') }} sinks, all living under one{{ signal_connect() }}wrapper.

Producers —@app.signal

A primary signal has one producer. Drive it either byapp.emit(push) or by an asyncsourcegenerator (pull). Use the decorated function as the source:

@app.signal("ticker", initial=first_spotlight, render=render_strip)
async def ticker():
    async for price in market.watch():
        yield price          # each yield renders + fans out as event: ticker
  • initial— a zero-arg callable returning the SSR seed value, so a binding paints its current value with no flash.
  • render — a value -> str mapper for the SSE data:payload and the seed (defaults tostr). Return HTML to swap a fragment, text to swap a scalar.
  • coalesce (default True) — latest-wins. A live value is idempotent, so dropping a stale update under back-pressure is safe.

For a push-only value, pass nothing to yield —app.emitdrives it:

@app.signal("balance", initial=lambda: wallet.balance)
async def balance():
    if False:
        yield 0   # never runs; the framework pumps it once and emit() takes over

Derived signals —@app.derived

A derived signal recomputes and re-emits whenever any of its dependencies changes.computereceives the current dependency values positionally, in declaration order:

@app.derived("net_worth", on=("balance", "holdings"))
def net_worth(balance, holdings):
    return balance + holdings

Bind it like any signal: {{ signal('net_worth') }}. A single app.emit("balance", ...) updates balance and re-derives net_worthin the same cascade. Derived-of-a-derived propagates too.

Bindings —signal(), signal_block(), signal_connect()

Three template globals, registered automatically when any signal exists:

  • {{ signal('name') }}— an SSR-seeded scalar sink: <span sse-swap="name" hx-target="this">{seed}</span>. The default sse-swap swap isinnerHTML.

  • {{ signal_block('name') }} — the same, for an HTML fragment, on a <div>. The seed is treated as already-rendered HTML (the signal'srenderproduced markup).

  • {{ signal_attrs('name') }}— the binding attributes only (sse-swap="name" hx-target="this"), for an existing element. The element keeps rendering its own SSR body; live eventsinnerHTML-swap it. Use this when a signal()/signal_block()wrapper would break the element's own layout — e.g. a CSS-grid container whose direct children must stay grid items, or a<ul>:

      <section class="board" {{ signal_attrs('market_stats') }}>
        {{ stat_strip_body(stats) }}   {# the section renders + re-renders its own body #}
      </section>
    
  • {{ signal_connect() }}— the one shared connection wrapper. Place it once in the shell. Every signal sink must live as a descendant, and you close the wrapper yourself after the last sink.

{{ signal_connect() }}
<header>
  <span class="balance">{{ signal('balance') }}</span>
  {{ signal_block('ticker') }}
</header>
<main id="main">
  {% block content %}{% end %}
</main>
</div>{# close the signal_connect() wrapper #}

Prefer {{ signal_attrs('name') }}for binding an existing element: its call-site is recorded for topic scoping and recognised by the contract, so the binding is validated even though thesse-swapis produced at render time. A hand-written sse-swap="name"attribute also works, but it is only contract-validated when the template either opens the connect itself or is composed under a layout that does.

Per-visitor signals —audience="session"

By default a signal is global: one value, fanned to every connection. For a value that differs per visitor — a logged-in user's balance, their unread count — declare it withaudience="session"so each connection sees only its own value:

@app.signal("balance", audience="session")
async def balance():
    if False:
        yield 0   # push-only


@app.route("/deposit", methods=["POST"])
async def deposit(request: Request):
    amount = int((await request.form())["amount"])
    new_balance = credit(request, amount)
    app.emit("balance", new_balance, audience_key=session_key(request))
    return Fragment("deposit.html", "deposit_form", ok=True)

emit takes an audience_key(the visitor's session store key) for session signals;signal_connect() renders sse-connect="/_chirp/live?aud=<key>"so the bus fans that emit only to the matching connection.

Gotcha — derivedcomputemust be pure

Aderived's computemust be a pure function of its input signal values. The emitted value already carries everything the derived needs; re-reading process-local state silently races the bus.

Worked example

A live $MEOW balance mirrored in the topbar and a deposit modal, plus a derived net-worth line.

One source, many derived projections

To make several regions update live from one data source over the single connection, use one source signal as a clock-plus-snapshot and a derived projection per region. Compute the expensive part (a ranking, a query) once in the source; each derived is a cheap pure projection that re-renders its own region in lockstep — the compute-once / broadcast-many shape.

import asyncio

# ONE source samples the data on a human cadence and emits a self-contained
# snapshot (the data + per-value direction flags for the flash). It has no DOM
# sink of its own, so its render returns None to skip its own wire event.
@app.signal("board", initial=lambda: snapshot(None), render=lambda _v: None)
async def board():
    prev = None
    while True:
        await asyncio.sleep(1.5)          # the refresh cadence (throttle, don't firehose)
        prev = snap = snapshot(prev)      # read-only; carries dirs vs the previous snap
        yield snap


# Each region is a PURE projection of the one snapshot — recomputed + re-rendered
# in the same cascade, and skipped automatically when its projection is unchanged.
@app.derived("stats", on=("board",), render=render_stats)
def stats(b):
    return (b.stats, b.stat_dirs)


@app.derived("movers", on=("board",), render=render_movers)
def movers(b):
    return (b.movers, b.mover_dirs)

Bind each region with signal_attrson the existing container, and render the same {% def %}body for the SSR paint and the derived re-render so they never drift:

<section class="stats" {{ signal_attrs('stats') }}>{{ stats_body(stats) }}</section>
<div class="movers" {{ signal_attrs('movers') }}>{{ movers_body(movers) }}</div>

The snapshot is built once per tick (not once per region); the derived stay pure (deterministic across workers); emit dedup drops any region whose projection didn't change; and it all rides the one/_chirp/liveconnection. The Lucky Cat example markets lobby is a full worked version — a stat strip, a re-ranking movers grid, and a featured spotlight, all live.

Production constraint — single-process only

The signal bus is in-process memory, and the/_chirp/liveSSE connection is pinned to the worker that accepted it. The single-nodesignal()primitive that ships today is single-process only:

This is the right shape for a single-user demo, an internal tool, or any app you deploy as one process. Multi-worker realtime needs a shared bus backplane (Redis / Postgres pub-sub) plus an external state store, so every worker sees the same emits and the same current values. That pluggable multi-worker bus is designed but not yet shipped.

If you need realtime across workers today, use product-owned transport: an EventStreamreading a durable cursor (a database sequence, a queue offset) per the Server-Sent Events replay pattern, where the backplane is your store rather than an in-process bus.

Contract validation

app.check()validates signal bindings against producers at startup:

Check Severity What it catches
signal_dead_binding ERROR A{{ signal('x') }} / signal_block('x') / signal_attrs('x')(or an sse-swap="x"under the signal stream) with no registered producer — the element would never update.
signal_orphan INFO A registered signal that no template binds — produced but never displayed.
signal_scope ERROR / WARNING ERROR: anaudience="session" signal with no SessionMiddleware. WARNING: a derived signal that depends on both global and session-scoped signals.

Because signal names are dynamic (signal(name)), these rules validate against the authoritative producer registry rather than AST inference. See Contract Categories.