Inventory Management (Advanced Events)
This example demonstrates a real-world inventory management dashboard built with GridSheet. It highlights how to use event handlers—such as onChange, onRemoveRows, onRemoveCols, onInsertRows, and onInsertCols—to track user-driven edits, manage inventory changes, and log detailed activity. The sheet's current state is also shown as a TSV dump, making it easy to export or inspect the data.
Implementation Guide
📄 View Source CodeonChange and getLastChangedAddresses
onChange fires after every cell mutation. The sheet object passed to the callback exposes getLastChangedAddresses(), which returns the cell addresses (string[]) that were modified in the most recent operation.
onChange: ({ sheet }: { sheet: UserSheet }) => {
const addresses = sheet.getLastChangedAddresses();
if (addresses.length > 0) {
addActivityLog(`✏️ Cells changed: ${addresses.join(', ')}`);
}
setTsv(convertToTSV(sheet));
},This is more precise than tracking points, because getLastChangedAddresses() reflects the actual addresses of written cells, including those affected by formula recalculation.
Fine-grained Structural Event Callbacks
Besides onChange, useSpellbook provides dedicated callbacks for row/column insertions and deletions. Each receives the exact positions affected.
onRemoveRows: ({ sheet, ys }) => {
addActivityLog(`🗑️ Removed row(s): ${ys.join(', ')}`);
},
onInsertRows: ({ sheet, y, numRows }) => {
addActivityLog(`➕ Inserted ${numRows} row(s) at row ${y}`);
},
onRemoveCols: ({ sheet, xs }) => {
const cols = xs.map((x) => String.fromCharCode(65 + x)).join(', ');
addActivityLog(`🗑️ Removed column(s): ${cols}`);
},
onInsertCols: ({ sheet, x, numCols }) => {
addActivityLog(`➕ Inserted ${numCols} col(s) at ${String.fromCharCode(65 + x)}`);
},Use these to build audit logs, update summary stats, or sync deleted rows to a backend.
Exporting Sheet Data as TSV
The UserSheet object can be converted to a TSV string by calling toValueMatrix(sheet, { resolution: 'RESOLVED' }). This evaluates formulas and returns a 2D array of plain values.
const convertToTSV = (sheet: UserSheet): string => {
const matrix = toValueMatrix(sheet, { resolution: 'RESOLVED' });
return matrix
.map((row) =>
row
.map((cell) => {
if (cell === null || cell === undefined) return '';
const s = String(cell);
// Escape tabs and newlines within cell values
return s.replace(/\t/g, ' ').replace(/\n/g, ' ');
})
.join('\t'),
)
.join('\n');
};Call this inside onInit and onChange and store it in state to keep the TSV display populated initially and in sync with every edit.
renderNumber for Color-coded Stock Indicators
Override renderNumber to visualize numeric values with contextual color and a status badge instead of just a number.
const StockPolicyMixin: PolicyMixinType = {
renderNumber({ value }: RenderProps<number>) {
const color = value <= 10 ? '#e74c3c' : value <= 50 ? '#f39c12' : '#27ae60';
const status = value <= 10 ? 'LOW' : value <= 50 ? 'MEDIUM' : 'GOOD';
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '11px' }}>
<div style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: color }} />
<span style={{ fontWeight: 'bold', color }}>{value}</span>
<span style={{ fontSize: '9px', color: '#666' }}>({status})</span>
</div>
);
},
};renderNull for Action Buttons in Cells
renderNull is called when a cell has no value (null or undefined). Use this to render interactive elements — like a delete button — in otherwise empty cells.
const DeleteButtonPolicyMixin: PolicyMixinType = {
renderNull({ value, point, apply, sheet }: RenderProps<null | undefined>) {
if (point.y < 1) return null; // Skip the header row
return (
<button
onClick={(e) => {
e.stopPropagation();
if (apply) {
apply(sheet.removeRows({ y: point.y, numRows: 1 }));
}
}}
style={{
backgroundColor: '#e74c3c', color: 'white',
border: 'none', borderRadius: '4px',
width: '20px', height: '20px', cursor: 'pointer',
}}
>
×
</button>
);
},
};apply(sheet.removeRows(...)) commits the row deletion imperatively from within the renderer. The onRemoveRows callback fires immediately after.
Communicating Render-side Context to Event Handlers
When a button inside a renderer needs to pass extra context (e.g., the product name) to an event callback, use a CustomEvent dispatched on document.
// Inside renderNull:
const deleteEvent = new CustomEvent('productDelete', {
detail: { row: point.y, productName },
});
document.dispatchEvent(deleteEvent);
apply(sheet.removeRows({ y: point.y, numRows: 1 }));
// In the component:
React.useEffect(() => {
const handler = (e: CustomEvent) => setPendingDeleteInfo(e.detail);
document.addEventListener('productDelete', handler as EventListener);
return () => document.removeEventListener('productDelete', handler as EventListener);
}, []);The onRemoveRows handler then reads pendingDeleteInfo to include the product name in the activity log.
renderString for Color-coded Category Badges
renderString intercepts rendering of string-valued cells. Use it to turn raw category text into colored badge elements.
const CategoryPolicyMixin: PolicyMixinType = {
renderString({ value }: RenderProps<string>) {
const colors: Record<string, string> = {
Electronics: '#3498db',
Clothing: '#e67e22',
Books: '#9b59b6',
Home: '#1abc9c',
Sports: '#e74c3c',
};
const color = colors[value] ?? '#95a5a6';
return (
<span style={{
backgroundColor: color,
color: 'white',
padding: '1px 6px',
borderRadius: '8px',
fontSize: '9px',
fontWeight: 'bold',
textTransform: 'uppercase',
}}>
{value}
</span>
);
},
};The fallback '#95a5a6' (grey) handles any category not present in the map, so new categories degrade gracefully.
Composing Multiple Mixins with Named Policies
Each column gets its own Policy composed from a single mixin. Pass all named policies to useSpellbook under the policies key:
const book = useSpellbook({
policies: {
stock: new Policy({ mixins: [StockPolicyMixin] }),
category: new Policy({ mixins: [CategoryPolicyMixin] }),
delete: new Policy({ mixins: [DeleteButtonPolicyMixin] }),
},
onChange: ({ sheet }) => { ... },
onRemoveRows: ({ sheet, ys }) => { ... },
// other event handlers ...
});Then in buildInitialCells, assign the policy name to each cell or column default so the right renderer is applied per column.