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)}
endsession 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>
"""
endKeep 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}
endText 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)}
endDevice 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}
endNavigation 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})}
endThe 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.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.dispatch(pid, "increment", %{})
Dala.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)}
endThe 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)Dala.Screen.start_link/3
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, %{})Dala.Screen.start_link/2 (with :test option)
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}
endhandle_event/3 — Programmatic dispatch
Called only via Dala.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.dispatch(pid, "tap", %{"tag" => "save"})
# In the screen:
def handle_event("tap", %{"tag" => tag}, socket) do
{:noreply, socket}
endRule 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
endThe 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>
"""
endSystem 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.