Reusable Fragments

After reading this guide, you will know:


Context

You’ve learned to decompose your app into fragments and route messages between them. Now you want to build fragments you can reuse — across your app, across projects.

Problem

App-specific fragments are tightly coupled. A UserList fragment only works with users. An OrderTable only works with orders. You’re duplicating similar logic everywhere.

Solution

Build generic fragments parameterized by their data. A PaginatedTable works with any items. A Modal wraps any content. A FlashMessage displays any notification. Reuse them everywhere.


Parameterized Init

Pass data to fragments through Init:

module PaginatedTable
  TableData = Data.define(:items, :page, :page_size, :selected_indices, :total_pages)

  Init = -> (items:, page_size: 20) {
    total_pages = (items.length.to_f / page_size).ceil
    TableData.new(
      items: items,
      page: 0,
      page_size: page_size,
      selected_indices: [],
      total_pages: total_pages
    )
  }
end

Use it with any data:

users_table = PaginatedTable::Init.(items: User.all, page_size: 15)
orders_table = PaginatedTable::Init.(items: Order.recent, page_size: 25)
logs_table = PaginatedTable::Init.(items: LogEntry.today)

The fragment doesn’t know or care what the items are. It just paginates them.


Multiple Model Instances

A fragment is not a class. It is a namespace for three callables (Init, View, and Update) and any number of model classes. In Object-Oriented Programming, you would expect two instances of PaginatedTable. MVU, by contrast, has its roots in functional prgoramming. When you have many PaginatedTable fragments on screen at once, you have:

You have multiple model data values, each generated by calling the same Init, once per use of PaginatedTable. These are stored on the parent fragment’s model. Each is processed by the same Update and View.

module AdminPanel
  include Rooibos::Router

  Model = Data.define(:users, :orders, :active_table)

  route :users, to: PaginatedTable
  route :orders, to: PaginatedTable

  delegate_unhandled_to: -> (model) { model.active_table }

  action -> { model.with(active_table: :users) }, key: :ctrl_u
  action -> { model.with(active_table: :orders) }, key: :ctrl_o

  Init = -> {
    Model.new(
      users: PaginatedTable::Init.(items: fetch_users),
      orders: PaginatedTable::Init.(items: fetch_orders),
      active_table: :users
    )
  }

  View = -> (model, tui) {
    active = model.public_send(model.active_table)
    PaginatedTable::View.call(active, tui)
  }
end

Each model value (users, orders) is independent data. The same PaginatedTable::Update and PaginatedTable::View functions process whichever model the Router passes them.


Events Bubbling Up

Sometimes a child needs to tell its parent “something happened.” Use Command.emit:

module PaginatedTable
  Update = -> (message, model) {
    case message
    in { type: :routed, envelope: :confirm_selection }
      selected = model.items.values_at(*model.selected_indices)
      [model, Command.emit(:selection_confirmed, selected)]

    in { type: :routed, envelope: :row_activated }
      item = model.items[model.selected_indices.first]
      [model, Command.emit(:row_activated, item)]

    # ... other handlers
    end
  }
end

The parent receives the emitted message:

module AdminPanel
  Update = -> (message, model) {
    case message
    in { type: :emitted, envelope: :row_activated, payload: user }
      # User table: open profile
      [model, open_user_profile(user)]

    in { type: :emitted, envelope: :row_activated, payload: order }
      # Order table: open details
      [model, open_order_details(order)]

    in { type: :emitted, envelope: :selection_confirmed, payload: items }
      # Bulk action on selected items
      [model, Command.system("process #{items.map(&:id).join(',')}")]

    else
      AdminPanel.from_router.call(message, model)
    end
  }
end

The child says “this happened.” The parent decides what to do. This is the OutMsg pattern — child outputs a message, parent interprets it.


Resize Handling

Reusable fragments should handle terminal resize:

module PaginatedTable
  Update = -> (message, model) {
    case message
    in { type: :resize, height: }
      # Recalculate page size based on available height
      new_page_size = height - 4  # Account for header/footer
      new_total = (model.items.length.to_f / new_page_size).ceil
      model.with(page_size: new_page_size, total_pages: new_total)

    # ... other handlers
    end
  }
end

The parent broadcasts resize to all children:

module App
  include Rooibos::Router

  broadcast :resize
end

Every fragment adapts to the new terminal size.


Building a Fragment Library

Common reusable fragments:

PaginatedTable

module PaginatedTable
  # TableData: items, page, page_size, selected_indices
  # Actions: next_page, prev_page, toggle_select, select_all
  # Emits: row_activated, selection_confirmed
end
module Modal
  # Model: visible, title, content_fragment
  # Actions: open, close, confirm, cancel
  # Emits: confirmed, cancelled
end

FlashMessage

module FlashMessage
  # MessageQueue: messages (queue of {type, text, expires_at})
  # Actions: dismiss
  # Handles: tick (to auto-expire)
end

ConfirmDialog

module ConfirmDialog
  # Model: visible, prompt, confirm_text, cancel_text
  # Actions: confirm, cancel
  # Emits: confirmed, cancelled
end

TextInput

module TextInput
  # Model: value, cursor_position, placeholder
  # Actions: type, backspace, move_cursor, submit
  # Emits: submitted, changed
end

Each fragment is independent, testable, and reusable across any app.


Testing Reusable Fragments

Test fragments in isolation:

class TestPaginatedTable < Minitest::Test
  def test_next_page_advances
    model = PaginatedTable::Init.(items: (1..100).to_a, page_size: 10)
    message = Message::Routed.new(envelope: :next_page, event: nil)

    new_model, _cmd = PaginatedTable::Update.call(message, model)

    assert_equal 1, new_model.page
  end

  def test_confirm_emits_selected_items
    model = PaginatedTable::Init.(items: %w[a b c])
    model = model.with(selected_indices: [0, 2])
    message = Message::Routed.new(envelope: :confirm_selection, event: nil)

    _model, cmd = PaginatedTable::Update.call(message, model)

    assert_equal :selection_confirmed, cmd.envelope
    assert_equal %w[a c], cmd.payload
  end
end

No parent, no routing, no terminal. Just the fragment logic.


Summary

Reusable fragments let you build a component library:

Start building: PaginatedTable, Modal, FlashMessage, TextInput. Then reuse them everywhere.


Related: Fractal Architecture | Message Routing | Commands


Previous: Message Routing | Next: Ractor Safety