Reactive state management for Phoenix LiveViews.
Provides a builder API to define state fields and derived computations,
producing a precomputed %Graph{} that drives automatic recomputation
when state changes.
The graph is stored on the socket during init/2, so subsequent calls
don't need the graph passed explicitly.
Usage
defmodule MyLive do
use Phoenix.LiveView
import Lavash.Rx
defp graph do
Lavash.Reactive.graph(__MODULE__, fn ->
Lavash.Reactive.new()
|> Lavash.Reactive.state(:count, 0)
|> Lavash.Reactive.state(:step, 1)
|> Lavash.Reactive.derive(:doubled, rx(@count * 2))
|> Lavash.Reactive.derive(:next, rx(@count + @step))
|> Lavash.Reactive.build()
end)
end
def mount(_params, _session, socket) do
{:ok, Lavash.Reactive.init(socket, graph())}
end
def handle_event("inc", _, socket) do
socket =
socket
|> Lavash.Reactive.put(:count, &(&1 + 1))
|> Lavash.Reactive.recompute()
{:noreply, socket}
end
# Required for async derives:
def handle_info(msg, socket) do
case Lavash.Reactive.handle_async(socket, msg) do
{:ok, socket} -> {:noreply, socket}
:not_handled -> {:noreply, socket}
end
end
endput/3 accepts a value or a function (&(&1 + 1)), marks the field
dirty, and defers recomputation until recompute/1 is called:
socket
|> Reactive.put(:search, "elixir")
|> Reactive.put(:page, 1)
|> Reactive.recompute()Async Derives
Mark a derive as async: true to run its computation in a background task.
The field is immediately set to AsyncResult.loading(), then updated when
the task completes. Downstream derives automatically propagate loading and
failed states.
|> Reactive.derive(:results, rx(fetch_results(@search)), async: true)
|> Reactive.derive(:count, rx(length(@results)))graph/2 builds the graph once and caches it in persistent_term,
so the topo sort only runs once per module for the entire app lifecycle.
Note: anonymous functions cannot be stored in module attributes (@graph)
due to BEAM escaping limitations. The graph/2 + persistent_term approach
achieves the same zero-cost-after-first-call behavior without macros.
Summary
Functions
Finalizes the builder into a frozen %Graph{}.
Registers a custom dependency resolver.
Registers a derived field with an rx() expression.
Drops the cached graph for the given module.
Returns field names that have the given tag.
Returns a cached %Graph{} for the given module.
Handles async task completion messages.
Initializes a socket with the reactive graph.
Creates a new reactive graph builder.
Sets a state field and marks it dirty.
Recomputes all derived fields affected by dirty state fields, then clears the dirty set.
Recomputes all derived fields in topological order.
Recomputes derived fields that depend (transitively) on changed_field.
Registers a state field with a default value.
Functions
Finalizes the builder into a frozen %Graph{}.
Performs topological sort and builds dependency indices. After this, the graph is immutable and ready for runtime use.
Registers a custom dependency resolver.
When a derive depends on a name that has a resolver, the resolver function
is called with the socket to produce the value, instead of reading from
socket.assigns.
This is useful for synthetic dependencies like :__actor__ or :__all_state__
that don't correspond to actual assign keys.
builder
|> Reactive.dep_resolver(:__actor__, fn socket -> socket.assigns[:current_user] end)
|> Reactive.dep_resolver(:__all_state__, &Lavash.Socket.full_state/1)
Registers a derived field with an rx() expression.
Dependencies are auto-extracted from @field references, and the
compute function is compiled in the caller's context (so private
functions are accessible).
import Lavash.Rx
derive(builder, :doubled, rx(@count * 2))
derive(builder, :fact, rx(factorial(@count)), async: true)Options:
async: true— computation runs in a background task. The field is set toAsyncResult.loading()immediately, then updated when the task completes. Downstream derives propagate loading/failed states automatically.tags: [term()]— arbitrary tags for this field. The graph builds a reverse index so callers can queryGraph.fields_with_tag(graph, tag)to find all fields with a given tag (e.g. for resource invalidation).
Drops the cached graph for the given module.
Takes an Macro.Env and bytecode so it matches the @after_compile callback
shape. Wired in by build_cache_invalidation_ast/0 in the Lavash compile
transformers so dev recompiles rebuild the graph.
Returns field names that have the given tag.
Useful for resource-centric invalidation:
Reactive.fields_with_tag(graph, {:resource, MyApp.Post})
Returns a cached %Graph{} for the given module.
On first call, invokes build_fn to construct the graph, then stores
it in persistent_term. Subsequent calls return the cached graph
with zero overhead.
defp graph do
Lavash.Reactive.graph(__MODULE__, fn ->
Lavash.Reactive.new()
|> Lavash.Reactive.state(:count, 0)
|> Lavash.Reactive.derive(:doubled, [:count], &(&1.count * 2))
|> Lavash.Reactive.build()
end)
end
Handles async task completion messages.
Call this from your LiveView's handle_info/2:
def handle_info(msg, socket) do
case Lavash.Reactive.handle_async(socket, msg) do
{:ok, socket} -> {:noreply, socket}
:not_handled -> {:noreply, socket}
end
end
Initializes a socket with the reactive graph.
Stores the graph on the socket, sets all state defaults, and computes
all derived values. Subsequent calls to set/3, put/3, etc. retrieve
the graph from the socket automatically.
Creates a new reactive graph builder.
Sets a state field and marks it dirty.
Accepts a value or a 1-arity function (applied to the current value).
Recomputation is deferred — call recompute/1 to flush:
socket
|> Reactive.put(:count, &(&1 + 1))
|> Reactive.put(:step, 1)
|> Reactive.recompute()
Recomputes all derived fields affected by dirty state fields, then clears the dirty set.
Returns the socket unchanged if nothing is dirty.
Recomputes all derived fields in topological order.
Recomputes derived fields that depend (transitively) on changed_field.
Registers a state field with a default value.