Mob

Mob Logo

BEAM-on-device mobile framework for Elixir. OTP runs inside your iOS and Android apps — embedded directly in the app bundle, no server required. Screens are GenServers; the UI is rendered by Compose and SwiftUI via a thin NIF.

Hex.pmDocs

Warning

Status: Early development. Android emulator and iOS simulator confirmed working. Not yet ready for production use.

What it is

flowchart TD
A["Your Elixir app<br/>(GenServers, OTP supervision, pattern matching, pipes)"]
B["Mob.Screen<br/>(GenServer — your logic lives here)"]
C["Mob.Renderer<br/>(component tree → JSON → NIF call)"]
D1["Compose (Android)<br/>native rendering, gestures"]
D2["SwiftUI (iOS)<br/>native rendering, gestures"]
A --> B --> C
C --> D1
C --> D2

You write Elixir. The native layer handles rendering. The BEAM node runs on the device — connect your dev machine to the running app over Erlang distribution, inspect state, and hot-push new bytecode without a restart.

Installation

Add to mix.exs:

def deps do
[{:mob, "~> 0.5"}]
end

The mob_new package (separate) provides project generation, deployment tooling, and will import mob_dev which is a live dashboard. Install it as a Mix archive:

mix archive.install hex mob_new

A screen

defmodule MyApp.CounterScreen do
use Mob.Screen
def mount(_params, _session, socket) do
{:ok, Mob.Socket.assign(socket, :count, 0)}
end
def render(assigns) do
%{
type: :column,
props: %{padding: :space_md, gap: :space_md, background: :background},
children: [
%{type: :text, props: %{text: "Count: #{assigns.count}", text_size: :xl, text_color: :on_background}, children: []},
%{type: :button, props: %{text: "Increment", on_tap: {self(), :increment}}, children: []}
]
}
end
def handle_event("tap", %{"tag" => "increment"}, socket) do
{:noreply, Mob.Socket.assign(socket, :count, socket.assigns.count + 1)}
end
end

App entry point

defmodule MyApp do
use Mob.App, theme: Mob.Theme.Obsidian
def navigation(_platform) do
stack(:home, root: MyApp.CounterScreen)
end
def on_start do
Mob.Screen.start_root(MyApp.CounterScreen)
Mob.Dist.ensure_started(node: :"my_app@127.0.0.1", cookie: :secret)
end
end

Navigation

# Push a new screen
Mob.Socket.push_screen(socket, MyApp.DetailScreen, %{id: 42})
# Pop back
Mob.Socket.pop_screen(socket)
# Tab bar layout
tab_bar([
stack(:home, root: MyApp.HomeScreen, title: "Home"),
stack(:profile, root: MyApp.ProfileScreen, title: "Profile")
])

Theming

# Named theme
use Mob.App, theme: Mob.Theme.Obsidian
# Override individual tokens
use Mob.App, theme: {Mob.Theme.Obsidian, primary: :rose_500}
# From scratch
use Mob.App, theme: [primary: :emerald_500, background: :gray_950]
# Runtime switch (accessibility, user preference)
Mob.Theme.set(Mob.Theme.Citrus)

Built-in themes: Mob.Theme.Obsidian (dark violet), Mob.Theme.Citrus (warm charcoal + lime), Mob.Theme.Birch (warm parchment).

Device APIs

All async — call the function, handle the result in handle_info/2:

# Haptic feedback (synchronous — no handle_info needed)
Mob.Haptic.trigger(socket, :success)
# Camera
Mob.Camera.capture_photo(socket)
def handle_info({:camera, :photo, %{path: path}}, socket), do: ...
# Location
Mob.Location.start(socket, accuracy: :high)
def handle_info({:location, %{lat: lat, lon: lon}}, socket), do: ...
# Push notifications
Mob.Notify.register_push(socket)
def handle_info({:push_token, :ios, token}, socket), do: ...

Also: Mob.Clipboard, Mob.Share, Mob.Photos, Mob.Files, Mob.Audio, Mob.Motion, Mob.Biometric, Mob.Scanner, Mob.Permissions.

For a full audit of what mob covers vs. what's missing vs. what's out of scope (compared against React Native + Expo SDK capabilities), see the Mobile Surface Matrix. Set realistic expectations before starting an app; spot plugin candidates if you want to fill a gap.

Background execution

The BEAM runs on the device, but it does not keep running once the app is backgrounded. iOS suspends the whole process within seconds — schedulers stop, GenServers freeze, and any distribution / socket connections drop. Android does the same unless you run a foreground service (the persistent-notification kind). This is an OS constraint every mobile runtime lives with, not a Mob limitation.

So a server can't push straight into a long-lived GenServer — the OS has to wake you first, via APNs (iOS) or FCM (Android). The shape is:

# Register for a push token; your server stores it and sends through APNs/FCM.
# See the mob_push package for the server side.
Mob.Notify.register_push(socket)
def handle_info({:push_token, :ios, token}, socket), do: ...
# React to the OS suspending / resuming the app. A push wakes the app, the BEAM
# resumes, your handler runs in a short window, then the OS suspends you again.
Mob.Device.subscribe([:app])
def handle_info({:mob_device, :did_enter_background}, socket), do: ...
def handle_info({:mob_device, :will_enter_foreground}, socket), do: ...

Mob.Device.foreground?/0 reports the current state. For true always-on (e.g. a live connection held open), an Android foreground service is the only path; iOS will not allow it. Otherwise treat the device as push-driven: server → APNs/FCM → OS wakes app → BEAM handles the event → BEAM suspends again.

What's in the box

The pre-built OTP runtime that ships with each app includes:

Native APIs surfaced via Mob.* modules (above) cover camera, location, audio, files, biometrics, push, clipboard, share, scanner, motion sensors, permissions.

The OTP runtime tarball is ~80 MB compressed; sliced per-arch by App Thinning (iOS) and App Bundle (Android) so each user only downloads ~25 MB of native runtime, on top of the BEAM bytecode for your app.

Live development

mix mob.connect # tunnel + connect IEx to running device
nl(MyApp.SomeScreen) # hot-push new bytecode, no restart
# In IEx:
Mob.Test.screen(:"my_app_ios@127.0.0.1") #=> MyApp.CounterScreen
Mob.Test.assigns(:"my_app_ios@127.0.0.1") #=> %{count: 3, ...}
Mob.Test.tap(:"my_app_ios@127.0.0.1", :increment)

Testing

test "increments count" do
{:ok, pid} = Mob.Screen.start_link(MyApp.CounterScreen, %{})
:ok = Mob.Screen.dispatch(pid, "tap", %{"tag" => "increment"})
assert Mob.Screen.get_socket(pid).assigns.count == 1
end
PackagePurpose
mob_devDev tooling: mix mob.new, mix mob.deploy, mix mob.connect, live dashboard
mob_pushServer-side push notifications (APNs + FCM)

Documentation

Full documentation at hexdocs.pm/mob, including:

License

MIT