Anurag Gupta

All posts

Building a TUI database browser with React (yes, React)

2026-03-22

Every time I tell someone I built a terminal database client in React, they assume I mean Electron squeezed into a scrollback buffer. I don't. DataSlip runs in your actual terminal, renders to a TTY, and it's React components the whole way down. You connect, you see tables, you click into rows, you write queries. No browser anywhere.

The reason is OpenTUI, a React renderer that targets terminals. Instead of rendering to the DOM, your components render to a terminal buffer through a custom reconciler that emits ANSI escape sequences. You get useState, you get hooks, you get component composition, all of it landing on a TTY. The last time I built a terminal UI I was fighting ncurses and losing. This felt like building a web app. That's the entire reason I picked it, and I'm not going back.

Component architecture

The layout is three panels: a sidebar for connections and tables, a main panel for row data or query results, and a bottom bar for status and errors. Each panel is a React component.

function App() {
    const [focused, setFocused] = useState<Panel>('sidebar');
    const [activeConn, setActiveConn] = useState<Connection | null>(null);
    const [activeTable, setActiveTable] = useState<string | null>(null);

    return (
        <Box flexDirection="row" width="100%" height="100%">
            <Sidebar
                focused={focused === 'sidebar'}
                onSelectConnection={setActiveConn}
                onSelectTable={setActiveTable}
            />
            <MainPanel
                focused={focused === 'main'}
                connection={activeConn}
                table={activeTable}
            />
            <StatusBar connection={activeConn} />
        </Box>
    );
}

Box is OpenTUI's flexbox equivalent. It takes flexDirection, width, height, padding, and borderStyle, and the renderer turns those into terminal cell positions. Nested Box components behave like nested divs with flexbox, so the three-panel layout is just flexDirection="row" with percentage widths. I kept expecting this to fall apart at some awkward edge. It didn't. The mental model I already had from the web transferred almost completely.

Focus management

On the web, the browser owns focus. In a TUI, you own all of it, and I underestimated how much "all" was. Every interactive component needs to know whether it holds focus, and the app needs one source of truth for which panel is active.

I built a small focus manager as a React context:

const FocusContext = createContext<{
    active: Panel;
    setActive: (panel: Panel) => void;
    register: (panel: Panel, handlers: KeyHandlers) => void;
}>(null!);

Each panel calls register on mount with its keyboard handlers, a map of key names to callbacks. The root listens for stdin keypress events through OpenTUI's useInput hook, looks up the active panel's handlers, and dispatches. Tab cycles focus. Escape returns to the sidebar. Arrow keys, Enter, and letter keys are panel-specific.

The part that bit me was input modes. The query editor needs raw text input, because you're typing SQL. The table browser needs single-key navigation, because arrow keys move the cell selection. Those two are fundamentally incompatible. My first attempt tried to make one handler serve both and turned into a nest of conditionals. The fix was to make the mode a function of focus: when the query editor is focused, the handler switches to raw mode where every keypress feeds a text buffer; when a table is focused, keys are navigation commands. The switch happens on focus change, which is just a React state transition, so it composes cleanly instead of branching everywhere.

Database interaction

The Postgres client uses the pg package, which is pure JavaScript in Bun's implementation, no native bindings. Connections live as JSON in a local file (~/.dataslip/connections.json), with passwords stored separately in the system keychain via Bun's FFI to libsecret on Linux or Security.framework on macOS. Passwords in a plaintext JSON file was never going to survive review, mostly because the reviewer was me a week later.

Schema browsing queries information_schema.tables and information_schema.columns. Row browsing uses SELECT * FROM {table} ORDER BY {primary_key} LIMIT {page_size} OFFSET {offset}, with the primary key discovered from pg_constraint. Page size defaults to 100 and the next page fetches on scroll, which is what dragged me into the rendering problem.

Terminal rendering performance

Terminal emulators have a finite redraw budget. The renderer writes ANSI escape sequences to stdout: cursor movement (\x1b[{row};{col}H), color (\x1b[38;2;{r};{g};{b}m), and text. Update too many cells per frame and you get visible flicker, because the terminal can't chew through the escape sequence stream fast enough between refreshes.

OpenTUI diffs like React's virtual DOM. Each render, it computes the difference between the previous terminal buffer and the new one and writes only the changed cells. This handles most of it. Most, not all: a table with 100 rows and 10 columns is 1000 cells, and on first render there's no previous buffer to diff against, so all of them get written. I measured initial table render at about 15ms on iTerm2 and 40ms on Terminal.app. Acceptable, but visible as a brief flash, and Terminal.app being nearly 3x slower was a reminder that the emulator, not my code, sets the ceiling.

For large query results I added virtualized rendering: only the visible rows (terminal height minus chrome) render. The table component tracks a scrollOffset and the render slices the data by [scrollOffset, scrollOffset + visibleRows]. Scrolling updates the offset and re-renders only the visible window. That took large-result rendering from noticeable stutter to instant, because you're never drawing more than about 30 rows no matter how big the result set is. This is the same trick every web data grid uses, and it works here for the same reason.

Cell editing

Double-pressing Enter on a cell opens an inline editor. The current value drops into a text input, and on confirmation it runs an UPDATE:

UPDATE {table} SET {column} = $1 WHERE {pk_column} = $2

The primary key value comes from the selected row. If the table has no primary key, which happens with views or tables somebody built in a hurry, editing is disabled and the status bar says why. After the update I re-fetch the visible page, so any triggers or computed columns show their real post-update values instead of what I optimistically typed.

Release binaries

bun build --compile produces a single executable. The GitHub Actions matrix builds for macOS (arm64, x64), Linux (x64), and Windows (x64). Cross-compilation just works, because pg is pure JavaScript in Bun and there are no native addons to reconcile per platform. The Linux build runs on an Ubuntu runner, macOS on macos-latest, Windows on windows-latest, and each artifact is uploaded to the release.

So: React, in a terminal, shipping as a single static binary with no browser and no native bindings. The thing I'd change is the keychain FFI, which is two code paths I have to test on two operating systems and dread touching. Everything else I'd build the same way. If you still think a TUI has to mean ncurses and hand-rolled draw loops, clone it and read the focus manager. That's the file that convinced me the joke in the title was actually the right call.