Skip to content

u2i/lavash

Repository files navigation

Lavash

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.

Architecture: the four layers

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.

  1. Base — the Spark DSL surface (mount, actions, messages, async, when_connected, template, components, slots, on_mount) wired straight onto Phoenix LiveView. Compile-time validation of phx-click event names against declared actions, of phx-value-* params against declared action args, of @field references against declared state. No state machinery beyond what Phoenix gives you.
  2. State — declarative persistence and sync: state :foo, from: :url | :socket | :session | :assigns | :ephemeral. Lavash.Socket.put_state/3 is the single write path; hydration is handled at mount. A flat lavashState object lives on the JS client so socket-backed state survives reconnects.
  3. Rx — server-side reactive graph. calculate :foo, rx(...) and rx(...) 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.
  4. Optimism — client-side instant feedback. optimistic: true on state and calculations causes the rx transpiler to emit JS, the template transformer to auto-inject data-lavash-* annotations (display, toggle, member, visible, enabled), and the LavashOptimistic JS hook to mount a per-hook reactive store (SyncedVar + merge walker) that reconciles client predictions with the server's authoritative reply. animated: ... adds the idle/entering/visible/exiting phase 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.

Why Lavash?

  • Declarative actions (layer 1). phx-click="increment" resolves against an action :increment do ... end block; 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 scattered handle_event clauses.
  • URL-backed state (layer 2). state :search, :string, from: :url makes the field part of the URL — deep-linkable, refresh-safe, bookmarkable. :socket, :session, :assigns, and :ephemeral sources 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.

Installation

def deps do
  [
    {:lavash, "~> 0.4.0-rc"}
  ]
end

Configure PubSub for cross-process invalidation:

# config/config.exs
config :lavash, pubsub: MyApp.PubSub

To 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
});

Quick start

The same counter, evolved one layer at a time. Each step adds one capability and shows what tradeoff it brings.

Just the DSL (layer 1)

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
end

This 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.

Add declarative state (layer 2)

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
end

from: :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).

Add reactivity (layer 3)

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.

Add optimistic UI (layer 4)

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.

Custom mount/3

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))}
end

If 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.


Layer 1: Base DSL

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).

Actions

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

Action operations

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.

Templates and auto-injection

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.

~L (legacy shape)

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.

Diagnostics

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 missing optimistic: true.
  • <.lavash_component bind=[child: :parent]> targets a :parent that isn't a declared state field on the host — the binding is write-only and the child won't receive parent updates.

Components

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
end

phx-target={@myself} is auto-injected inside component templates — you don't have to type it on every phx-* attribute.

Using a component

import Lavash.LiveView.Helpers, only: [lavash_component: 1]

<.lavash_component
  module={MyAppWeb.ProductCard}
  id={"product-#{product.id}"}
  product={product}
/>

Bindings

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.

Invoking component actions from parent

actions do
  action :open_modal, [:id] do
    invoke "product-modal", :open,
      module: MyAppWeb.ProductModal,
      params: [product_id: {:param, :id}]
  end
end

Lifecycle blocks

Beyond actions (which respond to events), lavash also has declarative blocks for the LiveView callback surface.

messages do message :name do ... end end

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
end

async :name do run fn end end

Declares 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
end

The field lands as %Phoenix.LiveView.AsyncResult{} on assigns, playable in case @report do %AsyncResult{...} patterns.

mount do <ops> end

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
end

when_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.


Layer 2: State management

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.

State

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: false

from: :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.

Auto-generated setters

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) end

Type system

Built-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 — uses String.to_existing_atom/1
  • {:array, type}"a,b,c"["a", "b", "c"]

Custom types

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: :url

Layer 3: Reactive expressions

The 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.

Reactive expressions: rx

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 calculations

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 :report

In templates, async fields are %Phoenix.LiveView.AsyncResult{}:

<%= case @report do %>
  <% %AsyncResult{loading: true} -> %>Loading...
  <% %AsyncResult{ok?: true, result: data} -> %>{inspect(data)}
  <% _ -> %>Error
<% end %>

Importing reactive helpers

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))
end

Reading Ash resources

Get by ID

read :product, Product do
  id state(:product_id)
  async true  # default
end

Query with auto-mapped arguments

read :products, Product, :list do
  invalidate :pubsub  # fine-grained PubSub invalidation
end
# Auto-maps state fields to action arguments by name

As dropdown options

read :categories, Category do
  async false
  as_options label: :name, value: :id
end

Forms

Forms 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.

Forms vs. data-lavash-bind on submit

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 @fieldphx-submit always 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.

Cookbook: a full form-submission recipe

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
end

A few things to notice, because they're easy to miss:

  • The custom mount/3 chains into Lavash.LiveView.Runtime.mount/4 before doing anything else. The generated mount is defoverridable (see Custom mount/3); if you skip the chain, the first handle_params/3 raises Reactive graph not found on socket.
  • Lavash.Socket.put_state/3 — not Phoenix.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 downstream calculates and PubSub invalidations see the value. (Action run fn bodies can also read raw socket assigns like @current_user directly, 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-bind on submit for why — phx-submit can otherwise race the bind sync.
  • The run fn body calls the unqualified private helper record_attestation/4 directly. Action bodies are hoisted into a generated function on the user's module, so local defps, aliases, and imports resolve normally.
  • disabled={not @ready_to_submit} is auto-rewritten to data-lavash-enabled="ready_to_submit" because the expression is the negation of a calculated optimistic field — the button enables client-side the moment @confirmed and @notes are populated.

Layer 4: Optimistic UI

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.

Optimistic state

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: true

Without optimistic: true, the field still works server-side but every update takes a full LiveView round-trip.

Auto-injected DOM annotations

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 a hidden class.
  • 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).

Overlays (modals, flyovers)

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
end

The overlay runs through phases (idle → entering → [loading] → visible → exiting → idle); the optimistic JS hook drives the transitions client-side.


Cross-cutting concerns

PubSub invalidation

# 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
end

When a form submits, Lavash broadcasts to PubSub topics matching the mutated resource. LiveViews with subscribed reads auto-refresh.

Using lavash without the DSL

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
end

put_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).

License

MIT

About

Declarative state management for Phoenix LiveView, built for Ash Framework

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors