A Mob screen is a GenServer — a Mob.Screen.Server process holding your module's socket. Each live screen in the navigation stack is a separate process, and Mob.Router owns them: it starts them, stops them, and restarts one that crashes. It is not an OTP Supervisor, because only the router knows where in the navigation a crashed screen sat; a restarted screen re-mounts and loses its assigns. Understanding the lifecycle means understanding when each callback fires and what you can do in it.
Callbacks
mount/3
@callback mount(params :: map(), session :: map(), socket :: Mob.Socket.t()) ::
{:ok, Mob.Socket.t()} | {:error, term()}Called once when the screen process starts. Initialize your assigns here.
params comes from the navigation call that opened this screen:
# Screen A navigates to Screen B with params:
Mob.Socket.push_screen(socket, MyApp.DetailScreen, %{id: 42})
# Screen B receives them in mount:
def mount(%{id: id}, _session, socket) do
item = fetch_item(id)
{:ok, Mob.Socket.assign(socket, :item, item)}
endsession is reserved for future use; pass it through.
If mount/3 returns {:error, reason}, the GenServer stops with that reason.
render/1
@callback render(assigns :: map()) :: map()Returns the component tree as a plain Elixir map. Called after every callback that returns a modified socket. The renderer serialises the tree, resolves tokens, and calls the NIF — Compose or SwiftUI diffs and updates the display.
The ~MOB sigil (imported automatically by use Mob.Screen) compiles to the same maps at compile time:
def render(assigns) do
~MOB"""
<Column padding={:space_md} background={:background}>
<Text text={assigns.title} text_size={:xl} text_color={:on_background} />
<Button text="Save" on_tap={{self(), :save}} />
</Column>
"""
endKeep render/1 pure. No side effects, no process sends. It may be called more than once for a given state.
handle_info/2
@callback handle_info(message :: term(), socket :: Mob.Socket.t()) ::
{:noreply, Mob.Socket.t()}The primary callback for responding to user interaction and async results. All UI events — taps, text changes, list selections — arrive here as messages sent by the NIF directly to the screen process.
Tap events are delivered as {:tap, tag} where tag is the second element of the on_tap: {pid, tag} tuple you specified in render/1:
# In render:
~MOB(<Button text="Save" on_tap={tap} />) # where tap = {self(), :save}
# In handle_info:
def handle_info({:tap, :save}, socket) do
save_data(socket.assigns)
{:noreply, socket}
endText field changes arrive as {:change, tag, value}:
# In render — pre-compute the handler tuple:
name_change = {self(), :name_changed}
~MOB(<TextField value={assigns.name} on_change={name_change} />)
# In handle_info:
def handle_info({:change, :name_changed, value}, socket) do
{:noreply, Mob.Socket.assign(socket, :name, value)}
endDevice API results also arrive here — see Device Capabilities:
def handle_info({:camera, :photo, %{path: path}}, socket) do
{:noreply, Mob.Socket.assign(socket, :photo_path, path)}
end
def handle_info({:camera, :cancelled}, socket) do
{:noreply, socket}
endNavigation is triggered by returning a modified socket:
def handle_info({:tap, :open_detail}, socket) do
{:noreply, Mob.Socket.push_screen(socket, MyApp.DetailScreen, %{id: socket.assigns.id})}
endThe default implementation (from use Mob.Screen) is a no-op that returns the socket unchanged. Always add a catch-all clause to handle messages you don't care about:
def handle_info(_message, socket), do: {:noreply, socket}handle_event/3
@callback handle_event(event :: String.t(), params :: map(), socket :: Mob.Socket.t()) ::
{:noreply, Mob.Socket.t()} | {:reply, map(), socket :: Mob.Socket.t()}Dispatched programmatically via Mob.Screen.dispatch/3 — used in tests to send string-keyed events to a screen process. Not called for normal UI interactions (those go through handle_info/2).
# In tests:
Mob.Screen.dispatch(pid, "increment", %{})
Mob.Screen.dispatch(pid, "tap", %{"tag" => "save"})
# In the screen:
def handle_event("increment", _params, socket) do
{:noreply, Mob.Socket.assign(socket, :count, socket.assigns.count + 1)}
endThe default implementation (from use Mob.Screen) raises for any unhandled event, so only define clauses for events you explicitly dispatch.
terminate/2
@callback terminate(reason :: term(), socket :: Mob.Socket.t()) :: term()Called when the screen process is about to stop — when the screen is popped
from its stack, or when navigation shuts down and takes its linked screens with
it. Use it for cleanup — cancel timers, release resources. The return value is
ignored. Persisted screens (use Mob.Screen, vsn: N or persist: true) also
dump their state here, so their assigns survive an app exit.
Only the screen leaving the stack is stopped: on a pop, the screens still below it in the history stay alive, which is what lets pop restore the previous screen's state without re-mounting it.
The default is a no-op. Most screens don't need to implement this.
Lifecycle flow
start_root/2 or push_screen/2
│
▼
mount/3 ──────────────────────────────────────────────┐
│ │
▼ │
render/1 ─ NIF set_root / set_view │
│ │
├── user taps button ────► handle_info/2 ──► render/1
│ │
├── text field change ───► handle_info/2 ──► render/1
│ │
├── device API result ───► handle_info/2 ──► render/1
│ │
├── send(pid, msg) ──────► handle_info/2 ──► render/1
│ │
└── screen popped from stack ─► terminate/2 ──────┘The socket
All callbacks receive and return a Mob.Socket.t(). Think of it as a struct carrying your screen's state:
socket.assigns— your data (:count,:user,:items, etc.)socket.__mob__— internal framework state; do not touch directly
Use Mob.Socket.assign/2,3 to update assigns. Use the navigation functions (push_screen, pop_screen, etc.) to queue navigation actions. Both return a new socket; they never mutate in place.
socket
|> Mob.Socket.assign(:loading, false)
|> Mob.Socket.assign(:items, items)
|> Mob.Socket.push_screen(MyApp.DetailScreen, %{id: id})Safe area
The socket always has a :safe_area assign populated by the framework:
assigns.safe_area
#=> %{top: 62.0, right: 0.0, bottom: 34.0, left: 0.0}Use it to avoid content being obscured by the notch, home indicator, or status bar:
def render(assigns) do
sa = assigns.safe_area
top = {self(), :top}
bottom = {self(), :bottom}
~MOB"""
<Column padding_top={sa.top} padding_bottom={sa.bottom}>
...
</Column>
"""
endCrashes and restarts
A crash in a screen callback kills that screen's process only. The router
observes the exit, restarts the screen in the same navigation slot with its
original mount params, and repaints. The restarted screen runs mount/3 again
and loses its assigns — persisted screens get their dumped state back through
load_state/2 — and the restart is logged at error, because a form clearing
itself is visible to the user. Restarts are capped (5 in 10 seconds per screen)
so a screen that crashes on every render cannot spin.
Because each screen owns its own process, self() in a callback is that
screen's pid. A task or timer started by a screen delivers to that screen —
even if it's parked under an inactive tab — and if the screen has been popped
and stopped, the BEAM drops the message rather than delivering it to whatever
screen is now current.
System back
The framework handles the system back gesture (Android hardware back / swipe, iOS edge-pan) automatically. If there is a screen behind the current one in the active stack, it pops. At the root of a secondary stack in a tab_bar/1/drawer/1 layout, back switches to the first stack (the Android convention — see Navigation). At the root of the first (or only) stack, the app exits. You do not need to handle {:mob, :back} unless you want to override this behaviour.