Lavash.Reactive (Lavash v0.4.0-rc.1)

Copy Markdown View Source

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
end

put/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

build(builder)

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.

dep_resolver(builder, name, fun)

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)

derive(builder, name, rx)

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 to AsyncResult.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 query Graph.fields_with_tag(graph, tag) to find all fields with a given tag (e.g. for resource invalidation).

derive(builder, name, deps, fun)

derive(builder, name, deps, fun, opts)

erase_graph(env, bytecode)

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.

fields_with_tag(graph, tag)

Returns field names that have the given tag.

Useful for resource-centric invalidation:

Reactive.fields_with_tag(graph, {:resource, MyApp.Post})

graph(module, build_fn)

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

handle_async(socket, arg2)

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

init(socket, graph)

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.

new()

Creates a new reactive graph builder.

put(socket, field, fun)

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

recompute(socket)

Recomputes all derived fields affected by dirty state fields, then clears the dirty set.

Returns the socket unchanged if nothing is dirty.

recompute_all(socket)

Recomputes all derived fields in topological order.

recompute_dependents(socket, changed_field)

Recomputes derived fields that depend (transitively) on changed_field.

state(builder, name, default)

Registers a state field with a default value.