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.