Mob supports three navigation patterns: stack, tab bar, and drawer. These are declared in your app module and managed through Mob.Socket functions in your screen callbacks.

Declaring navigation structure

Navigation is declared in your Mob.App module's navigation/1 callback. The function receives the current platform atom (:ios or :android) and returns a navigation map:

defmodule MyApp do
  use Mob.App

  def navigation(_platform) do
    stack(:home, root: MyApp.HomeScreen)
  end
end

Use the helper functions stack/2, tab_bar/1, and drawer/1 (imported from Mob.App):

Stack

A linear push/pop navigation hierarchy.

stack(:home, root: MyApp.HomeScreen)
stack(:settings, root: MyApp.SettingsScreen, title: "Settings")

The first argument is the stack's name atom — it becomes a valid navigation destination. The :root option is the screen module mounted when the stack is first entered.

Tab bar

Multiple named stacks, each with its own independent navigation history:

tab_bar([
  stack(:home,    root: MyApp.HomeScreen,    title: "Home"),
  stack(:search,  root: MyApp.SearchScreen,  title: "Search"),
  stack(:profile, root: MyApp.ProfileScreen, title: "Profile")
])

Each declared stack keeps its own history and its own live screens, so switching away from a tab and back restores exactly where you were — see Tabs and multi-stack state below.

No automatic tab chrome yet. The runtime fully backs tab_bar/1 — per-stack state is kept and switch_tab/2 works — but declaring it does not yet draw a bottom tab bar. Switching is programmatic via Mob.Socket.switch_tab/2 for now; to draw chrome yourself, render the :tab_bar widget in your screens and wire its on_tab_select to switch_tab/2 (see Styling → Tab bar props).

Drawer

The same shape with drawer semantics — multiple named stacks:

drawer([
  stack(:home,     root: MyApp.HomeScreen,     title: "Home"),
  stack(:settings, root: MyApp.SettingsScreen, title: "Settings")
])

The multi-stack state rules below apply identically. As with tab_bar/1, no drawer chrome is drawn yet; switching is programmatic.

Platform-specific navigation

Pass different structures per platform:

def navigation(:ios),     do: tab_bar([...])
def navigation(:android), do: drawer([...])
def navigation(_),        do: stack(:home, root: MyApp.HomeScreen)

Navigation is queued by returning a modified socket from any callback. The framework processes the nav action after the callback returns, mounts the new screen, and triggers a push/pop animation.

push_screen/2,3

Navigate to a new screen, pushing it onto the stack:

def handle_info({:tap, :open_detail}, socket) do
  {:noreply, Mob.Socket.push_screen(socket, MyApp.DetailScreen, %{id: socket.assigns.id})}
end

The second argument is either a module or a registered stack name atom:

# By module:
Mob.Socket.push_screen(socket, MyApp.DetailScreen, %{id: 42})

# By registered name (from navigation/1):
Mob.Socket.push_screen(socket, :detail, %{id: 42})

The params map is passed to the destination screen's mount/3.

pop_screen/1

Return to the previous screen:

def handle_info({:tap, :back}, socket) do
  {:noreply, Mob.Socket.pop_screen(socket)}
end

The system back gesture (Android hardware back / iOS edge-pan) calls this automatically. You do not need to handle it manually in most cases.

pop_to/2

Pop back to a specific screen in the history:

# Pop back to the Home screen wherever it is in the stack
Mob.Socket.pop_to(socket, MyApp.HomeScreen)
Mob.Socket.pop_to(socket, :home)  # by name

No-op if the screen is not in the history.

pop_to_root/1

Pop all screens back to the root of the current stack:

Mob.Socket.pop_to_root(socket)

reset_to/2,3,4

Replace the current navigation stack with a new root. No back button, no history:

# Replace only the active stack
def handle_info({:tap, :logged_in}, socket) do
  {:noreply, Mob.Socket.reset_to(socket, MyApp.HomeScreen)}
end

In an app with tabs, authentication transitions normally need to discard every stack so no screen or state from the prior session remains parked:

Mob.Socket.reset_to(socket, MyApp.LoginScreen, %{}, scope: :all)

The default scope is :stack, which preserves the other tab stacks. :all stops every screen in every stack and starts fresh. If the destination is a declared root, its stack becomes active; login and other undeclared roots use the private orphan stack. If the destination cannot mount, the existing stacks remain intact. An all-stack reset is also a persistence boundary: it clears all stored screen snapshots, suppresses final writes from the discarded screens, and does not restore a snapshot into the replacement screen.

The reset always replaces the stack. Its animation can be overridden when the same stack operation represents directional movement, such as custom tabs:

Mob.Socket.reset_to(socket, MyApp.PortfolioScreen, %{}, transition: :push)

switch_tab/2,3

Switch to a named stack in a tab bar or drawer layout:

Mob.Socket.switch_tab(socket, :settings)

The first switch to a stack mounts its declared :root; later switches restore the stack exactly as you left it. Switching to the stack you are already on, or to a name no stack declares, is a no-op. By default a tab switch is an unanimated swap. Apps with ordered tabs can supply a directional transition:

