Patterns & Anti-Patterns
Common Patterns
1. View Function Extraction (Mockup → App Workflow)
Extract reusable view functions so the same code works in both mockup previews and real components:
// View functions take plain data, return Element
fn sidebar(items: &[&str], selected: usize) -> Element {
Frame::new()
.header_left("Nav").border(true).width(Length::Px(28))
.child(List::new().items(items.iter().map(|s| ListItem::new(*s))).selected(selected))
.into()
}
// Step 1: preview with zero boilerplate
fn main() -> tui_lipan::Result<()> {
let items = vec!["Home", "Settings"];
mockup!("Preview", { sidebar(&items, 0) })
}
// Step 2: reuse in real component - no changes needed
fn view(&self, ctx: &Context<Self>) -> Element {
sidebar(&ctx.state.items, ctx.state.selected)
}2. Reusable Panel Shells (Parameterized UI)
When multiple panels share the same chrome but differ in header/footer labels or body, extract the shared shell into plain Rust helpers. The parameters come from Rust, while rsx! stays focused on composition.
fn app_panel(title: impl Into<Arc<str>>, child: impl Into<Element>) -> Element {
Frame::new()
.header_left(title)
.border(true)
.padding(1)
.child(child)
.into()
}
fn stats_panel(title: &str, value: &str) -> Element {
app_panel(
title,
rsx! {
Text {
content: value,
}
},
)
}
fn view(&self, _ctx: &Context<Self>) -> Element {
rsx! {
HStack {
stats_panel("CPU", "42%"),
stats_panel("Memory", "1.2 GB"),
}
}
}This is the preferred pattern when:
- the reusable piece is mostly view/chrome
- there is no local state or message handling
- you want to share the same UI between mockups, examples, and real components
If you want a more structured API, wrap the same idea in a small composite widget:
Composite widget recipe:
- define a
#[derive(Clone)]props/builder struct; - store child
Elements where the composite accepts arbitrary content; - implement
From<MyComposite> for Element; - build the returned tree from existing primitives such as
Frame,VStack,HStack,Text, andButton.
#[derive(Clone)]
struct AppPanel {
title: Arc<str>,
child: Element,
}
impl AppPanel {
fn new(title: impl Into<Arc<str>>, child: impl Into<Element>) -> Self {
Self {
title: title.into(),
child: child.into(),
}
}
}
impl From<AppPanel> for Element {
fn from(panel: AppPanel) -> Element {
Frame::new()
.header_left(panel.title)
.border(true)
.padding(1)
.child(panel.child)
.into()
}
}Use this when the same shell needs a named, reusable API such as AppPanel::new(...), .footer_left(...), .header(...), and .footer(...).
Choose the abstraction level by behavior:
- Helper function returning
Element- best for simple reusable shells - Composite widget struct - best for reusable builder-style UI with several options
ComponentwithProperties- best when the reusable panel has its own state or events
3. Numbered List Rows
Prefer ListItem::numbered(...) or ListItem::bulleted(...) instead of embedding the prefix in the label string yourself. Extra lines automatically indent under the label text.
List::new().items([
ListItem::new("Alpha")
.numbered(1)
.line(ListItemLine::new("first option")),
ListItem::new("Beta")
.numbered(2)
.line(ListItemLine::new("second option")),
])4. Background Command Pattern
Offload blocking I/O to a background thread:
fn update(&mut self, msg: Msg, ctx: &mut Context<Self>) -> Update {
match msg {
Msg::LoadUser(id) => {
ctx.state.loading = true;
let cmd = ctx.link().command(move |link| {
let user = db::get_user(id); // blocking
link.send(Msg::UserLoaded(user));
});
Update::with_command(cmd)
}
Msg::UserLoaded(user) => {
ctx.state.loading = false;
ctx.state.user = Some(user);
Update::full()
}
}
}If the blocking work is an interactive subprocess that needs the real TTY ($EDITOR, a pager), do not use link.command / Command::spawn for that phase: use Command::new on the UI thread with terminal_handoff, then request_full_repaint() as needed. See External programs.
4. Keyed Coalescing (Filter-as-You-Type)
Avoid stale search results by using TaskPolicy::LatestOnly:
Msg::QueryChanged(q) => {
ctx.state.query = q.clone();
let cmd = ctx.link().command_keyed(
"search",
TaskPolicy::LatestOnly,
move |link| {
let results = search_items(&q);
link.send(Msg::SearchDone(results));
},
);
Update::with_command(cmd) // Redraw to show query immediately
}5. Controlled Scroll with Synced State
For high-frequency scroll callbacks, separate widget-owned runtime state from visual state. Mirroring the latest offset usually should not rebuild the parent view.
// Controlled list with parent-managed offset
List::new()
.items(self.items.clone())
.selected(self.selected)
.on_scroll_to(ctx.link().callback(Msg::Scrolled))
// In update:
Msg::Scrolled(offset) => {
ctx.state.scroll_offset = offset;
Update::none()
}For ScrollView::on_viewport_change, return Update::none() when only the stored offset changed. Return Update::layout() only when visible metadata changes the emitting component's view or subtree, such as a sticky header label. Reserve Update::full() for state that affects other scopes or root-level composition.
5a. Read-only list with no selection
Pass .selected(None) when a list should show rows without a current-row highlight (for example a directory preview pane). Keyboard navigation still adopts the first/last selectable row on Down/Up so the list is not inert.
List::new()
.items(preview_entries.iter().map(|e| ListItem::new(e.name.clone())))
.selected(None)
.focusable(false)6. Memoized Heavy Child Components
When a parent rerenders often but some child panels/rows are expensive to rebuild, move those children into their own Component and give them a stable memo_key():
impl Component for MessageRow {
type Message = RowMsg;
type Properties = MessageRowProps;
type State = ();
fn create_state(&self, _props: &Self::Properties) -> Self::State {}
fn memo_key(&self, props: &Self::Properties, _ctx: &Context<Self>) -> Option<u64> {
Some(props.revision)
}
fn view(&self, ctx: &Context<Self>) -> Element {
render_message_row(ctx.props)
}
fn update(&mut self, _msg: RowMsg, _ctx: &mut Context<Self>) -> Update {
Update::none()
}
}Use this when:
- the subtree is expensive to build
- parent updates are frequent but mostly unrelated to the child
- the child's visual output is driven by a clear semantic revision key
Avoid using memo_key() as a manual diff cache for everything. If the subtree is cheap, a plain view helper is simpler.
7. Focus Routing
Use stable keys and explicit requests for pane-level navigation. This works under every FocusPolicy, including Manual:
fn update(&mut self, msg: Msg, ctx: &mut Context<Self>) -> Update {
match msg {
Msg::FocusSidebar => { ctx.request_focus("sidebar"); Update::none() }
Msg::FocusEditor => { ctx.request_focus("editor"); Update::none() }
}
}
fn on_key(&mut self, key: KeyEvent, ctx: &mut Context<Self>) -> KeyUpdate {
match key.code {
KeyCode::Char('1') if key.mods.ctrl => {
ctx.request_focus("sidebar");
KeyUpdate::handled(Update::none())
}
KeyCode::Char('2') if key.mods.ctrl => {
ctx.request_focus("editor");
KeyUpdate::handled(Update::none())
}
_ => KeyUpdate::unhandled(Update::none())
}
}Use .tab_stop(false) when a target should remain pointer/programmatically focusable but should not lengthen the global Tab ring. Wrap editor groups in FocusScope::Contain when Tab should cycle inside the group, and use pane-switch commands to leave it. Use FocusScope::Exclude for a subtree that should be absent from automatic and pointer focus while retaining request_focus(key) as an escape hatch.
Key dynamic focus targets. Under OnDemand, a temporarily unmounted focused key is retained and focus returns when that key remounts. Call ctx.blur() to forget it. Stable keys also suppress spurious widget on_blur/on_focus pairs across remounts.
8. Conditional Overlay Pattern
Show modals/overlays via state flags (not routing):
// State
struct State {
show_delete_confirm: bool,
selected_item: usize,
}
// View
fn view(&self, ctx: &Context<Self>) -> Element {
rsx! {
VStack {
List { items: ..., selected: ctx.state.selected_item }
if ctx.state.show_delete_confirm {
Modal {
title: "Confirm Delete",
VStack {
gap: 1,
Text { content: "This cannot be undone." }
HStack {
gap: 1,
Button {
label: "Cancel",
on_click: ctx.link().callback(|_| Msg::CancelDelete),
}
Button {
label: "Delete",
on_click: ctx.link().callback(|_| Msg::ConfirmDelete),
}
}
}
}
}
}
}
}9. Toast from Update
Msg::SaveSuccess => {
ctx.toast().push(Toast::new("File saved!"));
Update::none()
}
Msg::SaveError(e) => {
ctx.toast().push(Toast::new(format!("Error: {e}")).title("Error"));
Update::none()
}10. Dynamic Tab Management
struct State {
tabs: Vec<String>,
active: usize,
}
// In update:
Msg::OpenFile(path) => {
ctx.state.tabs.push(path);
ctx.state.active = ctx.state.tabs.len() - 1;
Update::full()
}
Msg::CloseTab(idx) => {
ctx.state.tabs.remove(idx);
ctx.state.active = ctx.state.active.min(ctx.state.tabs.len().saturating_sub(1));
Update::full()
}
Msg::ReorderTabs(e) => {
ctx.state.tabs.swap(e.from, e.to);
ctx.state.active = if ctx.state.active == e.from { e.to } else { ctx.state.active };
Update::full()
}11. Markdown Editor + Live Preview
Use TextArea for editing and DocumentView for display transforms. Keep the source in one state field and feed both widgets from the same value.
HStack::new()
.gap(1)
.child(
TextArea::new(ctx.state.markdown.clone())
.language("markdown")
.on_change(ctx.link().callback(|ev| Msg::SetMarkdown(ev.value))),
)
.child(
DocumentView::new(ctx.state.markdown.clone())
.markdown() // requires feature "markdown"
.line_numbers(true)
.wrap(true),
)For scroll-sync, emit source-line metrics from editor/preview and set DocumentView::scroll_to_source_line(...).
11a. TextArea inline sentinels (@mentions, file tokens)
Store app data on TextAreaSentinel::payload(your_type) and rely on SentinelId + on_sentinel_event for deletes instead of mirroring index → metadata maps. Use TextAreaSnapshot::capture / apply for stash–restore. See widgets/input.md.
12. Choosing the Right Container
| Need | Use | Don't use |
|---|---|---|
| Stack children vertically | VStack | Frame (unless you need border/labels) |
| Stack children horizontally | HStack | Frame (unless you need border/labels) |
| Border + header/footer labels | Frame | bare VStack with manual border |
| Overlay children on top of each other | ZStack | - |
| Center a single child | Center | VStack with align + justify hacks |
| Scrollable content | ScrollView | - |
| Resizable split panes | Splitter | - |
Frame is for visual chrome, not layout. Use Frame only when you need one or more of: border, header/footer labels, tab titles, join_frame, clipping, or FrameDecoration. If you just need to group children, use VStack or HStack directly.
Anti-Patterns (Common Mistakes)
❌ Returning Update::full() when nothing changed
// BAD: triggers unnecessary re-render
Msg::SomeEvent => Update::full()
// GOOD: only redraw if state actually changed
Msg::SomeEvent => {
if self.process(msg) {
Update::full()
} else {
Update::none()
}
}This is especially expensive for high-frequency widget callbacks. Avoid full root rebuilds when callbacks only synchronize runtime metadata:
// BAD: rebuilds the whole component tree on every wheel/drag event
Msg::ViewportChanged(event) => {
ctx.state.scroll_offset = event.offset;
Update::full()
}
// GOOD: the ScrollView already moved; just remember the settled offset
Msg::ViewportChanged(event) => {
ctx.state.scroll_offset = event.offset;
Update::none()
}❌ Missing .key(...) on list items or dynamic children
// BAD: reconciliation uses position - breaks on insert/remove
rsx! {
VStack {
for item in &items {
ItemWidget { data: item.clone() }
}
}
}
// GOOD: stable key for each child
rsx! {
VStack {
for item in &items {
ItemWidget { key: item.id.to_string(), data: item.clone() }
}
}
}❌ Blocking the main thread in update() or view()
// BAD: blocks the render loop
Msg::LoadData => {
let data = std::fs::read_to_string("data.json").unwrap(); // BLOCKS
ctx.state.data = data;
Update::full()
}
// GOOD: use a command
Msg::LoadData => {
Update::with_command(ctx.link().command(|link| {
let data = std::fs::read_to_string("data.json").unwrap();
link.send(Msg::DataLoaded(data));
}))
}❌ Using TaskPolicy::QueueAll for search/filter
// BAD: stale results pile up; each keystroke queues a new search
ctx.link().command_keyed("search", TaskPolicy::QueueAll, ...)
// GOOD: drop old pending searches
ctx.link().command_keyed("search", TaskPolicy::LatestOnly, ...)❌ Forgetting to drain screen.drain_responses()
TUI apps like fzf, vim, and lazygit query terminal capabilities. Not forwarding responses causes them to hang or malfunction.
// BAD: missing response forwarding
screen.process_bytes(&bytes);
let snapshot = screen.render_snapshot();
// GOOD: always drain and forward
screen.process_bytes(&bytes);
if let Some(pty) = &self.pty {
for response in screen.drain_responses() {
let _ = pty.write(&response);
}
}
let snapshot = screen.render_snapshot();❌ Setting scroll offset before resize in inline mode
// BAD: corrupts ratatui's internal cursor offset calculation
terminal.set_cursor_position((0, 0)); // before draw on resize
terminal.draw(...);
// GOOD: use backend_mut() for operations that must NOT touch ratatui state
terminal.backend_mut().execute(cursor_op)?;❌ Missing PartialEq on Properties
Properties must implement Clone + PartialEq for reconciliation to work. Without PartialEq, props changes may not trigger on_props_changed.
// BAD
#[derive(Clone)]
struct Props { value: String }
// GOOD
#[derive(Clone, PartialEq)]
struct Props { value: String }❌ Accessing image clipboard without image feature
// BAD: will not compile without feature "image"
use tui_lipan::ImageContent; // Only available with feature "image"
// Check Cargo.toml: features = ["image"]❌ Using Color::named() for precise contrast
// BAD: Color::Gray varies between terminal palettes
Button::new("Click").focus_style(Style::new().bg(Color::Gray))
// GOOD: use explicit RGB for predictable contrast behavior
Button::new("Click").focus_style(Style::new().bg(Color::rgb(80, 80, 80)))❌ Setting default values explicitly
Don't set properties that are already the default - it adds noise and signals misunderstanding of the framework. Common offenders:
// BAD: explicit framework defaults add noise
Input::new(query.clone())
.focusable(true) // interactive widgets are focusable by default
VStack::new()
.width(Length::Flex(1)) // containers default to Flex(1)
.height(Length::Flex(1)) // containers default to Flex(1)
.align(Align::Start) // Start is the default
.justify(Justify::Start) // Start is the default
App::new()
.mouse(true) // true in fullscreen mode by default
.contrast_policy(ContrastPolicy::Wcag) // Wcag is the default
// GOOD: Let the theme provide the shared caret, or explicitly override it when needed
Input::new(query.clone()) // inherits the active theme caret
Input::new(query.clone())
.caret_shape(CaretShape::Bar) // explicit per-input override
VStack::new()
.align(Align::Center) // Center is NOT the default - this IS meaningful❌ Setting background color on every widget and style variant
Background color inherits from the nearest ancestor. Don't repeat it on every child or sub-style:
// BAD: Repeating bg on every widget and sub-style
Frame::new()
.style(Style::new().bg(Color::indexed(235)))
.child(
Input::new(query.clone())
.style(Style::new().fg(Color::White).bg(Color::indexed(235)))
.focus_style(Style::new().fg(Color::White).bg(Color::indexed(235)).bold())
)
.child(
List::new()
.items(items)
.selection_style(Style::new().fg(Color::Cyan).bg(Color::indexed(235)))
)
// GOOD: Set bg once on the parent; children inherit it.
// Only set bg when you want a DIFFERENT background.
Frame::new()
.style(Style::new().bg(Color::indexed(235)))
.child(
Input::new(query.clone())
.style(Style::new().fg(Color::White))
.focus_style(Style::new().fg(Color::White).bold())
)
.child(
List::new()
.items(items)
.selection_style(Style::new().fg(Color::Cyan).bold())
)See styling.md for the full style inheritance rules.
Note: Only
bginherits this way.fgdoes not inherit - each widget resolves its own foreground color independently.
❌ Wrapping containers in Frame unnecessarily
Frame is for visual chrome (border, header/footer labels, clipping). If you just need layout, use VStack/HStack directly:
// BAD: Frame adds no value here - no border or labels used
Frame::new()
.child(
VStack::new()
.child(Text::new("Hello"))
.child(Button::new("Click"))
)
// GOOD: VStack alone provides the same layout
VStack::new()
.child(Text::new("Hello"))
.child(Button::new("Click"))
// GOOD: Frame is justified when you use its features
Frame::new()
.header_left("Settings")
.border(true)
.footer_right("v1.2")
.child(
VStack::new()
.gap(1)
.child(Text::new("Hello"))
.child(Button::new("Click"))
)