A Dala screen is a GenServer wrapped by Dala.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 :: Dala.Socket.t()) ::
  {:ok, Dala.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:
Dala.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, Dala.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 ~dala sigil (imported automatically by use Dala.Screen) compiles to the same maps at compile time:

def render(assigns) do
  ~dala"""
  <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 :: Dala.Socket.t()) ::
  {:noreply, Dala.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:
~dala(<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}
~dala(<TextField value={assigns.name} on_change={name_change} />)

# In handle_info:
def handle_info({:change, :name_changed, value}, socket) do
  {:noreply, Dala.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, Dala.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, Dala.Socket.push_screen(socket, MyApp.DetailScreen, %{id: socket.assigns.id})}
end

The default implementation (from use Dala.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 :: Dala.Socket.t()) ::
  {:noreply, Dala.Socket.t()} | {:reply, map(), socket :: Dala.Socket.t()}

Dispatched programmatically via Dala.Screen.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:
Dala.Screen.Screen.dispatch(pid, "increment", %{})
Dala.Screen.Screen.dispatch(pid, "tap", %{"tag" => "save"})

# In the screen:
def handle_event("increment", _params, socket) do
  {:noreply, Dala.Socket.assign(socket, :count, socket.assigns.count + 1)}
end

The default implementation (from use Dala.Screen) raises for any unhandled event, so only define clauses for events you explicitly dispatch.

terminate/2

@callback terminate(reason :: term(), socket :: Dala.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.

Starting screens

Dala.Screen.start_root/3

Called by Dala.App to start the root screen of the navigation stack. This is the entry point for your app's UI. If it returns {:error, reason}, the app crashes loudly (see AGENTS.md rule #2).

{:ok, pid} = Dala.Screen.start_root(MyApp.HomeScreen, %{}, nil)

Starts a screen in :no_render mode for testing. Skips NIF calls but runs all Elixir callbacks. Used in ExUnit tests:

{:ok, pid} = Dala.Screen.start_link(MyApp.CounterScreen, %{})

Equivalent to start_link/3 with nil as the third argument. Convenience for tests.

Event handling: handle_info vs handle_event

handle_info/2 — Primary callback

All UI events (taps, text changes, device API results) arrive here as messages sent to the screen process. This is the main event callback for user interactions.

def handle_info({:tap, :save}, socket) do
  save_data(socket.assigns)
  {:noreply, socket}
end

handle_event/3 — Programmatic dispatch

Called only via Dala.Screen.Screen.dispatch/3 — used in tests to send string-keyed events. Not invoked for normal UI interactions (those go through handle_info/2).

# In tests:
Dala.Screen.Screen.dispatch(pid, "tap", %{"tag" => "save"})

# In the screen:
def handle_event("tap", %{"tag" => tag}, socket) do
  {:noreply, socket}
end

Rule of thumb: Use handle_info/2 for UI events. Use handle_event/3 only for test dispatch.

Lifecycle flow

start_root/3 or start_link/3
        
        
   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
                                                          
         dispatch event  handle_event/3   render/1 (test only)
                                                          
         screen popped from stack  terminate/2  

terminate/2 — Cleanup callback

Called when the screen process stops (popped from stack, app exit, or error). Use for resource cleanup:

def terminate(_reason, socket) do
  # Stop camera preview, cancel timers, release resources
  if preview = socket.assigns[:camera_preview] do
    Dala.Native.stop_camera_preview(preview)
  end
  :ok
end

The default is a no-op. Most screens don't need it.

The socket

All callbacks receive and return a Dala.Socket.t(). Think of it as a struct carrying your screen's state:

  • socket.assigns — your data (:count, :user, :items, etc.)
  • socket.__dala__ — internal framework state; do not touch directly

Use Dala.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
|> Dala.Socket.assign(:loading, false)
|> Dala.Socket.assign(:items, items)
|> Dala.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}
  ~dala"""
  <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 {:dala, :back} unless you want to override this behaviour.