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.

client only

release · open

Draft: client-note

The ephemeral signal is absent. Patch or reload to restore client state.

Signal snapshot

mounted

tick 0
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.

lib/dstar_demo_web/pages/signals_page.ex
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

lib/dstar_demo_web/pages/signals_page.ex
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
# 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})