Introduction
When I first learned Elixir, I was wondering how to manage state. Unlike imperative languages with mutable global variables, Elixir’s immutable data model and concurrency-driven design (via the BEAM VM) require a different approach. In this article, I’ll explore how state is handled in Elixir.
Context: BEAM VM and Concurrency
Elixir runs on the BEAM virtual machine, designed for high concurrency and fault tolerance. Inspired by the Actor Model, BEAM treats processes as lightweight entities that communicate via message passing. Because data is immutable, state changes are achieved by creating new values rather than modifying existing ones. This ensures thread safety and simplifies concurrent programming.
Recursive Loops
The simplest way to maintain state is by implementing a recursive loop. Here’s an example:
defmodule StatefulMap do def start do spawn(fn -> loop(%{}) end) end def loop(current) do new = receive do message -> process(current, message) end loop(new) end def put(pid, key, value) do send(pid, {:put, key, value}) end def get(pid, key) do send(pid, {:get, key, self}) receive do {:response, value} -> value end end defp process(current, {:put, key, value}) do Map.put(current, key, value) end defp process(current, {:get, key, caller}) do send(caller, {:response, Map.get(current, key)}) current end end
Usage would be:
pid = StatefulMap.start() # PID<0.63.0> StatefulMap.put(pid, :hello, :world) StatefulMap.get(pid, :hello) # :world
Agents
Another option is the Agent
; this module allows you to easily share state between different processes or among the same process over time.
A sample implementation:
defmodule Counter do use Agent def start_link(initial_value) do Agent.start_link(fn -> initial_value end, name: __MODULE__) end def value do Agent.get(__MODULE__, & &1) end def increment do Agent.update(__MODULE__, &(&1 + 1)) end end
Usage would be:
Counter.start_link(0) #=> {:ok, #PID<0.123.0>} Counter.value() #=> 0 Counter.increment() #=> :ok Counter.increment() #=> :ok Counter.value() #=> 2
To start it, it's recommended to use a supervisor
children = [ {Counter, 0} ] Supervisor.start_link(children, strategy: :one_for_all)
GenServer
The most classic option is a GenServer
behaviour (it's similar to interface in .NET/Java), allows you to manage your state with sync and async requests
Key callbacks:
-
init/1
-> when the actor is started. -
handle_call/2
-> Sync request (e.g., expecting a response). -
handle_cast/3
-> Async request (e.g., fire-and-forget).
Here is a sample of GenServer
defmodule Stack do use GenServer # Callbacks @impl true def init(elements) do initial_state = String.split(elements, ",", trim: true) {:ok, initial_state} end @impl true def handle_call(:pop, _from, state) do [to_caller | new_state] = state {:reply, to_caller, new_state} end @impl true def handle_cast({:push, element}, state) do new_state = [element | state] {:noreply, new_state} end end
Usage:
# Start the server {:ok, pid} = GenServer.start_link(Stack, "hello,world") # This is the client GenServer.call(pid, :pop) #=> "hello" GenServer.cast(pid, {:push, "elixir"}) #=> :ok GenServer.call(pid, :pop) #=> "elixir"
Conclusion
Elixir’s state management relies on processes and immutability. Recursive loops provide foundational control, Agent
simplifies shared state, and GenServer
offers robust concurrency with supervision integration. Each tool serves distinct use cases, from simple counters to complex state logic.
Top comments (0)