Fractal Architecture

After reading this guide, you will know:


Context

Your app starts simple. One Model, one Update, one View. A file browser fits in a single module.

Then features grow. The file browser gains a preview pane. A status bar. Multiple tabs. A command palette.

Problem

Big apps become unmanageable. A single Update function handles dozens of message types. The Model grows to twenty fields. Testing requires mocking the entire world.

Worse: you can’t reuse code. The preview pane logic is tangled with the tab logic. Extracting it would mean untangling a web of dependencies.

Solution

Rooibos supports fractal architecture. Each piece of your UI can be its own fragment with:

The parent composes these together. It’s turtles all the way down — or all the way up.


A Fragment Is Just a Module

A fragment is any module with Model, Init, Update, and View:

module PreviewPane
  PaneContent = Data.define(:content, :scroll_position)

  Init = -> {
    PaneContent.new(content: "", scroll_position: 0)
  }

  Update = -> (message, model) {
    case message
    in { type: :routed, envelope: :scroll_down }
      model.with(scroll_position: model.scroll_position + 1)
    in { type: :routed, envelope: :scroll_up }
      model.with(scroll_position: [0, model.scroll_position - 1].max)
    else
      model
    end
  }

  View = -> (model, tui) {
    tui.paragraph(text: model.content)
  }
end

This fragment knows nothing about its parent. It receives messages, updates its model, renders its view. That’s it.


Model Composition

The parent’s Model holds child models as fields:

module FileBrowser
  # Parent model contains child models
  Model = Data.define(:files, :preview, :status_bar)

  Init = -> {
    files = FileList::Init.()
    preview = PreviewPane::Init.()
    status = StatusBar::Init.()

    Model.new(files:, preview:, status_bar: status)
  }
end

Each child model is independent. The parent just holds references.


The Router Handles Dispatch

Instead of writing dispatch logic manually, use the Router DSL:

module FileBrowser
  include Rooibos::Router

  Model = Data.define(:files, :preview, :status_bar)

  route :files, to: FileList
  route :preview, to: PreviewPane
  route :status_bar, to: StatusBar

  action move_down: FileList, keys: %i[down j]
  action move_up: FileList, keys: %i[up k]
  action scroll_down: PreviewPane, key: :ctrl_j
  action scroll_up: PreviewPane, key: :ctrl_k
  action -> { Command.exit }, key: :q

  Update = from_router

  Init = -> {
    Model.new(
      files: FileList::Init.(),
      preview: PreviewPane::Init.(),
      status_bar: StatusBar::Init.()
    )
  }

  View = -> (model, tui) {
    tui.layout(direction: :horizontal, children: [
      FileList::View.call(model.files, tui),
      PreviewPane::View.call(model.preview, tui),
    ])
  }
end

The Router: 1. Maps keys to semantic actions 2. Dispatches actions to the correct child fragment 3. Routes command responses back to originating children 4. Merges child models back into the parent

See Message Routing for details.


Widget Slots

Sometimes an outer fragment needs to inject its own widgets between parts of an inner fragment’s view. A panel layout renders the file list and preview side by side, but the outer fragment wants a path bar above them.

In this case, the outer fragment pre-renders a widget and passes it as an argument to the inner fragment’s View:

module FileBrowser
  View = -> (model, tui) {
    # FileBrowser renders the path bar
    path_bar = PathBar::View[model.path_bar, tui]

    # FileBrowser passes it to PanelLayout as a slot
    panels = PanelLayout::View[model.panels, tui, path_bar]

    tui.layout(
      direction: :vertical,
      constraints: [tui.constraint_fill(1), tui.constraint_length(1)],
      children: [panels, StatusBar::View[model.status_bar, tui]]
    )
  }
end

The inner fragment receives the pre-rendered widget and places it within its own layout:

module PanelLayout
  View = -> (model, tui, header_slot) {
    files = FileList::View[model.files, tui]
    preview = PreviewPane::View[model.preview, tui]
    panes = tui.layout(
      direction: :horizontal,
      constraints: [tui.constraint_percentage(40), tui.constraint_fill(1)],
      children: [files, preview]
    )
    tui.layout(
      direction: :vertical,
      constraints: [tui.constraint_length(1), tui.constraint_fill(1)],
      children: [header_slot, panes]
    )
  }
end

PanelLayout doesn’t know what header_slot is. It could be a path bar, a breadcrumb trail, or an empty widget. The outer fragment decides; the inner fragment just places it.

If you know Ember, this is {{yield}}. If you know React, it’s the children prop. If you know Rails, it’s content_for/yield :section in layouts. In BubbleTea, the outer model’s View() always controls composition — there’s no slot mechanism, so this pattern gives Rooibos more flexibility. The outer fragment owns the data and rendering; the inner fragment owns the layout position.

Tip: Use widget slots when an inner fragment controls the layout but a sibling fragment’s view belongs inside it. Keep the slot count small (one or two at most).


Manual Dispatch (Without Router)

For full control, write the dispatch logic yourself:

Update = -> (message, model) {
  case message
  # Dispatch to file list
  in { type: :routed, envelope: :move_down | :move_up }
    new_files, cmd = FileList::Update.call(message, model.files)
    [model.with(files: new_files), cmd]

  # Dispatch to preview
  in { type: :routed, envelope: :scroll_down | :scroll_up }
    new_preview, cmd = PreviewPane::Update.call(message, model.preview)
    [model.with(preview: new_preview), cmd]

  # Handle parent-level messages
  in { type: :key } if message.q?
    Command.exit

  else
    model
  end
}

Each child Update receives only its model slice. The parent merges the new child model back using model.with(...).

Tip: Start with the Router. Only drop to manual dispatch if you need control the Router doesn’t provide.


When to Extract a Fragment

Extract a fragment when:

Keep things inline when:

Tip: Start inline, extract when you feel pain. Don’t over-engineer upfront.


Summary

Fractal architecture means:

This scales to any depth. Root → Tabs → Panels → Panes → Widgets. Each level only knows about its direct children.


Related: Message Routing | Command Composition | The Elm Architecture


Previous: Command Composition | Next: Message Routing