Skip to content

The Elm architecture, without a DOM

Lustre and Gleam OTP actors use the same programming model. Each one starts with some state. Each one receives a typed message. Each one runs one function that returns the next state and any work to do. Lustre uses this model to run a user interface. An OTP actor uses it to run a concurrent process. beryl uses it to handle realtime socket traffic.

The last step is different in each case. Lustre renders a view. An actor keeps its next state or stops. beryl returns protocol effects. An effect can accept a join, send a reply, or broadcast a message. beryl also creates state once for each connected socket, not once for the whole application.

We start with beryl's core API. It keeps state, inputs, and effects in plain view. First we compare the Lustre and OTP forms. Then we show the beryl API and map its terms onto the same model.

We will build a live poll with rooms. A browser joins a topic such as poll:demo. It reads the current totals, votes for Gleam or Erlang, and sees votes from other tabs. Later chapters add automatic closing and a second kind of subscription.

A topic is a string. It names one subscription inside a WebSocket connection. In poll:demo, poll is the kind of subscription and demo is the room. One socket can join many topics. beryl uses the topic string to route messages and broadcasts.

beryl does not call this subscription a channel. In beryl, a channel is a separate API built on top of topics. A later chapter introduces that API. For now, the browser joins a topic, and your application routes it by its string.

A poll is a good example because it needs little domain code. Each browser connection needs its own state. All browsers in the same room share one poll. A vote also produces two kinds of output: a reply to the voter and a broadcast to everyone else. These needs give us real reasons to use models, typed messages, topic routing, effects, and an OTP actor that your application owns.

This chapter ends with the smallest useful checkpoint. The server accepts a poll:* join and returns the current poll state. Voting, broadcasts, timers, and channels come later, after the core loop is clear.

The shared model has four parts:

  1. create some state;
  2. define the messages, or inputs, that can arrive;
  3. handle one input against the current state;
  4. return the next state and say what happens next.

Lustre, OTP actors, and beryl use different names for the fourth part. They also do different things with it. Parts one to three look the same in all three.

The standard Lustre example has four pieces. Here is the counter from the Lustre overview, with full type annotations (see Lustre for Elm developers):

type CounterModel =
Int
fn init(_flags: Nil) -> CounterModel {
0
}
type Message {
Incr
Decr
}
fn update(model: CounterModel, message: Message) -> CounterModel {
case message {
Incr -> model + 1
Decr -> model - 1
}
}

The view calls each module by name:

fn view(model: CounterModel) -> element.Element(Message) {
let count = int.to_string(model)
html.div([], [
html.button([event.on_click(Incr)], [element.text(" + ")]),
html.p([], [element.text(count)]),
html.button([event.on_click(Decr)], [element.text(" - ")])
])
}
  • init creates the first model.
  • Message lists every event that can change the state.
  • update computes the next model.
  • view turns the model into elements. Those elements can send more messages.

Some work cannot happen inside update. A browser may need to send an HTTP request, start a timer, or call JavaScript. Lustre calls this work an Effect(Message). An update returns the next model and an effect. The Lustre runtime does the work. It can then send the result back to update as a new Message.

An OTP actor uses the same loop. The loop runs inside a concurrent process instead of a browser.

A Gleam OTP actor is a process with a mailbox. It owns some state. It handles one message at a time, in order. We keep the same counter so the only change is the runtime:

type Message {
Incr
Decr
}
fn on_message(count: Int, message: Message) -> actor.Next(Int, Message) {
case message {
Incr -> actor.continue(count + 1)
Decr -> actor.continue(count - 1)
}
}

The model is still an Int. The messages are still Incr and Decr. Two things change: where messages come from, and what happens to the result. Lustre gets messages from the interface and passes the next model to view. An actor gets messages from its mailbox and calls actor.continue to store the next state.

Here are the two side by side:

LustreGleam OTP actor
modelactor state
Messageactor mailbox message
updateon_message handler
next model and effectsactor.Next plus sends performed by the handler
viewno equivalent

A Lustre message is usually an interface event or the result of an effect. An actor message comes from another process. Both go through the same kind of typed transition. Lustre uses the result to update the screen. The actor runtime uses actor.Next to continue or stop the process.

To send a message to an actor, you use a process.Subject(message). A subject is a typed address for the actor's mailbox.

Now we can carry the same model into beryl and give each part a socket meaning.

beryl gives you two ways to program a socket endpoint. The channel layer is the recommended one. It routes each event to a handler based on the topic. Under it sits the raw dispatch API. Raw dispatch gives every event on a socket to one update function that you write.

"Raw dispatch" means your application does the routing. Your code receives joins, client messages, binary frames, close events, and typed server messages. Each one arrives as a socket.Input value. Your code decides how each input changes the socket's model. beryl still owns the WebSocket transport, wire decoding, protocol checks, and effect execution.

We start with raw dispatch because it shows the full loop. A later chapter moves the same poll to the channel layer. That chapter shows which routing work moves out of your code.

The three domains now line up:

RoleLustreGleam OTP actorberyl raw dispatch
stateapplication modelactor stateone app-defined Model per socket
inputMessageactor messagesocket.Input(Message)
transitionupdateon_message handlerupdate
next stepmodel and optional effectactor.Nextsocket.Next(model, effects)

beryl asks your application for init and update. This exact excerpt comes from step_01.gleam:

beryl.child_spec(
beryl.config(wire.phoenix_codec()),
init: raw.init,
update: raw.update(raw.ReadOnly, polls, clock, 60_000),
)

