A Mob screen is a GenServer wrapped by Mob.Screen. Each screen in the navigation stack is a separate, supervised process. 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. Use it for cleanup — cancel timers, release resources. The return value is ignored.

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

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 navigation stack, it pops. If the stack is empty, the app exits. You do not need to handle {:mob, :back} unless you want to override this behaviour.