Signal-native UI
Edit reactive client state with no request, then choose when the server should read it, reconcile it, patch owned values, or remove a signal.
Demo
Instant local projection
These bindings update in the browser. No HTTP request has happened yet.
release · open
Draft: client-note
The ephemeral signal is absent. Patch or reload to restore client state.
Signal snapshot
mounted
- scope
- release
- filter
- open
- ephemeral
- "client-note"
- server_tick
- 0
{}
How it works
- Bindings and expressions react locally before any request reaches Phoenix.
- handle_event/3 receives the request signals already read.
- patch_signals/3 updates server-owned signals.
- remove_signals/3 clears selected client state.
- The inspector itself is patched after each command.
Signals are client state. Datastar posts the current set with every event, so
handle_event/3
receives them already read; patch_signals/2
writes server-owned values back and remove_signals/2
deletes client ones. The inspector prints the raw map it was handed, minus the CSRF and
tab keys.
def handle_event(conn, "patch", signals) do
tick = next_tick(signals)
state = client_state(signals, tick: tick, audit: "patch_signals tick #{tick}")
conn
|> patch_signals(state)
|> patch(&inspector/1, state)
end
If this were LiveView
This is the one comparison where the two sides are not solving the same problem, because LiveView does not have the concept this page inspects.
Dstar
def handle_event(conn, "patch", signals) do
tick = next_tick(signals)
state = client_state(signals, tick: tick, audit: "patch_signals tick #{tick}")
conn
|> patch_signals(state)
|> patch(&inspector/1, state)
end
LiveView
# Illustrative LiveView equivalent — not compiled or run.
#
# There is no client-owned state to inspect: assigns live on the server,
# and the client only ever sends what a binding names explicitly. So this
# page has no real LiveView counterpart — only the nearest thing.
def mount(_params, _session, socket) do
{:ok,
assign(socket,
scope: "release",
filter: "open",
server_tick: 0,
audit_trail: "mount"
)}
end
def handle_event("patch", _params, socket) do
tick = socket.assigns.server_tick + 1
{:noreply, assign(socket, server_tick: tick, audit_trail: "assign tick")}
end
# To send client-held values at all, each one must be named:
#
# <button phx-click="patch" phx-value-scope={@scope} phx-value-filter={@filter}>
#
# or a JS hook pushes them:
#
# this.pushEvent("patch", {scope, filter})