You can see it in step_01.gleam. beryl.child_spec returns two values. The first is a beryl.Sockets handle. The second is a child specification for your supervisor. Your model and message types stay inside the closures you pass in. beryl never converts them to Dynamic.

The live-poll example defines these raw types:

pub type Message {
ClosePoll(topic: String)
}
pub type Model {
Model(sender: socket.Sender(Message), topics: Set(String))
}
pub fn init(info: socket.ConnectInfo(Message)) -> #(Model, List(socket.Effect)) {
#(Model(sender: info.self, topics: set.new()), [])
}

This excerpt comes from raw.gleam. Each name has one meaning:

  • Model is the state your application keeps for one connected socket.
  • Message is your own type for server-side events. The poll defines ClosePoll because a timer must tell a socket that one of its polls has ended. Another application would define its own variants.
  • socket.Effect describes work for beryl to do after init or update.
  • socket.ConnectInfo.self is a sender for this socket only. It is not a general process Subject.

The type parameter links the server-message path together. ConnectInfo(Message) gives you a Sender(Message). When you send a value through that sender, beryl delivers it to update as Info(Message). Client frames take a different path. They arrive as the Join, Message, and Binary variants of socket.Input. Your Message type never holds decoded client data.

The update function has the type fn(Model, socket.Input(Message)) -> socket.Next(Model). It matches every input variant. beryl uses socket.Input(Message) where Lustre uses Message and an actor uses its mailbox message. Some input variants carry client data. Info(Message) carries your typed server-side data. socket.Next(model, effects) continues with the next model. socket.Stop(reason) stops the whole socket.

We have mapped state and input. The last difference is output. beryl does not render the model. Instead, your application returns an ordered list of socket.Effect values. This exact excerpt comes from raw.gleam:

socket.Next(
Model(..model, topics: set.insert(model.topics, topic)),
[socket.AcceptJoin(ref, None)],
)

Before this line, the full example registers the room in its store. This branch then accepts a valid poll:* join and adds the topic to the socket's model. Other effects can reply to a client message, push to this socket, broadcast to subscribers, update presence, or close a topic.

Without view, you think about output in a new way. A change to the model does not send a wire frame. A wire frame goes out only when an effect asks for one. The model can also hold facts that never leave the server. The set of topics this socket has joined is one example.

Try that difference with the counter. Increment its private state three times before you request the count. Then increment again while the reply travels. The client receives the value captured when the effect was created.

Change state without sending it

Increment the server model, then request its count. Watch what the client receives.

Server model0Private count
Reply wire
Client display--No reply yet
Counter inputs

Last update return (pseudocode)

No input yet

The server starts at 0. The client has not received a count.

Local simulation, not a BEAM server. The counter topic is already joined. Increment is a typed server input; Request count is a client request with a reply reference.

Increment model represents an application-originated Info(Increment). Request count represents a client get_count request on an accepted topic. The reply uses the reference from that request.

The counter handler below comes from tutorial_counter.gleam. It is not a complete server.

Counter handler
import beryl/socket
import gleam/json
import gleam/option.{None}
pub type CounterMessage {
Increment
}
/// Start the teaching counter with no output.
pub fn counter_init(
_info: socket.ConnectInfo(CounterMessage),
) -> #(Int, List(socket.Effect)) {
#(0, [])
}
/// Separate private model changes from replies to client requests.
pub fn counter_update(
count: Int,
input: socket.Input(CounterMessage),
) -> socket.Next(Int) {
case input {
socket.Join("counter:demo", _payload, ref) ->
socket.Next(count, [socket.AcceptJoin(ref, None)])
socket.Join(_, _, ref) ->
socket.Next(count, [
socket.RejectJoin(ref, json.string("unknown topic")),
])
socket.Info(Increment) -> socket.Next(count + 1, [])
socket.Message("counter:demo", "get_count", _payload, reply) ->
socket.Next(count, socket.reply_ok(reply, json.int(count)))
socket.Message(_, _, _, _) | socket.Binary(_, _) | socket.Closed(_, _) ->
socket.Next(count, [])
}
}

Lustre creates one application model. An OTP actor usually creates one state value. beryl calls raw init once for every socket that connects. One socket actor stores that socket's Model and runs its updates. A separate router actor keeps the list of sockets and routes frames and broadcasts. This split matters for blocking work and crashes. Chapter 5 covers both.

The live poll now needs an actor that your application owns. Every browser in a room must see the same totals. So the totals cannot live in one socket's Model. The example puts them in a shared store.Store actor and captures it in the update closure. Each browser socket gets its own raw Model. All sockets send poll operations to the same store. Per-socket state and shared domain state stay apart.

The Elm architecture gives us words for pure state transitions. But beryl is not a frontend framework:

  • there is no view function;
  • init runs once per socket;
  • client input arrives as socket.Input;
  • output is an explicit list of socket.Effect values;
  • one socket actor stores each socket's model and runs its updates.

Raw dispatch shows these facts with little extra code. It is beryl's core. It is the clearest place to learn joins, replies, close events, and effect order. When an application has several channel families, you will usually move to beryl/channel. That is the recommended default for multi-channel and Phoenix-shaped systems. Chapter 4 makes that move without changing the wire protocol.

Terminal window
cd examples/live_poll && gleam run -m live_poll/step_01

Open http://localhost:8101, keep the room as demo, and select Join poll. The client joins poll:demo, requests get_state, and shows an open poll with zero votes. Voting and Close poll now do not work yet in this read-only checkpoint. Those pushes time out and do not change state.

Next: One update function, many socket events.