Reusable Fragments
After reading this guide, you will know:
-
How to build parameterized fragments that work with any data
-
How to use multiple instances of the same fragment type
-
How events bubble up from children to parents
-
How to build a library of reusable UI components
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:
-
zero instances of
PaginatedTable(it’s just a module). -
one instance of
Init,View, andUpdate(usually a lambda, sometimes a class that responds tocall). -
many instances of its model classes, as returned by calls to
Init(one call perPaginatedTableon screen).
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
Modal
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:
-
Parameterized Init — Pass data in, fragment handles the rest
-
Multiple instances — Same fragment type, different data
-
Events bubble up —
Command.emittells parent “this happened” -
Resize handling — Fragments adapt to terminal size
-
Isolated testing — Test without parents or routing
Start building: PaginatedTable, Modal, FlashMessage, TextInput. Then reuse them everywhere.
Related: Fractal Architecture | Message Routing | Commands