Solve

Hex.pmHexDocsCILicense

Solve is a controller-graph state runtime for Elixir applications.

It models application state as a graph of focused controllers instead of a tree that mirrors UI structure. Each controller owns one slice of behavior and state, exposes a plain map, and declares its dependencies explicitly.

This keeps state structure and UI structure separate. Your UI can still render as a tree, but your application state does not have to follow that shape.

Install Solve

Add solve to your dependencies:

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

Model state around interaction

Users do not interact with one widget in isolation. They interact with the application as a whole.

A click, a filter change, a draft edit, or a menu selection is presented in one place, but it often affects several other parts of the system:

That is why Solve models state around ownership and interaction, not around the nearest rendered component.

A controller is not just a bucket of values. A controller models one coherent slice of application behavior.

Learn the core ideas

Declared event handlers take the leading subset of runtime inputs they need:

For example, an event handler may be defined at any of these arities:

event_name(payload)
event_name(payload, state)
event_name(payload, state, dependencies)
event_name(payload, state, dependencies, callbacks)
event_name(payload, state, dependencies, callbacks, init_params)

Start with one controller

Start with one controller.

A controller is the smallest useful unit in Solve. It owns one slice of state, handles a small set of events, and exposes a plain map for the rest of the application to read.

defmodule MyApp.CounterController do
  use Solve.Controller, events: [:increment, :decrement]

  @impl true
  def init(_params, _dependencies), do: %{count: 0}

  def increment(_payload, state), do: %{state | count: state.count + 1}
  def decrement(_payload, state), do: %{state | count: state.count - 1}
end

This controller models one small interaction boundary: incrementing and decrementing a counter.

That is the right place to begin. Start with one interaction and give it one controller.

Run controllers in an app

Controllers run inside a Solve app.

The app starts the controller graph, keeps it alive, and routes events to the right controller instance.

defmodule MyApp.App do
  use Solve

  @impl Solve
  def controllers do
    [
      controller!(name: :counter, module: MyApp.CounterController)
    ]
  end
end

Start the app like any other GenServer:

{:ok, app} = MyApp.App.start_link(name: MyApp.App)

At this point:

Describe data flow in the app

The app is not only a runtime container. It also defines how controllers interact.

That is the main role of the controller graph.

An app defines:

For example:

defmodule MyApp.App do
  use Solve

  @impl Solve
  def controllers do
    [
      controller!(name: :task_list, module: MyApp.TaskList),
      controller!(
        name: :create_task,
        module: MyApp.CreateTask,
        callbacks: %{
          submit: fn title -> dispatch(:task_list, :create_task, title) end
        }
      ),
      controller!(
        name: :filter,
        module: MyApp.Filter,
        dependencies: [:task_list]
      ),
      controller!(
        name: :task_editor,
        module: MyApp.TaskEditor,
        variant: :collection,
        dependencies: [:task_list],
        collect: fn _context = %{dependencies: %{task_list: task_list}} ->
          Enum.map(task_list.ids, fn id ->
            {id, [params: %{id: id, title: task_list.tasks[id].title}]}
          end)
        end
      )
    ]
  end
end

This graph describes data flow directly:

Controllers implement behavior. The app defines how controllers read from and write to each other.

Keep controllers focused

Each controller owns one coherent interaction boundary.

Good controller boundaries include things like:

A focused controller has:

For example:

defmodule MyApp.Screen do
  use Solve.Controller, events: [:set]

  @screens [
    %{id: :tasks, label: "Tasks"},
    %{id: :reports, label: "Reports"}
  ]

  @impl true
  def init(_params, _dependencies), do: %{current: :tasks}

  def set(screen, state) when screen in [:tasks, :reports] do
    %{state | current: screen}
  end

  def set(_screen, state), do: state

  @impl true
  def expose(state, _dependencies, _params) do
    %{current: state.current, screens: @screens}
  end
end

Use dependencies for derived state

Use dependencies when one controller needs another controller's exposed state as input.

Dependencies are for reads.

They are the right place for:

For example, a filter controller exposes visible_ids instead of making the UI recompute filtering logic during rendering:

defmodule MyApp.Filter do
  use Solve.Controller, events: [:set]

  @filters [:all, :active, :completed]

  @impl true
  def init(_params, _dependencies), do: %{active: :all}

  def set(filter, _state) when filter in @filters, do: %{active: filter}
  def set(_filter, state), do: state

  @impl true
  def expose(state, _dependencies = %{task_list: task_list}, _params) do
    %{
      filters: @filters,
      active: state.active,
      visible_ids: visible_ids(state.active, task_list)
    }
  end

  defp visible_ids(:all, %{ids: ids}), do: ids

  defp visible_ids(:active, %{ids: ids, tasks: tasks}) do
    Enum.reject(ids, fn id -> tasks[id].completed? end)
  end

  defp visible_ids(:completed, %{ids: ids, tasks: tasks}) do
    Enum.filter(ids, fn id -> tasks[id].completed? end)
  end
end

This keeps derived state out of the UI layer.

Use callbacks for explicit writes

Use callbacks when one controller needs to request a write from another controller.

Dependencies describe reads. Callbacks describe writes.

This keeps the dependency graph acyclic while preserving ownership.

For example, an input controller owns the draft title while another controller owns the canonical list:

defmodule MyApp.App do
  use Solve

  @impl Solve
  def controllers do
    [
      controller!(name: :task_list, module: MyApp.TaskList),
      controller!(
        name: :create_task,
        module: MyApp.CreateTask,
        callbacks: %{
          submit: fn title -> dispatch(:task_list, :create_task, title) end
        }
      )
    ]
  end
end

The controller still owns its own local transition logic:

