Lavash

Declarative state management for Phoenix LiveView, built for Ash Framework.

Why Lavash?

Solve LiveView's state loss problem. Standard LiveView stores all state server-side, which means network disconnects, deploys, or server restarts lose user state. Lavash solves this by treating the client as the source of truth:

Components that own their state. Unlike standard LiveComponents which are stateless renderers, Lavash components can declare and manage their own state, derived fields, forms, and actions—just like LiveViews.

First-class Ash integration. Read Ash resources with auto-mapped action arguments, submit forms that auto-detect create vs update, and get cross-process PubSub invalidation when resources mutate.

Features

Installation

def deps do
  [
    {:lavash, "~> 0.1.0"}
  ]
end

Configure PubSub for cross-process invalidation:

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

Quick Start

defmodule MyAppWeb.CounterLive do
  use Lavash.LiveView

  # URL state - survives refresh, shareable
  state :count, :integer, from: :url, default: 0

  # Computed value - updates when count changes
  derive :doubled do
    argument :count, state(:count)
    run fn %{count: c}, _ -> c * 2 end
  end

  # Actions transform state
  actions do
    action :increment do
      update :count, &(&1 + 1)
    end

    action :reset do
      set :count, 0
    end
  end

  def render(assigns) do
    ~H"""
    <div>
      <p>Count: {@count}</p>
      <p>Doubled: {@doubled}</p>
      <button phx-click="increment">+</button>
      <button phx-click="reset">Reset</button>
    </div>
    """
  end
end

State Types

Lavash provides three state persistence modes:

Type Persisted In Survives Refresh Survives Reconnect Shareable
:url Query string Yes Yes Yes
:socket JS client No Yes No
:ephemeral Process only No No No
# URL state - filters, pagination, tabs
state :search, :string, from: :url, default: ""
state :page, :integer, from: :url, default: 1

# Socket state - UI state that survives reconnects
state :expanded_ids, {:array, :uuid}, from: :socket, default: []

# Ephemeral state - temporary, fastest
state :hovering, :boolean, from: :ephemeral, default: false

Auto-Generated Setters

Use setter: true to auto-generate a set_<name> action:

state :search, :string, from: :url, default: "", setter: true
# Generates: action :set_search, [:value] do set :search, &(&1.params.value) end

Type System

Built-in types with automatic URL serialization:

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

# Usage
state :start_date, MyApp.Types.Date, from: :url

Derived Fields

Computed values with automatic dependency tracking:

derive :total do
  argument :items, state(:items)
  argument :tax_rate, state(:tax_rate)

  run fn %{items: items, tax_rate: rate}, _ ->
    subtotal = Enum.sum(Enum.map(items, & &1.price))
    subtotal * (1 + rate)
  end
end

Async Derived Fields

For expensive computations:

derive :report do
  async true
  argument :filters, state(:filters)

  run fn %{filters: f}, _ ->
    # Expensive computation
    generate_report(f)
  end
end

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

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

Chained Derived Fields

Derived fields can depend on other derived fields:

derive :doubled do
  argument :count, state(:count)
  run fn %{count: c}, _ -> c * 2 end
end

derive :quadrupled do
  argument :doubled, result(:doubled)
  run fn %{doubled: d}, _ -> d * 2 end
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  # Enable 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

Auto-detects create vs update based on data:

form :edit_form, Product do
  data result(:product)  # nil = create, record = update
end

# Params auto-created as :edit_form_params ephemeral state

Actions

Declarative event handlers:

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 actions
  action :submit, [], [:form_valid] do
    submit :form
  end
end

Action Operations

Operation Description
set :field, value Set field to value or function
update :field, fn Transform field with function
effect fn Execute side effects
submit :form Submit a form
navigate path Navigate to URL
flash :level, msg Show flash message
invoke id, :action Invoke action on child component

Components

LiveComponents with props:

defmodule MyAppWeb.ProductCard do
  use Lavash.Component

  prop :product, :map, required: true
  prop :on_select, :atom  # Event name for notify_parent

  state :expanded, :boolean, from: :socket, default: false

  derive :title do
    argument :product, prop(:product)
    run fn %{product: p}, _ -> p.name end
  end

  actions do
    action :toggle do
      update :expanded, &(!&1)
    end

    action :select do
      notify_parent :on_select
    end
  end

  def render(assigns) do
    ~H"""
    <div phx-click="toggle" phx-target={@myself}>
      <h3>{@title}</h3>
      <div :if={@expanded}>Details...</div>
      <button phx-click="select" phx-target={@myself}>Select</button>
    </div>
    """
  end
end

Usage with lavash_component:

import Lavash.LiveView.Helpers

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

Invoking Component Actions from Parent

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

