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)}
end

session 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>
  """
end

Keep 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}
end

Text 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)}
end

Device 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}
end

Navigation 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})}
end

The 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)}
end

The 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>
  """
end

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