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

> Discover how llmfit achieves stateless TUI rendering. Learn how event handlers mutate app state while keeping UI rendering functions read-only and efficient.

- Repository: [Alex Jones/llmfit](https://github.com/AlexsJones/llmfit)
- Tags: internals
- Published: 2026-09-11

---

**The llmfit TUI achieves stateless rendering by restricting all state mutations to event handlers in [`tui_events.rs`](https://github.com/AlexsJones/llmfit/blob/main/tui_events.rs), while rendering functions in [`tui_ui.rs`](https://github.com/AlexsJones/llmfit/blob/main/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`](https://github.com/AlexsJones/llmfit/blob/main/tui_ui.rs) file contains pure rendering logic that transforms the current `App` state into terminal frames, while [`tui_events.rs`](https://github.com/AlexsJones/llmfit/blob/main/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`](https://github.com/AlexsJones/llmfit/blob/main/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`](https://github.com/AlexsJones/llmfit/blob/main/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.

```rust
// 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`](https://github.com/AlexsJones/llmfit/blob/main/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.

```rust
// 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.

```rust
// 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`](https://github.com/AlexsJones/llmfit/blob/main/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`](https://github.com/AlexsJones/llmfit/blob/main/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`](https://github.com/AlexsJones/llmfit/blob/main/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`](https://github.com/AlexsJones/llmfit/blob/main/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`](https://github.com/AlexsJones/llmfit/blob/main/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.