Declarative state management for Phoenix LiveView, built for Ash Framework.
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:
- URL state survives refresh, is shareable, and enables deep linking
- Socket state syncs to JS and survives reconnects (but not refresh)
- Ephemeral state for temporary UI state that can be lost
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.
- Client-Driven State - URL and socket state survive disconnects; no more lost form data
- Stateful Components - Components with their own state, derives, forms, and actions
- Ash Integration - Read resources, submit forms, auto-invalidate on mutations
- Derived Fields - Computed values with dependency tracking and async support
- Declarative Actions - Event handlers with set, update, submit, navigate, flash
- PubSub Invalidation - Fine-grained cross-process cache invalidation
- Type System - Automatic URL serialization with custom type support
- Optimistic Updates - Instant client-side state changes with automatic server reconciliation
- Modal Plugin - Ready-to-use modal behavior for components
def deps do
[
{:lavash, "~> 0.1.0"}
]
endConfigure PubSub for cross-process invalidation:
# config/config.exs
config :lavash, pubsub: MyApp.PubSubdefmodule 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
endLavash 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: falseUse 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) 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
# Usage
state :start_date, MyApp.Types.Date, from: :urlComputed 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
endFor expensive computations:
derive :report do
async true
argument :filters, state(:filters)
run fn %{filters: f}, _ ->
# Expensive computation
generate_report(f)
end
endIn templates, async fields render as %Phoenix.LiveView.AsyncResult{}:
<%= case @report do %>
<% %AsyncResult{loading: true} -> %>Loading...
<% %AsyncResult{ok?: true, result: data} -> %>{inspect(data)}
<% _ -> %>Error
<% end %>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
endread :product, Product do
id state(:product_id)
async true # default
endread :products, Product, :list do
invalidate :pubsub # Enable 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
endAuto-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 stateDeclarative 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| 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 |
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
endUsage with lavash_component:
import Lavash.LiveView.Helpers
<.lavash_component
module={MyAppWeb.ProductCard}
id={"product-#{product.id}"}
product={product}
on_select="product_selected"
/># In parent LiveView
actions do
action :open_modal, [:id] do
invoke "product-modal", :open,
module: MyAppWeb.ProductModal,
params: [product_id: {:param, :id}]
end
endPre-built modal behavior:
defmodule MyAppWeb.ProductModal do
use Lavash.Component, extensions: [Lavash.Modal.Dsl]
import Lavash.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
endMake UI feel instant by applying state changes client-side before server confirmation. Lavash automatically generates JavaScript functions from your DSL declarations.
- 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- Add
data-optimisticto trigger elements anddata-optimistic-displayto display elements:
def render(assigns) do
~H"""
<div>
<div data-optimistic-display="count">{@count}</div>
<div data-optimistic-display="doubled">{@doubled}</div>
<button phx-click="increment" data-optimistic="increment">+</button>
<button phx-click="decrement" data-optimistic="decrement">-</button>
</div>
"""
end- Register the hook in your
app.js:
import { LavashOptimistic } from "./lavash_optimistic";
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { LavashOptimistic, ...otherHooks }
});When a user clicks a button with data-optimistic="increment":
- Client-side: The hook immediately runs the generated JavaScript function to update state
- Server-side: The normal
phx-clickevent is sent to the server - 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, 0 |
count: 0 |
set :count, &String.to_integer(&1.params.value) |
count: Number(value) |
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;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"
/>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>Optimistic updates work best for:
- Simple numeric operations (increment, decrement, set)
- Pure computed values (derives)
- UI state that doesn't require server validation
Actions with side effects (submit, navigate, effect, invoke) are not generated as optimistic functions.
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
endWhen a form submits, Lavash broadcasts to all relevant PubSub topics, and LiveViews with matching reads automatically reload.
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.
MIT