How to build a general ledger table in React
The data grid is the easy part. What makes a ledger table a ledger table is two amount columns, a visible balance check, and an entry you can reverse but never edit.
1. Debit and credit are two columns
This is the single most important rendering rule in the accounting family, and the one a generic data grid gets wrong by default.
type JournalLine = {
accountId: string;
/** Exactly one of these is non-zero. Never a single signed amount. */
debit: Money;
credit: Money;
dimensions: Dimensions;
memo?: string;
};
A single signed column — -1,240.00 — collapses two facts into one. Double-entry exists so that a person can verify the arithmetic by eye; collapse the columns and you have deleted the control while keeping the data. There is no single-column variant of a ledger table, and offering one as a "compact mode" is offering to remove the audit trail to save 90 pixels.
2. Show the balance check
Not "validate on save". Show it, at the foot of the entry, with the totals:
Debit Credit
6200 · Rent expense 4,500.00
1010 · Operating cash 4,500.00
───────── ─────────
4,500.00 4,500.00 ✓ balanced
An unbalanced entry is a prominent error state with the difference stated — never a silently saved draft, and never a discrepancy rounded away. When the difference is divisible by nine, a transposition or a slide is the likely cause, and saying so turns a ten-minute hunt into a five-second fix. That diagnosis is a tool of its own.
3. Show the normal balance
Five account types, each with a side that increases it:
| Type | Normal | Statement | Typical range |
|---|---|---|---|
| Asset | Debit | Balance sheet | 1000–1999 |
| Liability | Credit | Balance sheet | 2000–2999 |
| Equity | Credit | Balance sheet | 3000–3999 |
| Revenue | Credit | P&L | 4000–4999 |
| Expense | Debit | P&L | 5000–8999 |
An account picker that shows Dr or Cr next to the account prevents a whole class of posting error for one glyph of ink. Contra accounts — accumulated depreciation, allowance for doubtful accounts — carry the opposite normal balance to their type and present as a deduction from their parent, so the picker has to state theirs rather than infer it from the type.
4. Posted entries are reversed, never edited
Immutability is not a database preference here, it is the product. A posted entry gets a reversing entry with its own number and date; the original stays exactly as it was. Which means the interface has three states to render, and the affordances differ:
- draft — editable, deletable, not in any report
- posted — immutable; the only actions are reverse and copy to new
- reversed — immutable, and linked in both directions to the entry that reversed it
The period state gates all of it. A posted entry in a closed period cannot be reversed into that period, only into an open one — which is a different transaction with different consequences, and the UI has to say so before the click, not after.
5. The ledger and the statement disagree about signs
The same fact renders differently depending on which representation you are in. Revenue is a credit in the ledger and a positive number in the P&L. A component that does not know which representation it is rendering will get revenue backwards — and it will look plausible, which is worse than looking broken.
So the representation is a property of the component, not an inference from the value. The full argument is in the accounting model.
6. Virtualise, but keep the keyboard
A register with four thousand rows has to virtualise. It also has to stay one tab stop for the whole grid, with arrows to navigate, Space to select, Enter to open and Escape to clear — and correct aria-rowcount and aria-rowindex, which virtualisation breaks by default because the DOM no longer contains the rows the numbers refer to.
Sticky header, sticky total, no zebra striping. The tabular figures do the aligning.
Where this is implemented
LedgerTable— two amount columns, and no single-column variantJournalEntryandJournalEntryForm— the balance check as you typeAccountPicker— grouped by type, with the normal balance shownEntryReversalDialog— the only way a posted entry changesTrialBalance— where the whole thing has to add up- The accounting core, running — nineteen components, live
- Journal entry balancer — paste an entry, see the out-of-balance, copy an import-ready CSV
- Trial balance checker — balances, and then whether anything sits on the wrong side of its normal
- Journals and the general ledger — whether Xero, QBO and NetSuite will even give you the GL