# 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.

Source: https://lopifi.com/docs/guides/04-build-a-general-ledger-table
Part of: Lopifi — US GAAP books on autopilot, reviewed by a CPA (https://lopifi.com)

---

# 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.

```ts
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](/tools/difference).

## 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](../domain/01-accounting-model.md).

## 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](03-align-numbers-in-a-table.md).

---

## Where this is implemented

- [`LedgerTable`](/components/ledger-table) — two amount columns, and no single-column variant
- [`JournalEntry`](/components/journal-entry) and [`JournalEntryForm`](/components/journal-entry-form) — the balance check as you type
- [`AccountPicker`](/components/account-picker) — grouped by type, with the normal balance shown
- [`EntryReversalDialog`](/components/entry-reversal-dialog) — the only way a posted entry changes
- [`TrialBalance`](/components/trial-balance) — where the whole thing has to add up
- [The accounting core, running](/showcase/gl) — nineteen components, live
- [Journal entry balancer](/tools/journal) — paste an entry, see the out-of-balance, copy an import-ready CSV
- [Trial balance checker](/tools/tb) — balances, and then whether anything sits on the wrong side of its normal
- [Journals and the general ledger](/apis/journals) — whether Xero, QBO and NetSuite will even give you the GL
