How llmfit Maintains Stateless TUI Rendering While Event Handlers Mutate App State

The llmfit TUI achieves stateless rendering by restricting all state mutations to event handlers in tui_events.rs, while rendering functions in tui_ui.rs only read from the App struct and use transient local variables for frame-specific widget state.

The llmfit terminal user interface demonstrates a robust architectural pattern for Rust applications built with ratatui. By strictly separating read-only rendering logic from state-mutating event handling, the codebase ensures deterministic frame generation while maintaining responsive user interactions. This design, implemented across three core files in the llmfit-tui/src directory, prevents accidental side effects during drawing operations.

Architectural Separation of Concerns

The TUI architecture divides responsibilities between two primary modules. The tui_ui.rs file contains pure rendering logic that transforms the current App state into terminal frames, while tui_events.rs handles all user input and background tick processing that modifies persistent state.

This separation ensures that the draw function acts as a pure function of the App snapshot, despite receiving &mut App to satisfy ratatui's API requirements. In tui_app.rs, the App struct encapsulates all mutable UI state including selection indices, filter strings, and viewport offsets, with methods like move_down() and move_up() serving as the exclusive mutation points.

Stateless Rendering Implementation

The rendering pipeline in tui_ui.rs adheres to strict immutability semantics even when rendering interactive widgets like tables.

Local Widget State Pattern

When drawing the model table, the draw_table function creates a local TableState instance named visible_state rather than mutating app.table_state directly. This local state is computed from the current viewport and discarded immediately after rendering.

// tui_ui.rs – draw_table (excerpt)
let mut visible_state = TableState::default();
visible_state.select(
    viewport
        .contains(&app.selected_row)
        .then(|| app.selected_row - viewport.start),
);
frame.render_stateful_widget(table, area, &mut visible_state);

A comment in the source code explicitly documents this design decision: "Widget selection is local to this frame. Persistent navigation state is updated by tui_events, never by drawing the table." This guarantees that rendering logic cannot accidentally modify scrolling offsets or selection indices, making each frame deterministic based solely on the App fields like app.theme, app.filtered_fits, and app.selected_row.

Event-Driven State Mutation

All persistent state changes flow through the event handling system in tui_events.rs, which serves as the sole authority for App mutation.

The Central Event Loop

The handle_events function processes background ticks first, then polls for keyboard input. When a key event occurs, the dispatcher calls methods on App that modify persistent fields. For example, pressing j or k in normal mode invokes app.move_down() or app.move_up(), which update app.selected_row directly.

// tui_events.rs – handle_normal_mode (excerpt)
fn handle_normal_mode(app: &mut App, key: KeyEvent) {
    match key.code {
        KeyCode::Down | KeyCode::Char('j') => app.move_down(),
        KeyCode::Up   | KeyCode::Char('k') => app.move_up(),
        // …other bindings omitted…
    }
}

Viewport Synchronization

After handling any event, the system calls update_model_viewport to recompute scroll offsets based on the new selection state. This function mutates app.table_state.offset_mut() to set the persistent viewport position, ensuring the visual scroll aligns with the logical selection.

// tui_events.rs – update_model_viewport (excerpt)
pub fn update_model_viewport(app: &mut App, terminal_area: ratatui::layout::Rect) {
    let table_area = crate::tui_ui::main_layout(terminal_area)[2];
    let viewport = crate::tui_ui::model_table_viewport(
        app.filtered_fits.len(),
        app.selected_row,
        app.table_state.offset(),
        usize::from(table_area.height.saturating_sub(3)),
    );
    app.table_state.select((!app.filtered_fits.is_empty()).then_some(app.selected_row));
    *app.table_state.offset_mut() = viewport.start;
}

State Ownership and Encapsulation

The App struct defined in tui_app.rs owns all mutable TUI state, including filters, tick counters, pop-up flags, and navigation indices. Its public methods constitute the API through which event handlers modify the UI state. Rendering functions never call these mutation methods; they only borrow fields immutably to construct the visual layout.

This ownership model ensures that state changes are intentional and traceable to specific event handlers, while the rendering layer remains idempotent and free of side effects.

Summary

  • Rendering functions in tui_ui.rs treat App as read-only, using local variables like visible_state for temporary widget configuration that is discarded after each frame.
  • Event handlers in tui_events.rs hold exclusive rights to mutate App state through methods like move_down() and update_model_viewport.
  • The local state pattern prevents rendering logic from accidentally modifying persistent navigation or scroll state, ensuring deterministic frame generation.
  • All state mutations are centralized in tui_app.rs methods, making the TUI predictable and easier to unit test.

Frequently Asked Questions

Why does the draw function take &mut App if it doesn't mutate state?

The draw function receives &mut App to satisfy ratatui's API requirements for stateful widgets, but the llmfit implementation treats this reference as read-only. The function never writes to persistent fields, instead using temporary local state for frame-specific rendering needs. This convention allows the code to work with ratatui's interfaces while maintaining the architectural guarantee of stateless rendering.

How does the TUI handle table scrolling without mutating state during rendering?

Scrolling is managed through the update_model_viewport function in tui_events.rs, which runs after every event handling cycle. This function calculates the visible viewport based on the current selection and explicitly updates app.table_state.offset_mut() to synchronize the view. During rendering, draw_table only reads this pre-computed offset to create a temporary visible_state for the current frame, ensuring the actual scroll state never changes while drawing.

What prevents accidental state mutation during the rendering phase?

The codebase enforces stateless rendering through a combination of code organization and local variable patterns. By creating a fresh TableState::default() instance in draw_table rather than mutating app.table_state, and by clearly documenting this pattern in source comments, the rendering code physically cannot modify persistent navigation state. All mutation paths are explicitly routed through event handlers that run before the next draw call.

Can this stateless rendering pattern be adapted for other Rust TUI applications?

Yes, this pattern generalizes to any ratatui-based application. The key requirements are separating rendering logic into functions that only read application state, routing all mutations through a dedicated event handling layer, and using temporary local state for frame-specific widget configuration rather than persistent struct fields. This approach is particularly valuable for applications requiring deterministic testing or complex UI state management.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →