How to build a general ledger table in React
The data grid is the easy part. A ledger table needs two amount columns, a visible balance check, and entries you can reverse but never edit.
1. Debit and credit are two columns#
This is the most important rendering rule in the accounting family, and a generic data grid gets it 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 a person can check the arithmetic by eye. Collapse the columns and you keep the data but delete the control. There's no single-column ledger table. A "compact mode" would trade the audit trail for 90 pixels.
2. Show the balance check#
Don't just validate on save. Show the check 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 save it silently as a draft, and never round the discrepancy 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 has its own tool.
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 each account prevents a whole class of posting errors for one glyph. Contra accounts, like accumulated depreciation and allowance for doubtful accounts, carry the opposite normal balance to their type and present as a deduction from their parent. The picker has to state theirs instead of inferring it from the type.
4. Posted entries are reversed, never edited#
Immutability is the product here, not a database preference. A posted entry gets a reversing entry with its own number and date, and the original stays exactly as it was. So the interface renders three states, each with different actions:
- draft: editable, deletable, not in any report
- posted: immutable; the only actions are reverse and copy to new
- reversed: immutable, and linked both ways to the entry that reversed it
The period state gates all of it. A posted entry in a closed period can only be reversed into an open period. That's 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 the representation. Revenue is a credit in the ledger and a positive number in the P&L. A component that doesn't know which one it's 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, never inferred from the value. The full argument is in the accounting model.
6. Virtualize, but keep the keyboard#
A register with four thousand rows has to virtualize. The whole grid still needs to be one tab stop, with arrows to navigate, Space to select, Enter to open and Escape to clear. It also needs correct aria-rowcount and aria-rowindex, which virtualization breaks by default because the DOM no longer holds the rows those 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, then checks 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