Mob.Socket.switch_tab(socket, :portfolio, transition: :push)
Mob.Socket.switch_tab(socket, :home, transition: :pop)

The transition changes only the animation; each tab still retains its own screen and history stack.

When a tab root needs session or launch data, pass it on the first switch:

Mob.Socket.switch_tab(socket, :home,
  transition: :push,
  mount_params: %{session: session}
)

mount_params must be a map. They are used only when the target stack has not yet mounted. Switching back to an existing stack restores its original screen and state; later mount_params do not replace the params it mounted with.

Tabs and multi-stack state

With a tab_bar/1 or drawer/1 layout, every declared stack owns its own history and its own live screens (Mob.Nav holds this state). The rules:

  • Stacks materialize on first visit. A declared stack has no screen and has never mounted until it is first switched to — matching UITabBarController, which does not instantiate a tab's view controller until selected. From the second visit onward its state is retained for the app's lifetime.
  • Switching away parks the whole stack. The parked stack's screen processes stay alive with their state, but their renders are not committed — an inactive tab holds state without painting. Switching back restores the exact screen and history, without re-mounting.
  • Histories are independent. pop_screen/1, pop_to/2, and pop_to_root/1 operate on the active stack only; nothing can pop across a stack boundary.
  • Back at a secondary stack's root returns to the first stack. The system back gesture at the root of any declared stack other than the first switches to the first stack instead of exiting the app (the Android convention). Only back at the first stack's root exits.
  • A root outside the layout gets a private stack. If start_root/1 mounts a screen no stack declares (a splash, login, or deep-link target), it is parked under a private orphan stack when you first switch_tab/2 away. Its state is preserved, every declared root stays reachable, and the orphan is never itself a switch target.

Known gaps, tracked on the epic: parked screens miss terminate/2 and persisted-state sync (MOB-116); re-selecting the active tab is a no-op rather than popping that stack to its root (MOB-117).

The framework automatically picks the right animation based on the navigation action:

  • Push — slide in from right (iOS) / slide up (Android)
  • Pop — reverse slide
  • Reset — cross-fade (no directional animation, no back history)
  • Tab switch — none by default; switch_tab/3 can request push, pop, or reset

reset_to/4 can override the animation with transition: :push or transition: :pop, and the reset boundary with scope: :stack or scope: :all. Any other transition value raises ArgumentError — including :none, which native would treat as "not navigation" and diff the incoming tree into the outgoing screen's view identities. A navigation's animation survives coalescing: an ordinary re-render (a timer tick, a component update) queued behind a push cannot swallow the push's animation.

switch_tab/3 accepts the same validated transition values (:push, :pop, or :reset) and an optional mount_params map. Unlike reset_to/4, its legacy switch_tab/2 form deliberately uses :none, preserving the unanimated tab-swap behavior.

Passing data on pop

Mob's navigation is process-based. When you pop back to a previous screen, that screen's process is still running with its original state. To pass data back, send a message to the parent's pid.

Pass the parent pid as a param when pushing:

# In the parent screen — pass self() so the child can reply:
def handle_info({:tap, :open_detail}, socket) do
  {:noreply, Mob.Socket.push_screen(socket, MyApp.DetailScreen, %{
    id:         socket.assigns.selected_id,
    parent_pid: self()
  })}
end

# In the parent screen's handle_info:
def handle_info({:saved, item}, socket) do
  {:noreply, Mob.Socket.assign(socket, :selected_item, item)}
end
# In the detail screen's mount — capture the parent pid from params:
def mount(%{id: id, parent_pid: parent_pid}, _session, socket) do
  {:ok, Mob.Socket.assign(socket, item: fetch_item(id), parent_pid: parent_pid)}
end

# Before popping — send the result back:
def handle_info({:tap, :save}, socket) do
  send(socket.assigns.parent_pid, {:saved, socket.assigns.item})
  {:noreply, Mob.Socket.pop_screen(socket)}
end

The Mob.Nav.Registry

Named destinations (the atoms you use in stack/2) are registered in Mob.Nav.Registry when the app starts. This lets you navigate by name instead of module reference, which is useful for decoupled navigation where a screen shouldn't import its destination's module:

# Navigation declaration auto-registers :home → MyApp.HomeScreen
stack(:home, root: MyApp.HomeScreen)

# Later, anywhere:
Mob.Socket.push_screen(socket, :home)  # resolves to MyApp.HomeScreen

Route-bound params

Mob.Nav.Registry.register/3 can bind a params map to a route, letting many routes share one parameterized screen module (the data-driven pattern — e.g. a plugin registering :post_list as {MobAsh.ListScreen, %{resource: MyApp.Post}}):

Mob.Nav.Registry.register(:post_list, MobAsh.ListScreen, %{resource: MyApp.Post})
Mob.Nav.Registry.register(:user_list, MobAsh.ListScreen, %{resource: MyApp.User})

When such a route is the navigation destination, the route-bound params are merged under the caller's push_screen params (the caller's keys win on conflict) and the merged map is what arrives in the destination's mount/3.