BEAM on Mobile · Part II

Mob: the syntax

LiveView muscle memory, on a phone

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.

Claude likes Elixir

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 / GenServerMob
mount/3mount/3
render(assigns)render(assigns)
assign(socket, …)Mob.Socket.assign(socket, …)
handle_event/3handle_info/2
push_navigatepush_screen / pop_screen

One row is different — handle_eventhandle_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 sigil plain maps

Same tree. Pick the one that fits your hands — or your robot's.

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.

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.

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.

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.

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.
BatteryIdle BEAM = idle CPU. Message-driven, nothing polling.

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 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.

Demo

Vibe-coding a screen · and a few things already built

Elixir, all the way to the phone

Same patterns. Same language.
Server to screen.

Mob docs QR — hexdocs.pm/mob

go build one

Part I (the why): genericjam.com/blog/mob-intro