Part I was why. This one is how — the syntax, and the
reasoning behind every decision.
Last time, in one slide
I was a mobile dev. Then I fell for Elixir.
Wanted the whole BEAM on the phone — not a webview, not a bridge.
The eureka: ship the BEAM as a NIF, drive native views from Elixir.
"Mob is LiveView for native mobile."
No mobile background needed tonight. If you've written a
LiveView and a GenServer, you already know Mob.
The bet
Make good decisions by stealing good ones.
Copy LiveView, GenServer, OTP — unless mobile says otherwise.
Bridge both ways: Elixir → mobile, and mobile → Elixir.
Don't reinvent. Solve solved problems with the known-good answer.
And there's a side effect
Adopt the patterns everyone already knows… …and Claude freaking loves this thing.
Familiar patterns = less to invent = an agent that's already fluent.
Let's make one
mix mob.new mobdemo
cd mobdemo && mix mob.install
mix mob.deploy # → onto a real phone / simulator
One command scaffolds a working app: a home screen, a handful of
example screens, an Ecto repo — all in Elixir.
It runs. It does the thing. Then we open it up.
Anatomy of a Screen
defmodule Mobdemo.DiceScreen do
use Mob.Screen
def mount(_params, _session, socket) do
{:ok, Mob.Socket.assign(socket, :value, nil)}
end
def render(assigns) do
# … a view tree …
end
def handle_info({:tap, :roll}, socket) do
{:noreply, Mob.Socket.assign(socket, :value, :rand.uniform(6))}
end
end
Squint. That's a LiveView. That's a GenServer.
You already know the API
LiveView / GenServer
Mob
mount/3
→
mount/3
render(assigns)
→
render(assigns)
assign(socket, …)
→
Mob.Socket.assign(socket, …)
handle_event/3
→
handle_info/2
push_navigate
→
push_screen / pop_screen
One row is different — handle_event → handle_info. We'll get to why.
Two ways to say the same thing
A screen is just a tree of components.
Write that tree however you like:
~MOB sigilplain maps
Same tree. Pick the one that fits your hands —
or your robot's.
HTML-ish, but it's Elixir all the way down — {nav_button(...)} is a function call.
The state loop: tap → message → re-render
# in render: the button announces who to message, and with what tag
%{type: :button,
props: %{text: "Roll again", background: :primary,
on_tap: {self(), :roll}}}
# the tap arrives as a plain message
def handle_info({:tap, :roll}, socket) do
value = :rand.uniform(6)
{:noreply, Mob.Socket.assign(socket, :value, value)}
end
Tap → {:tap, :roll} lands in handle_info
assign the new state → Mob re-renders the native view
Exactly the LiveView loop. No diffing in your code.
Everything a screen receives is just a message to its pid.
handle_info/2 already handles messages — it's the general primitive.
handle_event is a specialization LiveView adds. Mob didn't need it.
The general pattern was already enough. Fewer concepts, not more.
While we're on the topic…
Catch-events fits native. Callbacks don't.
BEAM — catch events
Native is synchronous — it emits and receives events.
The BEAM lives on messages and mailboxes.
Same shape on both sides — they just fit.
React Native — callbacks
JS was sync, then bolted on async to chase performance.
Callbacks are the kludge a sync language reinvents itself with.
A model grafted onto native's — not matched to it.
The two models just don't fit well together — IMO.
Navigation is a stack, not a route table
# go deeper
def handle_info({:tap, :open_dice}, socket) do
{:noreply, Mob.Socket.push_screen(socket, Mobdemo.DiceScreen)}
end
# go back
def handle_info({:tap, :back}, socket) do
{:noreply, Mob.Socket.pop_screen(socket)}
end
Web is a tree of URLs. Mobile is a stack of screens.
So nav is push / pop on a stack — held in a GenServer.
Here, Mob follows mobile's precedent, not the web's.
"No way that works on a phone"
"It's a server. It'll be fat, heavy, slow, and it'll torch the battery."
Let's take those one at a time.
Busted, one at a time
FatThe BEAM is a few MB. No embedded browser, no JS engine.
HeavyViews are native (UIKit / Compose). Elixir just sends a tree.
SlowRendering is the platform's. Elixir does very little work.