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.

In DSL style, the screen block compiles to the same maps at compile time:

dala do
  screen name: :home do
    column padding: :space_md, background: :background do
      text "@title", text_size: :xl, text_color: :on_background
      button "Save", on_tap: :save
    end
  end
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 atoms matching the on_tap handler you specified in the screen block:

# In the screen block:
button "Save", on_tap: :save

# In handle_event:
def handle_event(:save, _params, socket) do
  save_data(socket.assigns)
  {:noreply, socket}
end

Text field changes arrive as {:change, tag, value}:

# In the screen block:
text_field text: @name, on_change: :name_changed

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

The default implementation (from use Dala.Spark.Dsl) is a no-op that returns the socket unchanged.

handle_event/3

@callback handle_event(event :: term(), params :: map(), socket :: Dala.Socket.t()) ::
  {:noreply, Dala.Socket.t()} | {:reply, map(), socket :: Dala.Socket.t()}

The primary callback for responding to user interaction in DSL-style screens. All UI events — taps, text changes, list selections — arrive here.

# Tap event:
def handle_event(:save, _params, socket) do
  save_data(socket.assigns)
  {:noreply, socket}
end

# Change event with value:
def handle_event({:change, :name_changed, value}, _params, socket) do
  {:noreply, Dala.Socket.assign(socket, :name, value)}
end

Can also be dispatched programmatically via Dala.Screen.Screen.dispatch/3 for tests:

Dala.Screen.Screen.dispatch(pid, :increment, %{})

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.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_event vs handle_info

handle_event/3 — Primary callback for DSL screens

All UI events (taps, text changes, list selections) arrive here. This is the main event callback for DSL-style screens.

def handle_event(:save, _params, socket) do
  save_data(socket.assigns)
  {:noreply, socket}
end

handle_info/2 — Device API results and raw messages

Device API results (camera, location, etc.) and other raw messages still arrive via handle_info/2:

def handle_info({:camera, :photo, %{path: path}}, socket) do
  {:noreply, Dala.Socket.assign(socket, :photo_path, path)}
end

Rule of thumb: Use handle_event/3 for UI events. Use handle_info/2 for device API results and raw process messages.

Lifecycle flow

start_root/3 or start_link/3
        
        
   mount/3  
                                                          
                                                          
   render/1   NIF set_root / set_view                    
                                                          
         user taps button  handle_event/3   render/1
                                                          
         text field change  handle_event/3   render/1
                                                          
         device API result  handle_info/2   render/1
                                                          
         send(pid, msg)   handle_info/2   render/1
                                                          
         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:

dala do
  screen name: :home do
    column padding_top: @safe_area.top, padding_bottom: @safe_area.bottom do
      text "Content"
    end
  end
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.