defmodule MyApp.CreateTask do
  use Solve.Controller, events: [:set_title, :submit]

  @impl true
  def init(_params, _dependencies), do: %{title: ""}

  def set_title(title) when is_binary(title) do
    %{title: title}
  end

  def submit(_payload, state, _dependencies, _callbacks = %{submit: submit}) do
    case String.trim(state.title) do
      "" ->
        %{title: ""}

      title ->
        submit.(title)
        %{title: ""}
    end
  end
end

This keeps ownership explicit:

Use collections for repeated item state

Use a collection source when many items need the same local behavior.

This fits cases like:

A collection source reuses one controller design while keeping each item's local state separate.

controller!(
  name: :task_editor,
  module: MyApp.TaskEditor,
  variant: :collection,
  dependencies: [:task_list],
  collect: fn _context = %{dependencies: %{task_list: task_list}} ->
    Enum.map(task_list.ids, fn id ->
      {id, [params: %{id: id, title: task_list.tasks[id].title}]}
    end)
  end
)

Each item controller then owns only its own local behavior:

defmodule MyApp.TaskEditor do
  use Solve.Controller, events: [:begin_edit, :cancel_edit, :set_title, :save_edit]

  @impl true
  def init(params, _dependencies), do: %{editing?: false, title: params.title}

  def begin_edit(_payload, state), do: %{state | editing?: true}

  def set_title(title, %{editing?: true} = state) when is_binary(title) do
    %{state | title: title}
  end

  def cancel_edit(_payload, _state, _dependencies, _callbacks, params) do
    %{editing?: false, title: params.title}
  end

  @impl true
  def expose(state, _dependencies, params), do: Map.put(state, :id, params.id)
end

This keeps per-item local state out of the canonical list while still making every item controller addressable as {:task_editor, id}.

Read and write state directly

The base API is Solve.subscribe/3 and Solve.dispatch/4.

iex> {:ok, app} = MyApp.App.start_link(name: MyApp.App)
iex> Solve.subscribe(app, :counter)
%{count: 0}

iex> :ok = Solve.dispatch(app, :counter, :increment, %{})
iex> Solve.subscribe(app, :counter)
%{count: 1}

Solve.subscribe/3:

Solve.dispatch/4:

Solve also exposes a few inspection helpers:

Use these when a test, worker, or tool needs raw access to the runtime.

Use Solve.Lookup for process-local access

Use Solve.Lookup when a long-running process needs:

Solve.Lookup is framework-agnostic. It works in ordinary GenServer processes, workers, and UI processes.

The main helpers are:

Item lookups return the exposed state map augmented with an :events_ key. Collection lookups return %Solve.Collection{ids, items} whose items are augmented item maps.

Use Solve.Lookup in a GenServer

Solve.Lookup works well in ordinary GenServer processes.

defmodule MyApp.CounterWorker do
  use GenServer
  use Solve.Lookup

  def start_link(app) do
    GenServer.start_link(__MODULE__, app, name: __MODULE__)
  end

  @impl true
  def init(app), do: {:ok, %{app: app}}

  @impl true
  def handle_cast(:increment, state) do
    counter = solve(state.app, :counter)

    case event(counter, :increment) do
      {pid, message} -> send(pid, message)
      nil -> :ok
    end

    {:noreply, state}
  end

  def render(%{app: app} = state) do
    IO.inspect(solve(app, :counter), label: "counter")
    state
  end

  @impl Solve.Lookup
  def handle_solve_updated(_updated, state) do
    {:ok, render(state)}
  end
end

Key properties:

use Solve.Lookup defaults to handle_info: :auto. %Solve.Message{} envelopes refresh the local cache and call handle_solve_updated/2 for you.

Use handle_info: :manual when the process needs to inspect updates itself and decide which ones matter.

Use Solve.Lookup in Emerge

Solve.Lookup also fits naturally into Emerge viewports.

In Emerge, views read Solve state in render/0 or render/1, bind events from lookup items, and rerender from handle_solve_updated/2.

defmodule MyApp.Viewport do
  use Emerge
  use Solve.Lookup

  @impl Viewport
  def render(_state) do
    counter = solve(MyApp.App, :counter)

    row([], [
      button("+", event(counter, :increment)),
      el([], text("Count: #{counter.count}")),
      button("-", event(counter, :decrement))
    ])
  end

  @impl Solve.Lookup
  def handle_solve_updated(_updated, state) do
    {:ok, Viewport.rerender(state)}
  end
end

This keeps state reads and event wiring close to the view code that uses them.

Model larger applications

As an application grows, one controller stops being enough. The common shape is:

A task app can be split like this:

Controller Owns Depends on Purpose
:task_list canonical task data none create, update, delete, toggle tasks
:create_task draft input value none manage input state and submit new tasks
:filter active filter :task_list expose visible ids derived from task state
{:task_editor, id} local edit state for one task :task_list manage editing UI for a single item

This is a common Solve structure:

Split into separate apps only when parts of your system become genuinely independent domains. That means they:

Separate apps also fit when you need different variants of the same graph. A user-facing app and an admin app can reuse the same core controllers while the admin app adds moderation, audit, or other admin-only controllers.

Controllers stay reusable because they do not know which app they live in. The app defines how they are wired together. This works when reused controllers still receive the dependency keys, params, and callbacks they expect.

One concrete example of this style is the Emerge TodoMVC demo: https://github.com/emerge-elixir/emerge/tree/main/example

Pick the right primitive

Use these rules when choosing between Solve primitives:

Respect the invariants

Keep reading

Acknowledge the influences

Solve draws significant conceptual inspiration from Keechma Next, especially in its emphasis on controller-oriented state management, explicit data flow, and keeping UI structure separate from state structure.