---
title: "How to build a general ledger table in React"
description: "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."
canonical: "https://lopifi.com/docs/guides/build-a-general-ledger-table"
---

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

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

---

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

```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 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](/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 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](https://lopifi.com/md/docs/domain/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](https://lopifi.com/md/docs/guides/align-numbers-in-a-table).

---

## 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, then checks 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
