DataSlip is a terminal app for browsing PostgreSQL databases. You connect, you see tables, you click into rows, you write queries. It runs in your terminal. And it's built with React.
I know how that sounds. The reason is OpenTUI, which is a React renderer for terminal interfaces. Instead of rendering to the DOM, your components render to a terminal buffer with a custom reconciler that targets ANSI escape sequences. You get useState, you get hooks, you get component composition, all outputting to a TTY. The developer experience is closer to building a web app than to fighting ncurses, which is exactly why I picked it.
Component architecture
The layout is a three-panel design: 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>
);
}
The Box component is OpenTUI's flexbox equivalent. It accepts flexDirection, width, height, padding, and borderStyle props, and the renderer translates these into terminal cell positions. Nested Box components work like nested div elements with flexbox, which means the three-panel layout is just flexDirection="row" with percentage widths.
Focus management
On the web, the browser handles focus. In a TUI, you own all of it. Every interactive component needs to know whether it has focus, and the app needs a single 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 component listens for stdin keypress events via OpenTUI's useInput hook, looks up the active panel's handlers, and dispatches. Tab cycles focus between panels. Escape returns focus to the sidebar. Arrow keys, Enter, and letter keys are panel-specific.
The tricky part is that the query editor needs raw text input (you're typing SQL), but the table browser needs single-key navigation (arrow keys move the cell selection). These are incompatible input modes. When the query editor is focused, the input handler switches to a raw mode where every keypress feeds into a text buffer. When a table is focused, the handler interprets keys as navigation commands. The mode switch happens on focus change, which is a React state transition, so it composes cleanly.
Database interaction
The Postgres client uses the pg package (which is pure JavaScript in Bun's implementation, no native bindings). Connections are stored as JSON in a local file (~/.dataslip/connections.json), with passwords stored separately in the system keychain via Bun's FFI to libsecret (Linux) or Security.framework (macOS).
Schema browsing queries information_schema.tables and information_schema.columns for table/column metadata. Row browsing uses SELECT * FROM {table} ORDER BY {primary_key} LIMIT {page_size} OFFSET {offset}, with the primary key discovered from pg_constraint. I parameterize the page size (default 100) and fetch the next page on scroll, which brings us to 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. If you update too many cells per frame, you get visible flicker because the terminal can't process the escape sequence stream fast enough between screen refreshes.
OpenTUI batches diffs like React's virtual DOM. On each render, it computes a diff between the previous terminal buffer and the new one, and only writes the changed cells. This mostly handles it. "Mostly" because a table with 100 rows and 10 columns (1000 cells) on first render writes a lot of escape sequences regardless of diffing. I measured initial table render at about 15ms on iTerm2 and 40ms on Terminal.app, which is acceptable but visible as a brief flash.
For large query results, I added virtualized rendering: only the visible rows (terminal height minus chrome) are rendered. The table component tracks a scrollOffset state variable, and the render function slices the data array by [scrollOffset, scrollOffset + visibleRows]. Scrolling updates the offset and re-renders only the visible window. This dropped large result rendering from noticeable stutter to instant, because you're never rendering more than ~30 rows regardless of result set size.
Cell editing
Double-pressing Enter on a cell opens an inline editor. The cell's current value appears in a text input, and on confirmation it executes an UPDATE statement:
UPDATE {table} SET {column} = $1 WHERE {pk_column} = $2
The primary key value comes from the selected row's data. If the table has no primary key (views, or badly designed tables), editing is disabled and the status bar shows why. After the update, the visible page is re-fetched to reflect any triggers or computed columns that might have changed.
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 works because pg is pure JavaScript in Bun, so there are no native addons to worry about. The Linux build runs on an Ubuntu runner, the macOS build on macos-latest, and the Windows build on windows-latest. Each artifact is uploaded to the GitHub release.