Declarative state, reactivity, and optimistic UI for Phoenix LiveView. Lavash
is a Spark DSL on top of Phoenix.LiveView that turns the usual grab-bag of
assigns, handle_event clauses, handle_info callbacks, and hand-written
JS hooks into a small set of declarative entities — state fields,
reactive expressions, actions, components, forms — that the compiler
cross-validates and (where applicable) transpiles to client-side JS for
optimistic updates. The reactive graph is always server-authoritative;
the optimistic layer is a UX shim that runs in front of it.
Lavash is best understood as four stacked concerns. Each layer has a self-contained value proposition and can be consumed without the layers above it.
- Base — the Spark DSL surface (
mount,actions,messages,async,when_connected,template, components, slots,on_mount) wired straight onto Phoenix LiveView. Compile-time validation ofphx-clickevent names against declared actions, ofphx-value-*params against declared action args, of@fieldreferences against declared state. No state machinery beyond what Phoenix gives you. - State — declarative persistence and sync:
state :foo, from: :url | :socket | :session | :assigns | :ephemeral.Lavash.Socket.put_state/3is the single write path; hydration is handled at mount. A flatlavashStateobject lives on the JS client so socket-backed state survives reconnects. - Rx — server-side reactive graph.
calculate :foo, rx(...)andrx(...)inside action bodies are the two surface forms. The compiler builds a topologically ordered dependency graph and recomputes only affected derives on every state change. Async derives (async: true) are supported. All eval is server-side; results flow through normal LiveView diffs. - Optimism — client-side instant feedback.
optimistic: trueon state and calculations causes the rx transpiler to emit JS, the template transformer to auto-injectdata-lavash-*annotations (display, toggle, member, visible, enabled), and theLavashOptimisticJS hook to mount a per-hook reactive store (SyncedVar + merge walker) that reconciles client predictions with the server's authoritative reply.animated: ...adds theidle/entering/visible/exitingphase machine used by modals and flyovers.
The full stack is use Lavash.LiveView / use Lavash.Component. If you
want the DSL and the reactive graph without the optimistic UI layer,
use Lavash.LiveView.Base / use Lavash.Component.Base opt out
explicitly — optimistic: true and animated: become compile-time
errors, so "no client-side optimism" is a load-bearing contract the
schema enforces. The point isn't bundle size (layer 4 is already
pay-for-what-you-use under the full DSL — if no field is marked
optimistic: true, the optimistic transformers no-op); it's having
explicit contracts you can write code against.
For the deep treatment of layer boundaries, back-edges, and the file-by-file inventory, see docs/ARCHITECTURE.md.
- Declarative actions (layer 1).
phx-click="increment"resolves against anaction :increment do ... endblock; the compiler cross- validates event names, params, and assign references. Op-sequence vocabulary (set,run,effect,submit,navigate,fire,flash,push_event,push_patch,redirect) replaces the usual scatteredhandle_eventclauses. - URL-backed state (layer 2).
state :search, :string, from: :urlmakes the field part of the URL — deep-linkable, refresh-safe, bookmarkable.:socket,:session,:assigns, and:ephemeralsources cover the rest. - Reactive state, not manual recompute (layer 3). Declare
calculate :doubled, rx(@count * 2); lavash recomputes it whenever its dependencies change, in topological order. - First-class Ash integration (layer 3). Read resources, submit forms, and auto-invalidate via PubSub on resource mutations.
- Optimistic UI by default (layer 4). The template auto-injects
data-lavash-*attributes so simple actions feel instant — the JS hook updates the DOM before the server reply lands. - Stateful components (all layers). Lavash components have their
own state, derived fields, and actions — and can bind to parent
state via the
bind=parent/child propagation chain.
def deps do
[
{:lavash, "~> 0.4.0-rc"}
]
endConfigure PubSub for cross-process invalidation:
# config/config.exs
config :lavash, pubsub: MyApp.PubSubTo enable lavash on the client side in app.js:
import { lavash, defaultConcerns, getHooks, getState } from "lavash";
const lavashDecorator = lavash({ concerns: defaultConcerns });
const liveSocket = new LiveSocket("/live", Socket, {
params: () => ({ _csrf_token: csrfToken, _lavash_state: getState() }),
hooks: getHooks(lavashDecorator, MyAppHooks)
});lavash({ concerns }) returns a decorator that wraps every hook
passed to getHooks. It auto-activates on elements that the server
runtime marks with data-lavash-state (lavash LiveViews, components,
overlays); on regular Phoenix hooks it passes through with zero cost.
defaultConcerns is the standard bundle (optimisticActions,
bindings, forms, overlays). To omit one — e.g. you never use
modals — replace with an explicit array:
import { lavash, optimisticActions, bindings, forms, getHooks } from "lavash";
const lavashDecorator = lavash({
concerns: [optimisticActions, bindings, forms] // no overlays
});The same counter, evolved one layer at a time. Each step adds one capability and shows what tradeoff it brings.
defmodule MyAppWeb.CounterLive do
use Lavash.LiveView
actions do
action :increment do
run fn socket ->
Phoenix.Component.assign(socket, :count, socket.assigns.count + 1)
end
end
action :reset do
run fn socket -> Phoenix.Component.assign(socket, :count, 0) end
end
end
template do
~H"""
<div>
<p>Count: {@count}</p>
<button phx-click="increment">+</button>
<button phx-click="reset">Reset</button>
</div>
"""
end
endThis is just vanilla LiveView with the DSL on top: phx-click resolves
to a declared action, the compiler validates the event names against
the actions block, and the template references @count like any HEEx.
What you get for free over hand-written handle_event clauses is the
uniform op vocabulary and the cross-validation.
state :count, :integer, from: :url, default: 0
actions do
action :increment do
set :count, rx(@count + 1)
end
action :reset do
set :count, 0
end
endfrom: :url makes @count part of the query string —
/counter?count=5 is deep-linkable, refresh-safe, and bookmarkable.
set :count, rx(...) replaces the run fn body — lavash knows how
to write the field and trip the URL update. Other sources are
:socket (reconnect-survival, JS-side cache), :session (Phoenix
session-backed), :assigns (lift an existing on_mount-injected
assign, e.g. @current_user, into lavash state), and :ephemeral
(process-only, the default).
state :count, :integer, from: :url, default: 0
state :multiplier, :integer, from: :ephemeral, default: 2
calculate :doubled, rx(@count * @multiplier)calculate :doubled, rx(...) adds a derived field. The reactive
graph tracks @count and @multiplier as dependencies; lavash
recomputes :doubled whenever either changes, in topological order.
Async derives are a flag away (async: true). All eval happens
server-side; the result flows through the normal LiveView diff.
state :count, :integer, from: :url, default: 0, optimistic: true
state :multiplier, :integer, from: :ephemeral, default: 2, optimistic: true
calculate :doubled, rx(@count * @multiplier)optimistic: true causes the rx transpiler to emit JS for the
set :count, rx(@count + 1) operation, the template transformer to
auto-inject <span data-lavash-display="count">{@count}</span> and
<span data-lavash-display="doubled">{@doubled}</span>, and the
LavashOptimistic JS hook to apply the prediction client-side
before the server reply arrives. The merge walker reconciles when
the authoritative reply lands. Server is still the source of truth;
optimism is a UX shim in front of it.
Lavash generates a mount/3 that initialises the reactive graph (state
hydration, dependency graph, PubSub subscriptions). For most mount-time
setup — firing async tasks, subscribing to PubSub, scheduling timers —
the declarative mount do ... end block (see
Lifecycle blocks) is the better fit; it runs after
the runtime mount and doesn't require chaining.
When you need something the block doesn't cover — temporary_assigns:
on the return tuple, code that has to run before the runtime mount,
or assigns the runtime doesn't manage — the generated mount/3 is
defoverridable. Chain into Lavash.LiveView.Runtime.mount/4 first so
the reactive graph gets attached to the socket:
def mount(params, session, socket) do
{:ok, socket} = Lavash.LiveView.Runtime.mount(__MODULE__, params, session, socket)
# ...your per-route setup
{:ok, Phoenix.Component.assign(socket, :greeting, lookup_greeting(params))}
endIf you skip the Runtime.mount/4 call, the first handle_params/3 will
crash with Reactive graph not found on socket — the reactive layer relies
on graph state being initialised at mount time.
The DSL surface that maps to vanilla Phoenix.LiveView constructs. Compile-
time validation, no state machinery beyond what Phoenix gives you. This
layer covers actions, the template block, components, and the lifecycle
blocks (mount, messages, async, when_connected, on_mount).
Declarative event handlers triggered by phx-click, phx-change, etc.
actions do
action :save do
submit :edit_form, on_success: :after_save, on_error: :on_error
end
action :after_save do
flash :info, "Saved!"
navigate "/products"
end
action :on_error do
flash :error, "Failed to save"
end
# With parameters from phx-value-*
action :delete, [:id] do
effect fn %{params: %{id: id}} ->
Product |> Ash.get!(id) |> Ash.destroy!()
end
end
# Guarded — only fires when @form_valid is true
action :submit, [], [:form_valid] do
submit :form
end
end| Operation | Description |
|---|---|
set :field, rx(...) |
Set field via a reactive expression (transpilable) |
set :field, value |
Set field to a literal value |
update :field, fun |
Transform field with a function (server-only) |
effect fn |
Execute side effects |
run fn |
Run a function over socket (full LV API available) |
submit :form |
Submit a form |
navigate path |
Navigate to URL |
push_patch to: path |
Patch the URL without remount |
redirect to: path |
Hard redirect |
push_event "name", payload |
Dispatch a JS event to the page |
flash :level, msg |
Show flash message |
fire :name |
Trigger an async :name do ... end declaration |
invoke id, :action |
Invoke an action on a child component |
set :field, rx(...) transpiles to JS for optimistic updates (layer 4).
update, effect, submit, run, push_patch, redirect,
push_event, flash, fire, invoke always go through the server.
Lavash modules declare their template with a template do ~H"..."end
block. The transformer rewrites the template at compile time, injecting:
| You write | Becomes |
|---|---|
{@count} (optimistic) |
<span data-lavash-display="count">{@count}</span> |
<input field={@form[:name]}> |
Phoenix form attrs + data-lavash-bind + error attrs |
<div :if={@open}> (optimistic) |
adds data-lavash-visible="open" |
<button disabled={not @valid}> (optimistic) |
adds data-lavash-enabled="valid" |
<div class={if @flag, do: "on", else: "off"}> (optimistic) |
adds data-lavash-toggle="flag|on|off" |
<div class={if "x" in @items, do: "sel", else: "unsel"}> (optimistic) |
adds data-lavash-member="items|sel|unsel" + data-lavash-member-value="x" |
<.lavash_component module=Child id="x" bind={[n: :count]}> |
adds parent value forwarding + binding-chain plumbing |
You write normal Phoenix HEEx; lavash adds the wiring underneath. Hand-written
data-lavash-* attributes still work for cases the inference can't reach
(non-bare expressions, unless, complex class concatenation, etc.). Most
of the auto-injected attributes are layer-4 concerns — they only fire on
fields marked optimistic: true — but the template transformer itself
is a layer-1 piece of compile-time plumbing.
render fn assigns -> ~L"..." end is still supported and produces the
same compiled output as template do ~H"..."end. The ~L shape predates
the template block and is the only path that supports render_loading fn
for animated overlays. New code should prefer template do ~H.
The transformer warns at compile time when:
- A bare
{@field}references a declared-but-non-optimistic state field — the template renders as plain text. Likely missingoptimistic: true. <.lavash_component bind=[child: :parent]>targets a:parentthat isn't a declared state field on the host — the binding is write-only and the child won't receive parent updates.
defmodule MyAppWeb.ProductCard do
use Lavash.Component
prop :product, :map, required: true
state :expanded, :boolean, from: :socket, default: false, optimistic: true
calculate :title, rx(@product.name)
actions do
action :toggle do
set :expanded, rx(not @expanded)
end
end
template do
~H"""
<div phx-click="toggle">
<h3>{@title}</h3>
<div :if={@expanded}>Details...</div>
</div>
"""
end
endphx-target={@myself} is auto-injected inside component templates — you
don't have to type it on every phx-* attribute.
import Lavash.LiveView.Helpers, only: [lavash_component: 1]
<.lavash_component
module={MyAppWeb.ProductCard}
id={"product-#{product.id}"}
product={product}
/>A child can declare a bind= mapping to read and write a parent's state
field:
<.lavash_component
module={MyAppWeb.Toggle}
id="dark-mode"
bind={[value: :dark_mode]}
/>The child's :value field hydrates from the parent's :dark_mode on every
update; the child's writes to :value propagate back up to the parent's
:dark_mode. Works across arbitrarily nested chains via parent CID routing
or send_update.
actions do
action :open_modal, [:id] do
invoke "product-modal", :open,
module: MyAppWeb.ProductModal,
params: [product_id: {:param, :id}]
end
endBeyond actions (which respond to events), lavash also has declarative blocks for the LiveView callback surface.
handle_info as op-sequence — the same vocabulary as actions
(run/effect/set/fire). For PubSub broadcasts, self-scheduled
timers, monitor messages:
messages do
message :tick do
set :ticks, rx(@ticks + 1)
end
message {:user_event, payload}, [:payload] do
run fn socket ->
assign(socket, :last_event, payload)
end
end
endDeclares a triggerable async task — like vanilla LV's assign_async
but invoked explicitly via fire :name:
async :report do
run fn assigns ->
{:ok, generate_report(assigns.filters)}
end
end
actions do
action :refresh do
fire :report
end
endThe field lands as %Phoenix.LiveView.AsyncResult{} on assigns,
playable in case @report do %AsyncResult{...} patterns.
Op-sequence for mount-time setup. Symmetric with messages do:
mount do
fire :report
when_connected do
run fn socket ->
Phoenix.PubSub.subscribe(MyApp.PubSub, "updates")
Process.send_after(self(), :tick, 1000)
socket
end
end
endwhen_connected do ... end is a guard for ops that should only run on
the websocket mount (not the initial HTTP render) — replaces the
ubiquitous if connected?(socket) do ... end pattern.
Declarative persistence and sync. state :foo, :type, from: ... declares
where a piece of state comes from and where its mutations propagate to.
Server is always authoritative; the client carries a flat lavashState
object across reconnects.
Lavash supports several persistence sources:
from: |
Persisted in | Survives refresh | Survives reconnect | Shareable |
|---|---|---|---|---|
:url |
Query string | Yes | Yes | Yes |
:socket |
JS client | No | Yes | No |
:session |
Phoenix session | Yes | Yes | No |
:assigns |
Existing assign | depends on source | depends on source | depends on source |
:ephemeral (default) |
Process only | No | No | No |
# URL-backed: filters, pagination, tabs
state :search, :string, from: :url, default: ""
state :page, :integer, from: :url, default: 1
# Socket-backed: UI state that survives reconnects
state :expanded_ids, {:array, :uuid}, from: :socket, default: []
# Lift an on_mount-injected assign (e.g. @current_user from your auth
# plug) into lavash state so calculate/actions can see it.
state :user, :map, from: :assigns, assigns_key: :current_user
# Ephemeral: temporary
state :hovering, :boolean, default: falsefrom: :url looks up the query/path parameter under the field name by
default. If the URL key needs to differ — typically because the query
string uses a shorter name — set url_name::
# URL: /attest?subject=alice
state :subject_handle, :string, from: :url, default: nil, url_name: "subject"When a from: :url field falls back to its default and there's no
matching key in the params (and the URL did have other params), Lavash
logs a dev-only warning so a mismatched url_name doesn't silently
hydrate to nil. Use required: true if missing the param should
raise instead.
setter: true generates a set_<name> action callable from the client (e.g.
from a form input's phx-change):
state :search, :string, from: :url, default: "", setter: true
# Generates: action :set_search, [:value] do set :search, rx(@value) endBuilt-in types with automatic URL serialization:
:string— pass-through:integer—"42"↔42:float—"3.14"↔3.14:boolean—"true"↔true:uuid— full UUID ↔ base32 (26 chars){:uuid, "prefix"}— TypeID format (cat_01h455vb4pex5vsknk084sn02q):atom— usesString.to_existing_atom/1{:array, type}—"a,b,c"↔["a", "b", "c"]
defmodule MyApp.Types.Date do
use Lavash.Type
@impl true
def parse(value) when is_binary(value) do
case Date.from_iso8601(value) do
{:ok, date} -> {:ok, date}
{:error, _} -> {:error, "invalid date"}
end
end
@impl true
def dump(%Date{} = date), do: Date.to_iso8601(date)
end
state :start_date, MyApp.Types.Date, from: :urlThe server-side reactive graph engine. calculate :foo, rx(...) and
rx(...) inside action bodies (e.g. set :count, rx(@count + 1)) are
the two surface forms. The compiler builds a topologically ordered
dependency graph and recomputes only affected derives on every state
change.
rx(...) captures an expression at compile time. References to @field are
tracked as dependencies. The same expression compiles to both Elixir (for
server-side evaluation) and JavaScript (for the optimistic hook — layer 4).
calculate :doubled, rx(@count * 2)
calculate :total, rx(Enum.sum(@items))
calculate :greeting, rx("Hi, " <> @name)async: true runs the computation in a background task. The field is set to
AsyncResult.loading() immediately and updated when the task completes.
Downstream calculations propagate loading/failed states automatically.
calculate :report, rx(generate_report(@filters)), async: true
calculate :report_size, rx(byte_size(@report)) # waits for :reportIn templates, async fields are %Phoenix.LiveView.AsyncResult{}:
<%= case @report do %>
<% %AsyncResult{loading: true} -> %>Loading...
<% %AsyncResult{ok?: true, result: data} -> %>{inspect(data)}
<% _ -> %>Error
<% end %>defrx declares a transpilable helper; import_rx makes it available in
rx() blocks elsewhere:
defmodule MyApp.Validators do
use Lavash.Rx.Functions
defrx valid_email?(email) do
String.length(email) > 0 && String.contains?(email, "@")
end
end
defmodule MyAppWeb.SignupLive do
use Lavash.LiveView
import_rx MyApp.Validators
calculate :email_valid, rx(valid_email?(@email))
endread :product, Product do
id state(:product_id)
async true # default
endread :products, Product, :list do
invalidate :pubsub # fine-grained PubSub invalidation
end
# Auto-maps state fields to action arguments by nameread :categories, Category do
async false
as_options label: :name, value: :id
endForms ride on the reactive graph: auto-generated <form>_<field>_valid
and <form>_valid calculations are derived from Ash constraints, so
the submit button can live-update from rx(...) instead of from
manual on-change handlers.
Auto-detects create vs. update based on data:
form :edit_form, Product do
data result(:product) # nil → create, record → update
end
# Params are auto-created as :edit_form_params (ephemeral state).
# Validation derives are auto-generated: :edit_form_<field>_valid,
# :edit_form_<field>_errors, :edit_form_valid, :edit_form_errors.Hook a form into your template:
<form phx-change="form_change_edit_form" phx-submit="save">
<input field={@edit_form[:name]} />
<input field={@edit_form[:price]} />
<button type="submit" disabled={not @edit_form_valid}>Save</button>
</form><input field={...}> auto-injects name, value, and the right
data-lavash-* attrs so validation errors render instantly client-side.
data-lavash-bind (the attribute the auto-injector adds to <input field={...}>) syncs through Lavash's own channel events, not through
phx-change. The flow is async: typing into a bound input fires a
client-only optimistic update plus a debounced server push.
For inputs hooked up via <input field={@form[...]}> this is fine —
phx-submit re-reads @form from the AshPhoenix.Form params, which
are kept in dedicated ephemeral state (<form_name>_params).
For bound state on a hand-rolled form (data-lavash-bind="confirmed"
on a checkbox, etc.) submit can race the bind sync. If the user ticks
the box and immediately clicks submit, the phx-submit request can
arrive at the server before the bind has propagated, and the action
body sees the not-yet-synced value of @confirmed.
Two safe patterns until this gap closes:
- Prefer the
<.form for={@some_form}>/field={...}flow for any submit-style interaction. - For hand-rolled forms, read the form params directly inside the
action body (via the action's
params [...]list) instead of through@field—phx-submitalways carries the live form values.action :submit, [:confirmed, :notes] do run fn %{confirmed: confirmed, notes: notes} = assigns -> # use confirmed/notes from the submitted form, not @confirmed ... end end
A future release will sync bound state through the submit payload so
the @field read works on submit too.
The pieces above — state, calculate, actions, custom mount/3,
Lavash.Socket.put_state/3, and action ..., [:fields] — chain together
on a real page. This recipe shows all of them in one module: an
attestation form behind sign-in, with a URL-backed subject, ephemeral
form state, a submit button that lights up when the form is ready, and a
side-effecting submit handler.
defmodule MyAppWeb.AttestLive do
use Lavash.LiveView
on_mount {AshAuthentication.LiveView, :live_user_required}
# URL-backed: /attest?subject=alice is deep-linkable and refresh-safe.
# `url_name:` lets the public param stay short while the field name
# stays descriptive.
state :subject_handle, :string,
from: :url,
default: nil,
url_name: "subject",
required: true,
optimistic: true
# Ephemeral form state, bound to the inputs in the template so the
# checkbox + textarea can drive optimistic UI without a round-trip
# for every keystroke.
state :confirmed, :boolean, default: false, optimistic: true
state :notes, :string, default: "", optimistic: true
# Set on success so the template can swap the form for a thank-you.
state :submitted_at, :utc_datetime, default: nil, optimistic: true
# Seeded from the signed-in user inside the custom mount below.
state :actor_email, :string, default: nil
calculate :ready_to_submit,
rx(@confirmed and String.length(@notes) > 0 and is_nil(@submitted_at))
actions do
# `params [...]` makes the action read the submit payload directly
# rather than `@confirmed` / `@notes`, so it sees the fresh values
# even if the bind sync hasn't caught up yet.
action :submit, [:confirmed, :notes] do
run fn %{confirmed: confirmed, notes: notes} = assigns ->
case record_attestation(assigns.actor_email, assigns.subject_handle, confirmed, notes) do
{:ok, at} -> assign(assigns, :submitted_at, at)
{:error, _} -> assigns
end
end
end
end
def mount(params, session, socket) do
# Attach the reactive graph first — handle_params/3 needs it.
{:ok, socket} = Lavash.LiveView.Runtime.mount(__MODULE__, params, session, socket)
# Hydrate Lavash-aware state from the assigns the auth on_mount put
# on the socket. `put_state/3` (not `assign/3`) registers the field
# with the reactive graph and tracks dirty/url changes.
socket = Lavash.Socket.put_state(socket, :actor_email, socket.assigns.current_user.email)
{:ok, socket}
end
template do
~H"""
<div :if={is_nil(@submitted_at)}>
<h1>Attest for {@subject_handle}</h1>
<form phx-submit="submit">
<label>
<input type="checkbox" name="confirmed" data-lavash-bind="confirmed" />
I confirm the statements above.
</label>
<textarea name="notes" data-lavash-bind="notes"></textarea>
<button type="submit" disabled={not @ready_to_submit}>Submit</button>
</form>
</div>
<p :if={not is_nil(@submitted_at)}>
Recorded at <span data-lavash-display="submitted_at">{@submitted_at}</span>.
</p>
"""
end
defp record_attestation(actor_email, subject, confirmed, notes) do
# ...persist via Ash, audit log, etc.
{:ok, DateTime.utc_now()}
end
endA few things to notice, because they're easy to miss:
- The custom
mount/3chains intoLavash.LiveView.Runtime.mount/4before doing anything else. The generated mount isdefoverridable(see Custommount/3); if you skip the chain, the firsthandle_params/3raisesReactive graph not found on socket. Lavash.Socket.put_state/3— notPhoenix.Component.assign/3— is what you reach for when seeding Lavash state from inside a custom mount. It registers the field with the reactive graph and tracks url/socket changes, so downstreamcalculates and PubSub invalidations see the value. (Actionrun fnbodies can also read raw socket assigns like@current_userdirectly, but lifting the value into Lavash state keeps the auth library's shape out of business logic and makes the field observable to the reactive graph.)action :submit, [:confirmed, :notes]reads the live form payload rather than@confirmed/@notes. See Forms vs.data-lavash-bindon submit for why —phx-submitcan otherwise race the bind sync.- The
run fnbody calls the unqualified private helperrecord_attestation/4directly. Action bodies are hoisted into a generated function on the user's module, so localdefps, aliases, and imports resolve normally. disabled={not @ready_to_submit}is auto-rewritten todata-lavash-enabled="ready_to_submit"because the expression is the negation of a calculated optimistic field — the button enables client-side the moment@confirmedand@notesare populated.
Client-side instant feedback. optimistic: true on a state field or
calculation causes the rx transpiler to emit JS, the template
transformer to auto-inject data-lavash-* annotations, and the
LavashOptimistic JS hook to apply predictions client-side before the
server reply arrives. Under the hood: a per-hook SyncedVar store with
version tracking and a merge walker that reconciles server pushes
against in-flight optimistic state. The server is still the source of
truth — strip this layer out (use Lavash.LiveView.Base) and the app
still works, just with a round-trip-latency feel.
Add optimistic: true to make a field part of the client-side state map. The
lavash JS pipeline reads it from data-lavash-state and updates the DOM as
transpiled actions fire — before the server reply arrives.
state :count, :integer, default: 0, optimistic: trueWithout optimistic: true, the field still works server-side but every
update takes a full LiveView round-trip.
The layer-4 reach into HEEx is the family of data-lavash-* attributes
the template transformer adds when an expression resolves against an
optimistic field. You don't write them by hand for the common cases:
data-lavash-display="field"— wraps bare{@field}in a span the hook can re-text directly.data-lavash-toggle="field|on|off"— toggles class strings based on a boolean optimistic field.data-lavash-member="field|sel|unsel"+data-lavash-member-value— array membership class toggling (the ChipSet pattern).data-lavash-visible="field"— show/hide via ahiddenclass.data-lavash-enabled="field"— enable/disable a button without a server roundtrip.
Hand-written data-lavash-* attributes still work for cases the
inference can't reach (non-bare expressions, unless, complex class
concatenation, async patterns).
animated: state fields drive a phase machine
(idle → entering → [loading] → visible → exiting → idle). The optimistic
JS hook drives the transitions client-side. Modal and flyover DSLs are
built on top:
defmodule MyAppWeb.ProductModal do
use Lavash.Component, extensions: [Lavash.Overlay.Modal.Dsl]
import Lavash.Overlay.Modal.Helpers
modal do
open_field :product_id # nil = closed
close_on_escape true
close_on_backdrop true
async_assign :edit_form
end
read :product, Product do
id state(:product_id)
end
form :edit_form, Product do
data result(:product)
end
actions do
action :save do
submit :edit_form, on_success: :close
end
end
render fn assigns ->
~L"""
<div class="p-6">
<.modal_close_button myself={@myself} />
<!-- form content -->
</div>
"""
end
endThe overlay runs through phases (idle → entering → [loading] → visible → exiting → idle); the optimistic JS hook drives the transitions
client-side.
# In a read declaration
read :products, Product, :list do
invalidate :pubsub
end
# In the Ash resource: which attributes trigger invalidation
defmodule MyApp.Product do
use Ash.Resource, extensions: [Lavash.Resource]
lavash do
notify_on [:category_id, :in_stock]
end
endWhen a form submits, Lavash broadcasts to PubSub topics matching the mutated resource. LiveViews with subscribed reads auto-refresh.
This is the layer-3-only escape hatch: the reactive engine, without the DSL surface, the template transformer, the optimistic JS, or any of the overlay / form / binding machinery.
Lavash.LiveView.Explicit exposes the reactive engine alone. You get
the dependency graph and automatic recomputation; you write mount/3,
handle_event/3, and render/1 like any plain Phoenix LiveView.
defmodule MyAppWeb.CounterLive do
use Lavash.LiveView.Explicit
reactive do
state :count, 0
state :step, 1
derive :doubled, rx(@count * @step)
end
@impl Phoenix.LiveView
def handle_event("inc", _, socket) do
{:noreply, put_state(socket, :count, &(&1 + 1))}
end
@impl Phoenix.LiveView
def render(assigns) do
~H"""
<p>{@count} (doubled = {@doubled})</p>
<button phx-click="inc">+</button>
"""
end
endput_state/3 mutates a field and immediately recomputes the dependent
graph — no "I forgot to call recompute" footgun. mount/3 and
handle_info/2 for async derives are wired automatically.
This path is useful when you want the reactive primitives but don't need
the DSL's optimistic JS, URL-backed state, forms, or overlays. If you
want the DSL but still no client-side optimism, use
Lavash.LiveView.Base instead (layers 1 + 2 + 3).
MIT