Overlays (Modal, etc.)

Pre-built modal behavior:

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

  render_loading fn assigns ->
    ~H"<div class=\"p-6\">Loading...</div>"
  end

  render fn assigns ->
    ~H"""
    <div class="p-6">
      <.modal_close_button myself={@myself} />
<!-- Form content -->

    </div>
    """
  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
end

Optimistic Updates

Make UI feel instant by applying state changes client-side before server confirmation. Lavash automatically generates JavaScript functions from your DSL declarations.

Basic Setup

  1. Mark state fields and derives with optimistic: true:
defmodule MyAppWeb.CounterLive do
  use Lavash.LiveView

  state :count, :integer, from: :url, default: 0, optimistic: true
  state :multiplier, :integer, from: :ephemeral, default: 2, optimistic: true

  derive :doubled do
    optimistic true
    argument :count, state(:count)
    argument :multiplier, state(:multiplier)
    run fn %{count: c, multiplier: m}, _ -> c * m end
  end

  actions do
    action :increment do
      update :count, &(&1 + 1)
    end

    action :decrement do
      update :count, &(&1 - 1)
    end
  end
end
  1. Add data-optimistic to trigger elements and use the <.o> helper for display elements:
import Lavash.LiveView.Helpers

def render(assigns) do
  ~H"""
  <div>
    <.o field={:count} value={@count} tag="div" />
    <.o field={:doubled} value={@doubled} />

    <button phx-click="increment" data-optimistic="increment">+</button>
    <button phx-click="decrement" data-optimistic="decrement">-</button>
  </div>
  """
end

The <.o> component eliminates duplication by generating both the display and the data-optimistic-display attribute. It supports:

Alternatively, you can use the raw data attribute:

<div data-optimistic-display="count">{@count}</div>
  1. Register the hook in your app.js:
import { LavashOptimistic } from "./lavash_optimistic";

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: { LavashOptimistic, ...otherHooks }
});

How It Works

When a user clicks a button with data-optimistic="increment":

  1. Client-side: The hook immediately runs the generated JavaScript function to update state
  2. Server-side: The normal phx-click event is sent to the server
  3. Reconciliation: When the server responds, stale values are ignored using per-field tracking

Actions are automatically converted to JavaScript if they only contain set and update operations:

Elixir DSL Generated JavaScript
update :count, &(&1 + 1)count: state.count + 1
update :count, &(&1 - 1)count: state.count - 1
set :count, 0count: 0
set :count, &String.to_integer(&1.params.value)count: Number(value)

Custom Derive Functions

For complex derived values, provide JavaScript implementations via ColocatedJS:

<script :type={Phoenix.LiveView.ColocatedJS} name="optimistic">
  function factorial(n) {
    if (n < 0) return null;
    if (n > 170) return Infinity;
    let result = 1;
    for (let i = 2; i <= n; i++) result *= i;
    return result;
  }

  export default {
    // Derive functions receive state and return the computed value
    doubled(state) {
      return state.count * state.multiplier;
    },

    fact(state) {
      return factorial(Math.max(state.count, 0));
    }
  };
</script>

Register custom functions in app.js:

import optimistic from "phoenix-colocated/demo/DemoWeb.CounterLive/optimistic";
window.Lavash = window.Lavash || {};
window.Lavash.optimistic = window.Lavash.optimistic || {};
window.Lavash.optimistic["DemoWeb.CounterLive"] = optimistic;

Input Fields

For range sliders and other inputs, use data-optimistic-field:

<input
  type="range"
  name="value"
  value={@multiplier}
  phx-change="set_multiplier"
  data-optimistic-field="multiplier"
/>

Actions with Parameters

For actions that take values (like "Set to 100"), use data-optimistic-value:

<button
  phx-click="set_count"
  phx-value-amount="100"
  data-optimistic="set_count"
  data-optimistic-value="100"
>
  Set to 100
</button>

Limitations

Optimistic updates work best for:

Actions with side effects (submit, navigate, effect, invoke) are not generated as optimistic functions.

PubSub Invalidation

Cross-process resource invalidation for multi-tab/user scenarios:

# In read declaration
read :products, Product, :list do
  invalidate :pubsub
end

# In Ash resource - specify 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 all relevant PubSub topics, and LiveViews with matching reads automatically reload.

Architecture

Lavash stores all state in socket.private.lavash to avoid LiveView change tracking overhead:

socket.private.lavash = %{
  state: %{},      # Current state values
  derived: %{},    # Computed values
  dirty: MapSet,   # Fields needing recomputation
  url_fields: MapSet,
  socket_fields: MapSet
}

The dependency graph ensures derived fields compute in topological order, and only dirty fields are recomputed on state changes.

License

MIT