Skip to content

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.

┌─────────────────────────────────────────┐
│ render loop │
│ │
│ ┌─────────┐ event ┌───────────┐ │
│ │ State │ ────────► │ Handler │ │
│ └─────────┘ └─────┬─────┘ │
│ ▲ │ │
│ └──────────────────────┘ │
│ state updated │
│ │ │
│ ┌────▼──────┐ │
│ │ render() │ │
│ └───────────┘ │
└─────────────────────────────────────────┘
  1. State — a plain object holding all application data
  2. Events — keyboard input, timer ticks, terminal resize
  3. Handler — a pure function: (state, event) → newState
  4. 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.

examples/module-2/02-render-loop.ts
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 render
render(state);

Notice how render and handleKey are completely separate. handleKey produces a new state. render draws it. Neither cares about the other’s internals.

You might worry: “Won’t redrawing the entire screen flicker?”

The answer is almost never, because:

  1. Terminal writes are buffered by the OS — bytes go into a kernel buffer and are flushed together
  2. The terminal emulator renders asynchronously — it does not redraw the display for every byte; it batches updates at the monitor refresh rate
  3. 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.

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.

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 input
setInterval(() => {
// 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.

Once your app grows beyond a single file, extract the boilerplate into a reusable class:

examples/module-2/tui-base.ts
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.