Spreadsheet
Spreadsheet demo built with Expressive MVC - the complete source below runs as an editable sandbox when JavaScript is enabled.
App.tsx
import './App.css';
import State, { Component, get, map } from '@expressive/react';
const COLS = ['A', 'B', 'C', 'D'];
const ROWS = [1, 2, 3, 4, 5];
// The sheet owns the cells, keyed by coordinate, and spawns them lazily on
// first edit - an empty coordinate simply has no member yet. Its own render
// is static; each cell view subscribes to its own key and repaints alone.
export default class Sheet extends Component {
cells = map(() => new Cell());
editing = '';
edit(id: string, input: string) {
if (!this.cells.has(id)) this.cells.set(id);
this.cells.get(id)!.input = input;
}
render() {
return (
<div className="container sheet">
<h1>Spreadsheet</h1>
<p>
Enter a number, text, or a formula like <code>=A1*2+B1</code>.
Editing a cell recomputes only the cells that reference it.
</p>
<table>
<thead>
<tr>
<th />
{COLS.map((c) => (
<th key={c}>{c}</th>
))}
</tr>
</thead>
<tbody>
{ROWS.map((r) => (
<tr key={r}>
<th>{r}</th>
{COLS.map((c) => (
<CellView key={c + r} id={c + r} />
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
}
// A cell is a spawned, owned member of the sheet's map - the sparse data,
// one per *filled* coordinate. Its value is a computed getter: a formula
// substitutes each referenced cell's value (read through the map by key,
// which subscribes to that key alone) and evaluates the arithmetic - so a
// cell recomputes only when a cell it references changes, and it cascades.
class Cell extends State {
sheet = get(Sheet);
input = '';
get value(): number | string {
const raw = this.input.trim();
if (!raw) return '';
if (!raw.startsWith('=')) {
const n = Number(raw);
return isNaN(n) ? raw : n;
}
// Replace A1-style refs with their numeric value, then evaluate the
// remaining arithmetic (+ - * / and parens). The substituted string is
// digits and operators only, checked before it runs.
const expr = raw.slice(1).replace(/[A-Za-z]+\d+/g, (ref) => {
const cell = this.sheet.cells.get(ref.toUpperCase()); // subscribes
const val = cell ? cell.value : 0;
return typeof val === 'number' && isFinite(val) ? String(val) : '0';
});
if (!/^[\d+\-*/(). ]+$/.test(expr)) return '#ERR';
try {
const result = Function(`"use strict"; return (${expr})`)();
if (typeof result !== 'number' || !isFinite(result)) return '#ERR';
return Math.round(result * 1e4) / 1e4;
} catch {
return '#ERR';
}
}
}
// One <td> per coordinate - the dense view, one per *slot*. It holds no
// state, so it is a plain function component: Sheet.get() finds the sheet
// from context and subscribes to just this coordinate's cell - absent until
// first edit, then filled in. A keyed read that tracks a key before it
// exists and repaints only when its own cell changes.
const CellView = ({ id }: { id: string }) => {
const sheet = Sheet.get();
const cell = sheet.cells.get(id);
return (
<td>
{sheet.editing === id ? (
<input
autoFocus
value={cell ? cell.input : ''}
onChange={(e) => sheet.edit(id, e.target.value)}
onBlur={() => (sheet.editing = '')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Escape') sheet.editing = '';
}}
/>
) : (
<div className="cell" onClick={() => (sheet.editing = id)}>
{cell ? cell.value : ''}
</div>
)}
</td>
)
};
App.css
.sheet {
max-width: 34rem;
}
.sheet table {
border-collapse: collapse;
width: 100%;
table-layout: fixed;
font-variant-numeric: tabular-nums;
}
.sheet th {
font-size: var(--t-xs);
font-weight: 600;
color: var(--muted);
background: var(--surface);
padding: var(--s2);
text-align: center;
border: 1px solid var(--border);
}
.sheet thead th,
.sheet tbody th {
width: 2rem;
}
.sheet td {
border: 1px solid var(--border);
padding: 0;
height: 2.25rem;
}
.sheet .cell {
height: 100%;
padding: 0 var(--s2);
display: flex;
align-items: center;
justify-content: flex-end;
font-size: var(--t-sm);
color: var(--fg);
cursor: cell;
white-space: nowrap;
overflow: hidden;
}
.sheet .cell:hover {
background: var(--accent-soft);
}
.sheet td input {
width: 100%;
height: 100%;
min-width: 0;
padding: 0 var(--s2);
font: inherit;
font-size: var(--t-sm);
text-align: right;
color: var(--fg);
background: var(--bg);
border: 2px solid var(--accent);
border-radius: 0;
outline: none;
}