2.2 The Render Loop
A TUI is a loop. Input arrives, state changes, the screen re-renders. This is identical to a game engine’s main loop — the difference is that a game loop runs at 60fps regardless, while a TUI typically only re-renders when something changes.
The Pattern
Section titled “The Pattern”┌─────────────────────────────────────────┐│ render loop ││ ││ ┌─────────┐ event ┌───────────┐ ││ │ State │ ────────► │ Handler │ ││ └─────────┘ └─────┬─────┘ ││ ▲ │ ││ └──────────────────────┘ ││ state updated ││ │ ││ ┌────▼──────┐ ││ │ render() │ ││ └───────────┘ │└─────────────────────────────────────────┘- State — a plain object holding all application data
- Events — keyboard input, timer ticks, terminal resize
- Handler — a pure function:
(state, event) → newState - Render — a pure function:
(state) → void(writes to stdout)
The key insight: render() is always a total redraw. You do not surgically update parts of the screen — you redraw the whole frame every time state changes. This is simple, correct, and fast enough for any TUI.
A Counter App
Section titled “A Counter App”import chalk from 'chalk';import ansiEscapes from 'ansi-escapes';import { parseKey } from './01-keypress-parser';
// ── State ────────────────────────────────────────────────
type State = { count: number; message: string;};
let state: State = { count: 0, message: 'Use ↑ / ↓ or +/- to change the counter.',};
// ── Render ───────────────────────────────────────────────
function render(s: State) { const { columns: cols, rows } = process.stdout;
// Move to top-left — we always redraw from the beginning process.stdout.write(ansiEscapes.cursorTo(0, 0));
// Title process.stdout.write(chalk.bold.cyan('Counter\n')); process.stdout.write(chalk.dim('─'.repeat(cols) + '\n'));
// Counter value — centered const value = chalk.bold.yellow(String(s.count)); const label = ` Value: ${value}`; process.stdout.write(label + '\n');
// Message process.stdout.write('\n' + chalk.dim(s.message) + '\n'); process.stdout.write(chalk.dim('─'.repeat(cols) + '\n')); process.stdout.write(chalk.dim('q: quit\n'));
// Erase from cursor to end of screen (clears any leftover content) process.stdout.write(ansiEscapes.eraseDown);}
// ── Input handler ─────────────────────────────────────────
function handleKey(raw: string) { const key = parseKey(raw);
switch (key.name) { case 'up': case '+': state = { ...state, count: state.count + 1, message: 'Incremented.' }; break; case 'down': case '-': state = { ...state, count: state.count - 1, message: 'Decremented.' }; break; case 'ctrl+q': case 'q': cleanup(); return; }
render(state);}
// ── Setup & cleanup ───────────────────────────────────────
function cleanup() { process.stdin.setRawMode(false); process.stdout.write(ansiEscapes.cursorShow); process.stdout.write(ansiEscapes.exitAlternativeScreen); process.exit(0);}
process.stdout.write(ansiEscapes.enterAlternativeScreen);process.stdout.write(ansiEscapes.cursorHide);process.stdout.write(ansiEscapes.clearScreen);
process.on('SIGINT', cleanup);process.on('SIGTERM', cleanup);
process.stdin.setRawMode(true);process.stdin.resume();process.stdin.setEncoding('utf8');process.stdin.on('data', handleKey);
// Initial renderrender(state);Notice how render and handleKey are completely separate. handleKey produces a new state. render draws it. Neither cares about the other’s internals.
Why Total Redraw Works
Section titled “Why Total Redraw Works”You might worry: “Won’t redrawing the entire screen flicker?”
The answer is almost never, because:
- Terminal writes are buffered by the OS — bytes go into a kernel buffer and are flushed together
- The terminal emulator renders asynchronously — it does not redraw the display for every byte; it batches updates at the monitor refresh rate
- TUIs are text — the amount of data is tiny. A 200×50 terminal is 10,000 characters. At UTF-8 with styles, a full redraw is typically 20–100 KB, which completes in microseconds
Flickering only becomes visible if you clear the screen and then draw slowly. The fix — double buffering — is covered in the next lesson.
State Immutability
Section titled “State Immutability”Notice state = { ...state, count: state.count + 1 } instead of state.count++. This is intentional:
- It is easier to debug: old state is unchanged
- It makes future features (undo, history) trivial
- It matches how functional TUI frameworks (like Bubble Tea in Go) work
For simple apps, a mutable object is fine. For anything with undo or history, immutable updates are worth it.
Timed Re-renders
Section titled “Timed Re-renders”Some TUIs need to update on a timer as well as on input — a live log view, a dashboard with real metrics, a game:
// Redraw every 1 second regardless of inputsetInterval(() => { // update state with fresh data state = { ...state, timestamp: new Date().toISOString() }; render(state);}, 1000);Input and timers coexist naturally — both call render(state) after updating state.
The TUI Class Pattern
Section titled “The TUI Class Pattern”Once your app grows beyond a single file, extract the boilerplate into a reusable class:
import ansiEscapes from 'ansi-escapes';import { parseKey, type Key } from './01-keypress-parser';
export abstract class TuiApp { protected cols = process.stdout.columns; protected rows = process.stdout.rows;
start() { process.stdout.write(ansiEscapes.enterAlternativeScreen); process.stdout.write(ansiEscapes.cursorHide); process.stdout.write(ansiEscapes.clearScreen);
process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding('utf8'); process.stdin.on('data', (raw: string) => { this.onKey(parseKey(raw)); this.render(); });
process.stdout.on('resize', () => { this.cols = process.stdout.columns; this.rows = process.stdout.rows; this.onResize(); this.render(); });
process.on('SIGINT', () => this.exit()); process.on('SIGTERM', () => this.exit());
this.render(); }
exit() { process.stdin.setRawMode(false); process.stdout.write(ansiEscapes.cursorShow); process.stdout.write(ansiEscapes.exitAlternativeScreen); process.exit(0); }
abstract render(): void; abstract onKey(key: Key): void; onResize() {}}Extending it:
class CounterApp extends TuiApp { private count = 0;
render() { process.stdout.write(ansiEscapes.cursorTo(0, 0)); process.stdout.write(`Count: ${this.count}\n`); process.stdout.write(ansiEscapes.eraseDown); }
onKey(key: Key) { if (key.name === 'up') this.count++; if (key.name === 'down') this.count--; if (key.name === 'q') this.exit(); }}
new CounterApp().start();This pattern is what we will build on for the rest of the course.