zubin-grid
v0.43.0
Published
A lightweight grid state manager for React and TypeScript.
Maintainers
Readme
zubin-grid
A lightweight grid state manager for React and TypeScript.
zubin-grid helps you model a 2D grid of reactive cells, update row and column headers, and compute derived row or column summaries with simple hooks.
The root package now stays framework-agnostic. React hooks live on the zubin-grid/adaptors/react subpath.
Why use it?
- Reactive cell store with subscriptions
- Flexible row and column ids with
string,number,boolean, orDate - Typed grid API for rows, columns, heads, and tails
- Row-bound and column-bound dimension grids for axis metadata
- React hooks for reading and updating state
- Row and column reordering helpers
- JSON-friendly initialization with typed row, column, and cell records
Installation
npm install zubin-grid react
reactis a peer dependency. Hooks are designed for React 18+.
Local example app
This repository also includes a small Vite + React playground under examples/ so you can try the JSON-friendly grid API, sparse-cell editing, elastic row/column resizing, persistence, demo controls, and a Conway's Game of Life reactivity showcase locally.
npm install
npm run exampleUseful companion scripts:
npm run example:check
npm run example:buildQuick start
Create a grid from JSON-friendly row, column, and cell records:
import { grid } from 'zubin-grid'
type SalesSchema = {
rows: { id: string; label: string }[]
columns: { id: string; label: string }[]
cells: { rowId: string; columnId: string; value: number }[]
}
const initialState: SalesSchema = {
rows: [
{ id: 'north', label: 'North' },
{ id: 'south', label: 'South' },
],
columns: [
{ id: 'jan', label: 'January' },
{ id: 'feb', label: 'February' },
],
cells: [
{ rowId: 'north', columnId: 'jan', value: 12 },
{ rowId: 'north', columnId: 'feb', value: 9 },
{ rowId: 'south', columnId: 'jan', value: 7 },
{ rowId: 'south', columnId: 'feb', value: 15 },
],
}
const salesGrid = grid<SalesSchema>(initialState, {
rowHeaders: ['id', 'rowId'],
colHeaders: ['id', 'columnId'],
})
console.log(salesGrid.getValue('north', 'jan'))
// 12React example
Create the grid once outside render, or memoize it if you build it inside a component.
import { grid } from 'zubin-grid'
import { useCell } from 'zubin-grid/adaptors/react'
type BudgetRowId = 'marketing' | 'ops'
type BudgetColumnId = 'planned' | 'actual'
type BudgetSchema = {
rows: { id: BudgetRowId; label: string }[]
columns: { id: BudgetColumnId; label: string }[]
cells: {
rowId: BudgetRowId
columnId: BudgetColumnId
value: number
}[]
}
const budgetGrid = grid<BudgetSchema>(
{
rows: [
{ id: 'marketing', label: 'Marketing' },
{ id: 'ops', label: 'Operations' },
],
columns: [
{ id: 'planned', label: 'Planned' },
{ id: 'actual', label: 'Actual' },
],
cells: [
{ rowId: 'marketing', columnId: 'planned', value: 1000 },
{ rowId: 'marketing', columnId: 'actual', value: 1200 },
{ rowId: 'ops', columnId: 'planned', value: 800 },
{ rowId: 'ops', columnId: 'actual', value: 950 },
],
},
{
rowHeaders: ['id', 'rowId'],
colHeaders: ['id', 'columnId'],
},
)
export function BudgetCell(props: {
row: number
col: number
}) {
const [value, setValue, meta] = useCell(
budgetGrid,
{ row: props.row, col: props.col },
{
placeholder: ({ rowId, columnId }) => ({
rowId,
columnId,
value: 0,
}),
},
)
return (
<label>
<input
type="number"
value={value.value}
onChange={(event) =>
setValue({
...value,
value: Number(event.target.value),
})
}
/>
<small>{meta.existsInDb ? 'stored' : 'placeholder'}</small>
</label>
)
}useCell(...) now returns [value, setValue, meta].
meta.existsInDbtells you whether the sparse store currently has a persisted cell for that coordinate.meta.isDirtytells you whether the cell differs from the last hydrated or persisted snapshot.- Passing a
placeholderkeeps the UI dense even when the store is sparse. - Calling
setValue(undefined)removes that sparse cell entry again.
For row-bound and column-bound dimension grids, pass { row } or { col } respectively. useCellValue(...) follows the same sparse read rules but returns only the current value.
Working with heads
Headers are stored separately from cell values, so labels and order can change without rebuilding the grid.
import { useColumnHead, useRowHead } from 'zubin-grid/adaptors/react'
export function HeaderControls() {
const { head: rowHead, updateLabel } = useRowHead(budgetGrid, 'marketing')
const { head: columnHead, updateOrder } = useColumnHead(budgetGrid, 'actual')
return (
<div>
<button onClick={() => updateLabel('Marketing Team')}>
Rename row: {rowHead.label}
</button>
<button onClick={() => updateOrder(0)}>
Move column first: {columnHead.label}
</button>
</div>
)
}Derived row and column tails
Tails let you compute summaries such as totals.
import { useRowTail } from 'zubin-grid/adaptors/react'
export function MarketingRowTotal() {
const total = useRowTail(budgetGrid, 'marketing', (cells) => {
return cells.reduce((sum, currentCell) => sum + currentCell.value, 0)
})
return <strong>Total: {total ?? 0}</strong>
}Reordering rows and columns
useGrid now stays focused on reading the ordered row and column ids. Mutating helpers such as reorderRow and reorderColumn live on the zubin-grid/helpers subpath, and the React hooks themselves live on zubin-grid/adaptors/react.
import { useGrid } from 'zubin-grid/adaptors/react'
import { reorderColumn, reorderRow } from 'zubin-grid/helpers'
export function GridToolbar() {
const { rows, cols } = useGrid(budgetGrid)
return (
<div>
<div>Rows: {rows.join(', ')}</div>
<div>Columns: {cols.join(', ')}</div>
<button onClick={() => reorderRow(budgetGrid, 'ops', 'marketing')}>
Move ops above marketing
</button>
<button onClick={() => reorderColumn(budgetGrid, 'actual', 'planned')}>
Move actual first
</button>
</div>
)
}useGrid also accepts an optional onGridUpdate callback so React components can listen to every grid mutation with the current grid reference plus a structured diff.
const { rows, cols } = useGrid(budgetGrid, {
onGridUpdate: (nextGrid, diff) => {
console.log(nextGrid.getState(), diff)
},
})Outside React, use budgetGrid.subscribeGrid((grid, diff) => { ... }).
Full-grid updates and reset helpers
Use setGrid when you want to work with full snapshots instead of one row, column, or cell at a time.
salesGrid.setGrid({
rows: [{ id: 'west', label: 'West', order: 2 }],
columns: [{ id: 'mar', label: 'March', order: 2 }],
cells: [{ rowId: 'west', columnId: 'mar', value: 21 }],
}, 'update')
salesGrid.setGrid({
rows: [],
columns: [],
cells: [],
}, 'replace')"update"merges incoming rows, columns, and cells into the existing grid."replace"swaps the current rows, columns, and cells with the incoming snapshot.
Reset helpers are available for the common clear flows:
salesGrid.clearCells()
salesGrid.clearGrid()clearCells()removes current cell entries while preserving row/column heads and tail registrations.clearGrid()clears rows, columns, cells, and tail registrations.
Creating a grid from JSON-friendly state
If your data already exists as JSON-like records, you can create the grid from a single schema object.
grid only accepts this schema-based initializer form now.
import { grid } from 'zubin-grid'
type SalesSchema = {
rows: { id: string; label: string }[]
columns: { id: string; label: string }[]
cells: { rowId: string; columnId: string; value: number }[]
}
const initialState: SalesSchema = {
rows: [
{ id: 'north', label: 'North' },
{ id: 'south', label: 'South' },
],
columns: [
{ id: 'jan', label: 'January' },
{ id: 'feb', label: 'February' },
],
cells: [
{ rowId: 'north', columnId: 'jan', value: 12 },
{ rowId: 'north', columnId: 'feb', value: 9 },
{ rowId: 'south', columnId: 'jan', value: 7 },
{ rowId: 'south', columnId: 'feb', value: 15 },
],
}
const salesGrid = grid<SalesSchema>(initialState, {
rowHeaders: ['id', 'rowId'],
colHeaders: ['id', 'columnId'],
})You can also lazily create that state with a function, which is handy when you want explicit typing during bootstrap:
const emptySalesGrid = grid<SalesSchema>(() => ({
rows: [],
columns: [],
cells: [],
}), {
rowHeaders: ['id', 'rowId'],
colHeaders: ['id', 'columnId'],
})You can keep the initializer even lighter and let missing arrays default to []:
const bootstrappedSalesGrid = grid<SalesSchema>({}, {
rowHeaders: ['id', 'rowId'],
colHeaders: ['id', 'columnId'],
})Flexible row and column ids
zubin-grid exports GridId as string | number | boolean | Date.
Each row id must still be unique within the row axis, and each column id must be unique within the column axis. Date ids are compared by timestamp rather than object identity, so recreating the same date value still points at the same row or column.
const april14 = new Date('2026-04-14T00:00:00.000Z')
const availabilityGrid = grid({
rows: [
{ id: true, label: 'Available' },
{ id: false, label: 'Unavailable' },
],
columns: [
{ id: april14, label: 'April 14' },
],
cells: [
{ rowId: true, columnId: april14, value: 'x' },
],
}, {
rowHeaders: ['id', 'rowId'],
colHeaders: ['id', 'columnId'],
})
availabilityGrid.getValue(true, new Date('2026-04-14T00:00:00.000Z'))
// 'x'Non-reactive snapshots and upserts
grid.getState() returns a plain snapshot of the current rows, columns, and cells without subscribing React to anything.
const snapshot = salesGrid.getState()
const northFebruary = salesGrid.readCell('north', 'feb')
// { value: undefined, meta: { existsInDb: false, isDirty: false } }
salesGrid.upsertRows([{ id: 'west', label: 'West' }])
salesGrid.upsertColumns([{ id: 'mar', label: 'March' }])
salesGrid.upsertCell({ rowId: 'west', columnId: 'mar', value: 21 })
salesGrid.setCell('west', 'apr', { rowId: 'west', columnId: 'apr', value: 13 })
salesGrid.setCell('west', 'apr', undefined)readCell(rowId, columnId) is the sparse-aware getter, and setCell(rowId, columnId, nextValue) lazily creates or removes sparse cell entries. getValue, getRowHead, and getColumnHead remain strict non-reactive getters when you only need an already-materialized cell or a targeted header read.
Dimension grids
Use createDimensionGrid when you want one reactive cell per row or one reactive cell per column instead of a full row/column matrix. This is handy for statuses, visibility flags, notes, or other metadata attached to a single axis.
import { createDimensionGrid } from 'zubin-grid'
import { useCell } from 'zubin-grid/adaptors/react'
type ShipmentStatus = 'pending' | 'packed' | 'shipped'
const rowStatusGrid = createDimensionGrid<ShipmentStatus>(salesGrid, [], 'rows')
const columnStatusGrid = createDimensionGrid<ShipmentStatus>(
salesGrid,
['pending'],
'columns',
{ persist: false },
)
rowStatusGrid.getValue(0)
rowStatusGrid.setGrid(['packed', 'shipped'], 'update')
function RowStatus(props: { row: number }) {
const [status, setStatus] = useCell(rowStatusGrid, { row: props.row })
return (
<select
value={status ?? ''}
onChange={(event) => setStatus(event.target.value as ShipmentStatus)}
>
<option value="">Unset</option>
<option value="pending">Pending</option>
<option value="packed">Packed</option>
<option value="shipped">Shipped</option>
</select>
)
}createDimensionGrid(parentGrid, initialCells, 'rows' | 'columns', options?) keeps its size and ordering in sync with the chosen parent axis.
- Row dimension grids use
useCell(rowDimensionGrid, { row }). - Column dimension grids use
useCell(columnDimensionGrid, { col }). setGrid(...)accepts either a plain array or{ dimension, cells }.- Persistence inherits from the parent by default, can be disabled with
persist: false, or overridden withpersist: ['your-key'].
Sub grids
Use createSubGrid when you want a second grid layer that reuses the parent grid's row and column dimensions, but stores a different cell shape.
import { createSubGrid } from 'zubin-grid'
import { useCell } from 'zubin-grid/adaptors/react'
type SalesNoteCell = {
note: string
dirty?: boolean
}
const salesNotesGrid = createSubGrid<SalesNoteCell>(salesGrid, [], {
persist: ['sales-notes'],
})
function SalesNote(props: {
row: number
col: number
}) {
const [noteCell, setNoteCell] = useCell(salesNotesGrid, {
row: props.row,
col: props.col,
})
return (
<input
value={noteCell.note}
onChange={(event) =>
setNoteCell({
...noteCell,
note: event.target.value,
dirty: true,
})
}
/>
)
}Sub grids behave like normal grids for cell and hook usage, with a few important rules:
- They inherit the parent grid's row ids, column ids, row heads, and column heads.
- Parent row and column changes stay in sync, including upserts, label changes, order changes,
setGrid(...), andclearGrid(). useCell,useCellValue,useGrid,useRowHead,useColumnHead,useRowTail, anduseColumnTailfromzubin-grid/adaptors/reactall work with sub grids.- Persistence inherits from the parent when omitted, can be disabled with
persist: false, or can use a custom key withpersist: ['your-key']. - Row and column mutations called on a sub grid are forwarded to the parent grid.
clearGrid()on a sub grid clears the sub grid's own cells while keeping the inherited parent dimensions intact.
Pass seeded cells as the second argument and persistence as the optional third argument:
const salesFlagsGrid = createSubGrid<SalesNoteCell>(salesGrid, [
{ rowId: 'north', columnId: 'jan', value: { note: 'Review' } },
], {
persist: ['sales-flags'],
})Persistence
Use persist to cache the current grid snapshot under a storage key. A custom adapter can be provided, otherwise zubin-grid falls back to a default async browser storage implementation with a runtime cache.
const persistedSalesGrid = grid<SalesSchema>(initialState, {
rowHeaders: ['id', 'rowId'],
colHeaders: ['id', 'columnId'],
persist: ['sales-grid'],
})
const theme = cell<'dark' | 'light'>('light', {
persist: 'theme',
})Custom adapters receive get, set, and remove methods:
const persistedWithCustomAdapter = grid<SalesSchema>(initialState, {
rowHeaders: ['id', 'rowId'],
colHeaders: ['id', 'columnId'],
persist: [
'sales-grid',
{
get: async (key) => window.myStore.get(key) ?? null,
set: async (key, value) => {
await window.myStore.set(key, value)
},
remove: async (key) => {
await window.myStore.remove(key)
},
},
],
})Standalone cells accept either a string storage key or the same tuple form as grids:
const persistedTheme = cell<'dark' | 'light'>('light', {
persist: [
'theme',
{
get: async (key) => window.myStore.get(key) ?? null,
set: async (key, value) => {
await window.myStore.set(key, value)
},
remove: async (key) => {
await window.myStore.remove(key)
},
},
],
})Inferred grid helper types
Grid and dimension-grid instances expose phantom properties that are handy for related helper types:
type BudgetCell = typeof budgetGrid.$inferedCell
type BudgetPosition = typeof budgetGrid.$inferedPosition
type RowStatusCell = typeof rowStatusGrid.$inferedCell
type RowStatusPosition = typeof rowStatusGrid.$inferedPositionThese are type-only markers; they exist so you can cheaply derive the cell and position shapes from an existing grid instance.
API overview
Store creators
cell(initialValue, options?)- creates a reactive cell, optionally with standalone persistencegrid({ rows, columns, cells }, options)- creates a grid from JSON-friendly stategrid(() => ({ rows, columns, cells }), options)- lazily creates typed grid statecreateDimensionGrid(parentGrid, initialCells, dimension, options?)- creates a row-bound or column-bound grid that stays synced with the parent axiscreateSubGrid(parentGrid, initialCells?, options?)- creates a child grid with its own cells while inheriting the parent grid dimensions
Cell hooks
useCell(cell)useCell(grid, { row, col })useCell(rowDimensionGrid, { row })useCell(columnDimensionGrid, { col })useCellValue(...)
All React hooks are exported from zubin-grid/adaptors/react, not the root package.
Head hooks
useRowHead(grid, rowId)useColumnHead(grid, columnId)
Tail hooks
useRowTail(grid, rowId, updater)useColumnTail(grid, columnId, updater)
Grid helpers
createGridKey(rowId, columnId)grid.getState()grid.readCell(rowId, columnId)grid.setGrid(nextState, mode?)grid.setCell(rowId, columnId, nextValue)grid.upsertRow(...)grid.upsertRows(...)grid.upsertColumn(...)grid.upsertColumns(...)grid.upsertCell(...)grid.upsertCells(...)grid.clearCells()grid.clearGrid()grid.subscribeGrid((grid, diff) => { ... })
Helper subpath
reorderRow(grid, activeRowId, overRowId)fromzubin-grid/helpersreorderColumn(grid, activeColumnId, overColumnId)fromzubin-grid/helpers
React adaptor
useCell(...)fromzubin-grid/adaptors/reactuseCellValue(...)fromzubin-grid/adaptors/reactuseGrid(grid, { onGridUpdate })fromzubin-grid/adaptors/reactuseRowHead(...)/useColumnHead(...)fromzubin-grid/adaptors/reactuseRowTail(...)/useColumnTail(...)fromzubin-grid/adaptors/react
Imports
Use the root package for framework-agnostic state primitives:
import {
cell,
createDimensionGrid,
createSubGrid,
grid,
} from 'zubin-grid'Use the React adaptor for hooks:
import {
useCell,
useCellValue,
useRowHead,
useColumnHead,
useRowTail,
useColumnTail,
useGrid,
} from 'zubin-grid/adaptors/react'Subpath imports are also available:
import { grid } from 'zubin-grid/grid'
import { reorderColumn, reorderRow } from 'zubin-grid/helpers'
import { useRowTail } from 'zubin-grid/adaptors/react'Links
License
MIT
