BEAM on Mobile · Part II
Mob: the syntax
LiveView muscle memory, on a phone
Kevin Edey · Elixir meetup · 2026
Part I was why . This one is how — the syntax, and the
reasoning behind every decision.
Sequel to the blog post (genericjam.com/blog/mob-intro = the written Part I).
Part I = motivation + architecture. Tonight = syntax + the reasoning. Audience:
Elixir devs, assume zero mobile knowledge. ~20 min slides, ~20 min demo.
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.
Gentle recap for the room — don't relitigate Part I. The one load-bearing fact:
it's real native views (SwiftUI / Jetpack Compose), with the full BEAM on device.
Logic in Elixir, rendering native. Land the last line: this talk leans entirely on
patterns they already have.
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.
Core thesis. Mob isn't novel for novelty's sake — it adopts established patterns.
Where mobile has a stronger precedent (navigation stack, native layout), follow
mobile. Where Elixir does (state, concurrency, events), follow Elixir. The bridge
metaphor is the whole pitch.
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.
Because Mob reuses LiveView/GenServer shapes, the model has seen millions of
examples of the underlying patterns. It writes Mob screens fluently without me
teaching it a bespoke DSL. This pays off hard in the demo. Beat the punchline.
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.
This is the "generate → does the thing → dive in" beat. In the live demo I'll
actually run this and show it on the device. The generated app ships with 8 example
screens (text input, dice, rock-paper-scissors, webview, audio, camera, storage) —
those are exactly the files we dissect next.
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.
Walk it slowly for the room. use Mob.Screen, mount/3 returns {:ok, socket}, render/1
takes assigns, handle_info/2 reacts to messages and returns {:noreply, socket}. Every
single shape here is borrowed. Nothing new to learn.
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.
The whole table is "you already know this." Flag the one deviation (events arrive as
messages, handled in handle_info, not handle_event) and promise the reason — it's the
NIF boundary, covered two slides on.
Two ways to say the same thing
A screen is just a tree of components.
Write that tree however you like:
~MOB sigil
plain maps
Same tree. Pick the one that fits your hands —
or your robot's.
Set up the duality. The view is data either way. The sigil is sugar that compiles to
the maps. Humans tend to like the sigil; Claude usually reaches for maps.
Sigil vs map
~MOB — humans like this
~MOB"""
<Scroll background={:background}>
<Column padding={:space_lg}>
<Text text="Roll!" text_size={:xl} />
<Button
text="Roll Dice"
on_tap={{self(), :roll}} />
</Column>
</Scroll>
"""
maps — Claude reaches for this
%{
type: :scroll,
props: %{background: :background},
children: [
%{type: :column, props: %{padding: :space_lg},
children: [
%{type: :text,
props: %{text: "Roll!", text_size: :xl}},
%{type: :button,
props: %{text: "Roll Dice",
on_tap: {self(), :roll}}}
]}
]
}
The sigil compiles to the map. Identical tree, identical app.
Land it: ~MOB is just sugar. Same data structure underneath. Claude "can't be bothered
with sigils" — it emits the maps directly and is happy. Both ship in the SAME generated
app: home/audio/camera/webview/storage use the sigil, text/dice/list use maps.
Real screen: the home screen
def render(assigns) do
~MOB"""
<Scroll background={:background}>
<Column padding={:space_lg}>
<Image src={logo_src(assigns.theme)} width={120} height={120} />
<Text text="Mobdemo" text_size={:xl} text_color={:on_surface} />
<Text text="BEAM running on device" text_color={:primary} />
<Spacer size={40} />
{nav_button("Roll Dice", :open_dice)}
{nav_button("Text Input", :open_text)}
</Column>
</Scroll>
"""
end
HTML-ish, but it's Elixir all the way down — {nav_button(...)} is a function call.
Straight from the generated app (lightly trimmed). Point out: interpolation with {}
is just Elixir — nav_button/2 is a private helper returning a component node. Tokens
like :space_lg, :on_surface, :primary are theme design tokens, not raw pixels/hex.
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.
This is the heart of it. The full dice screen also tracks history + a running average
in assigns — same loop, more state. Stress: you never touch a view object; you change
state and describe the tree, Mob reconciles to native.
Why handle_info, not handle_event?
on_tap: {self(), :roll} → handle_info({:tap, :roll}, socket)
on_change: {self(), :text_changed} → handle_info({:change, :text_changed, value}, socket)
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.
The reasoning here is the whole thesis in miniature — and it came out of a long
back-and-forth with Claude. handle_event is an extra abstraction LiveView layers on
for the web. On the BEAM, a UI event is already just a message to a pid, and
handle_info already catches messages — so there was nothing to add. We reached for
the MORE general pattern, not a new one. Don't invent an abstraction you don't need.
on_change carries the value as a third tuple element.
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.
The opinion slide, and an honest one — flag the IMO. The argument: native UI is
synchronous and event-emitting; that's exactly the BEAM's home turf (messages +
mailboxes), so Mob's catch-events model lines up 1:1 with the platform. React Native
instead leans on callbacks — and callbacks are what synchronous languages (JS) had to
invent to fake async for performance. So RN grafts an async-callback model onto a
native event model; they don't fit. Mob's does, because both sides speak events.
Navigation is a layout, too
A screen lays out components — the app lays out navigation.
use Mob.App
# each platform gets its native shell
def navigation(:ios), do: tab_bar([stack(:home, root: HomeScreen),
stack(:browse, root: BrowseScreen)])
def navigation(:android), do: drawer([stack(:home, root: HomeScreen),
stack(:browse, root: BrowseScreen)])
def navigation(_), do: stack(:main, root: HomeScreen)
tab_bar → iOS tab bar · Android bottom nav
drawer → native Android side drawer
Per-platform — and all just maps . Navigation is data too.
The sibling of the screen-layout slides: a screen is a tree of component maps; the
app's navigation is a tree of stack/tab_bar/drawer maps. mix mob.new ships the simple
single-stack form — stack(:main, root: HomeScreen) — which is the navigation(_)
fallback here. The win: navigation/1 takes the PLATFORM, so you give each OS its
native shell (UITabBarController on iOS, NavigationBar or drawer on
Android). Stack names (:home, :browse, :main) become push_screen destinations — next
slide.
Moving within it: push & pop
# 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 you push / pop the stack — held in a GenServer, driven by a message.
Hardware back / back-swipe pops it for free .
The runtime half: a tap message lands in handle_info and pushes or pops a screen on
the stack declared on the previous slide. Where MOBILE has the stronger pattern, Mob
copies mobile (stack of screens, not a route table). Back-gesture / hardware back pops
automatically — you don't wire it.
"I'll never do apps…"
"…I just like Ecto too much to give it up."
We gotchu, bruv.
defmodule Mobdemo.Repo do
use Ecto.Repo, otp_app: :mobdemo,
adapter: Ecto.Adapters.SQLite3 # ← right there on the phone
end
Repo.insert!(%Round{user_choice: "rock", result: "win"})
Repo.all(from r in Round, order_by: [desc: r.inserted_at])
Real schemas, migrations, from — on SQLite in the app's private storage. Survives restarts and updates.
Kevin's joke — land it: the objection ("I'll never do apps, I just love Ecto too
much"), beat, then "We gotchu, bruv." The generated app ships exactly this: a
Mobdemo.Repo on the Ecto.Adapters.SQLite3 adapter, a Round schema, real
insert!/from/order_by in list_screen.ex (Rock Paper Scissors persists every round).
The DB file lives in MOB_DATA_DIR — NSDocumentDirectory on iOS, getFilesDir() on
Android — app-private, survives updates. It's the whole Ecto you already use; nothing
to relearn. Good segue into myth-busting: "a SQL database on a phone? yes."
"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.
The skeptic's reflex. Everyone's seen the BEAM as a beefy server process. Set up each
myth, then knock them down on the next slide. Keep it playful.
Busted, one at a time
Fat The BEAM is a few MB . No embedded browser, no JS engine.
Heavy Views are native (SwiftUI / Jetpack Compose). Elixir just sends a tree.
Slow Rendering is the platform's. Elixir does very little work .
Battery Idle BEAM = idle CPU. Message-driven , nothing polling.
Reveal one card at a time. The throughline: people picture the BEAM doing the heavy
lifting, but in Mob it does almost nothing — the OS draws the pixels. The BEAM is just
coordinating messages, which is precisely what it sips power doing.
What I left out: a CSS layout engine
React Native ships a whole flexbox engine (Yoga) to re-implement CSS layout on top of native.
Mob uses the platform's native layout directly — Column, Row, Box, Spacer.
No layout engine to ship, debug, or drain the battery.
Bonus: native containers clip by default — no surprise overflow.
Less code, fewer bugs, and it's not even my code doing the layout.
The deliberate omission + the gentle RN jab. RN's Yoga is impressive engineering for a
problem Mob declines to have. Native layout primitives map cleanly to Column/Row/Box.
The overflow-by-default line is a callback to the Part I critique of RN.
"The performance smokes"
It's not magic. It's native —
and Elixir is doing almost nothing.
The view tree is tiny. The OS draws it at full native speed.
The BEAM just passes messages — its home turf.
We got here by doing very little work.
(I did no performance tuning apart from rejecting layers.)
Tie the bow on myth-busting. The reason it's fast is the reason it's light is the reason
the battery's fine: minimal work on the Elixir side, native everywhere it matters.
"We did very little work to get here" — callback to the blog.
Native is already in the room
The BEAM runs inside the app — so every native framework is already loaded in-process. You don't bridge to it. You just call it.
EMLX · on-device ML
TF Lite
iOS / Android TTS · STT
Apple Intelligence
All already demoed. No abstraction layer waiting to not support the thing you need.
The "no ceiling" beat. Because the BEAM is in-process (the cocoon model), any native
SDK loaded into the app is reachable — no wrapper, no bridge module that lags the
platform. Proof, not theory: I've already built demos on EMLX (Apple MLX / on-device
ML), TensorFlow Lite, native iOS + Android TTS/STT (Mob.Speech), and Apple
Intelligence. The whole pitch: it's right there, you just tie in.
Go lower, or go community
mix mob.add_nif crunch --type rustler # Rust (Cargo crate)
mix mob.add_nif crunch --type zigler # Zig (inline ~Z)
mix mob.add_nif crunch --type c # C (skeleton)
Statically linked, App-Store-safe, wired in one command — plus embedded Python via Pythonx.
A plugin system (mapped, in build) makes it community-driven — write once, anyone adds it.
Plugins are opt-in, not opt-out — nothing ships unless you reach for it.
Almost anything you can do on a phone — you'll be able to do.
One arg drops you to native in Rust / Zig / C (mix mob.add_nif --type …), and Python
rides along via embedded CPython (Pythonx, --python). These are STATICALLY linked, not
dlopen'd .so — iOS rejects bundled dylibs and Android's RTLD_LOCAL hides enif_*
symbols, so mob links the NIF into the main binary and wires the static driver table
in the same command. The wooly part gets solved by the plugin system (already mapped,
being built): native capabilities become community packages. Crucially plugins OBLIGE
opt-in, not opt-out — you only ship what you ask for (footprint + security win).
I need more BEAM!
A distributed BEAM cluster — across phones. It works.
Catch: dist is trust-all — the cookie is the only gate.
Real permissions = a major rewrite. Across a trust boundary, that's why the good Lord gave you HTTPS.
EPMD control is yours — cluster at will. Just don't cluster with strangers.
Phone↔phone: server + client each + track IPs, or a tunnel (Cloudflare / Tailscale).
The distribution superpower — and an honest caveat. BEAM dist authenticates with a
shared cookie; once two nodes connect they FULLY trust each other (RPC can run any
code on the peer). There's no per-message capability model, and bolting one on would
be a major rewrite of dist. So treat the cluster as one trust domain: cluster nodes
you own, and put anything crossing a trust boundary behind HTTPS/TLS (Phoenix
endpoint, REST/WebSocket) — Kevin's "that's why the good Lord gave you HTTPS." Mob
exposes EPMD/dist control (Mob.Dist.ensure_started, port + tunnel setup) so you open
and cluster deliberately — "don't cluster with strangers." Phone-to-phone has no
stable public IP (NAT), so you need rendezvous: a server+client pair tracking IPs, or
a tunnel like Cloudflare Tunnel / Tailscale. Not the Reticulum lateral-comms research
path — this is the pragmatic answer for tonight.
Did I mention mix tasks?
You get a mix task — and you get a mix task — and you get a mix task!
~35 of them. The whole lifecycle — scaffold, run, inspect, ship — is just mix.
The Oprah bit — milk it. Then the real point on the next two slides: Mob ships a full
tooling surface, all as ordinary mix tasks, so the agentic loop and the human loop are
the same muscle memory. Don't read every task aloud; sweep the breadth.
Mix tasks: build & run
Scaffold & set up
mob.newcreate a new app
mob.installfirst-run setup
mob.doctorcheck your environment
mob.enableturn on optional features
mob.add_nifscaffold a native NIF
mob.gen.live_screenLiveView + Screen pair
mob.icongenerate app icons
mob.provisioniOS app ID + profile
The dev loop
mob.deploybuild → all devices
mob.connectIEx into running apps
mob.pushhot-push changed modules
mob.watchauto hot-push on save
mob.serverdev server :4040
mob.deviceslist connected devices
mob.emulatorsstart / stop emulators
mob.routesvalidate navigation
Left column = zero to first run. Right = the inner loop you live in: deploy, connect
(IEx + RPC into the device BEAM), push / watch for hot code reload, server, devices,
emulators, routes (compile-time nav destination validation). All plain mix.
Mix tasks: inspect & ship
Inspect · slim · secure
mob.trace_otptrace touched code
mob.audit_otpwhich OTP libs you use
mob.snapshot_loadedwhat's loaded on device
mob.verify_stripprove the strip is safe
mob.security_scanscan deps + runtime
mob.battery_bench_*battery benchmark
mob.cacheshow / clear caches
Release & ship
mob.releasesigned .ipa / .aab
mob.release.otpcross-compile OTP
mob.release.opensslcrypto NIF archives
mob.publishupload to the store
mob.republishbump + build + upload
mob.setup.google_playautomate Play setup
mob.uninstallremove from devices
Left = the BEAM-on-device superpowers: trace/audit what OTP you actually touch,
snapshot loaded modules, verify the strip is safe, security-scan deps+runtime, battery
benchmarks, cache control. Right = the release pipeline: cross-compile OTP, build the
signed artifact, publish/republish to the stores, automate Play setup. A couple of
sub-tasks omitted for space (release.tarball/.publish, security_scan.log, watch_stop).
Elixir, all the way to the phone
Same patterns. Same language.
Server to screen.
Part I (the why): genericjam.com/blog/mob-intro
Close on the thesis: it's Elixir end to end. Scan to start building. Point at the blog
for the motivation/architecture half. Then take questions / roll into more demo if time.
The surface — & the roadmap
Reference: React Native core + Expo · ✅ solid 🟡 partial ❌ plugin candidate ⛔ out of scope
✅ Solid today
UI components Box…WebView, Camera, GPU
Device & system info
Storage KV, files, SQLite, photos
Camera · audio · TTS
Notifications local + push
Clipboard · share · alerts · perms
🟡 Partial
Gestures beyond tap / swipe
Sensors accel + gyro
Location background limited
ML / Vision QR + TFLite
Accessibility
❌ Roadmap — plugin territory
Maps
BLE · NFC
Auth & payments sign-in, IAP
Speech-to-text
Bottom sheets · drawers · pickers
Background fetch / jobs
⛔ Out of scope
Widgets · Live Activities
Watch app · App Clips
separate build targets
Honest disclosure, not a parity promise — most gaps are plugin-shaped.
The roadmap closer, straight from guides/mobile_surface_matrix.md (reference surface =
React Native core + Expo). This is a category-level condense of a ~300-line matrix — do
not claim it's exhaustive; it's the shape. The honest framing is the doc's own: a
snapshot of reality, not a parity promise, and the ❌ rows are mostly plugin-shaped
(MOB_PLUGINS.md). Leave the audience with: broad coverage today, an honest map of the
gaps, and a community plugin path to fill them. (If you'd rather end on the QR, swap
this with the previous slide.)