One table, two products
Your watchlist table renders ticker, last, change, volume. Your screener needs code, name, market cap, sector, yield — different columns, different rows, and exactly the same behaviour underneath.
This is the second refactor, and it is a harder judgement call than the first.
The trap: generalising too early and too far
The tempting move is a table that can do anything: pass it any data, any column definitions, sorting, grouping, pinning, virtualisation. Two hours later you have written a worse version of a library, and both products depend on it.
The discipline is to generalise exactly as far as the second caller requires, and no further. You have two concrete uses in front of you. Build for those two. The third caller, if it ever exists, can push it further with real requirements instead of imagined ones.
What the two actually share
Write it down before writing code:
- a header row, a body, and one row per record
- right-aligned figures with tabular numerals so columns line up
- a value that can be missing, rendered as a dash rather than blank
- an empty state that says why it is empty
- horizontal scrolling on a narrow screen instead of pushing the page sideways
Nothing there mentions prices. That list is your component's contract.
type Column<T> = {
key: string;
label: string;
align?: "left" | "right";
render: (row: T) => string | null; // null renders as a dash
};
A column knows how to turn a row into a cell. The table knows nothing else about either. The watchlist passes quote columns; the screener passes screen columns.
Why render returns a string
Because formatting is where the numbers get lied about, and putting it in one place per column means there is one place to fix.
The screener's yield column is the example that matters. The API sends 0.0002, which is a fraction. The column that renders it is the only code that should know that:
{ key: "dividend_yield", label: "Yield", align: "right",
render: (r) => r.dividend_yield == null
? null
: `${(r.dividend_yield * 100).toFixed(2)}%` }
If instead the table received pre-formatted rows, or worse formatted numbers itself, that * 100 would end up scattered or forgotten. Unit 3 lesson 12 is a whole lesson on the damage that does.
The generalisation that is worth it
Notice what you get for free once the table is generic: the empty state, the dash-for-missing, the alignment and the scroll behaviour are now written once and correct in both products. When you fix a rendering bug you fix it everywhere.
That is the actual return on a rebuild, and it is why "start a new project" was the expensive option.
Try it now
Generalise your table and render both products through it. Then apply the real test of the abstraction: add one column to the screener — sector, say — and count how many files you had to touch. If the answer is more than one, the contract is leaking and it is cheaper to fix now than after the third product.