npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

zubin-grid

v0.43.0

Published

A lightweight grid state manager for React and TypeScript.

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, or Date
  • 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

react is 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 example

Useful companion scripts:

npm run example:check
npm run example:build

Quick 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'))
// 12

React 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.existsInDb tells you whether the sparse store currently has a persisted cell for that coordinate.
  • meta.isDirty tells you whether the cell differs from the last hydrated or persisted snapshot.
  • Passing a placeholder keeps 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 with persist: ['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(...), and clearGrid().
  • useCell, useCellValue, useGrid, useRowHead, useColumnHead, useRowTail, and useColumnTail from zubin-grid/adaptors/react all work with sub grids.
  • Persistence inherits from the parent when omitted, can be disabled with persist: false, or can use a custom key with persist: ['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.$inferedPosition

These 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 persistence
  • grid({ rows, columns, cells }, options) - creates a grid from JSON-friendly state
  • grid(() => ({ rows, columns, cells }), options) - lazily creates typed grid state
  • createDimensionGrid(parentGrid, initialCells, dimension, options?) - creates a row-bound or column-bound grid that stays synced with the parent axis
  • createSubGrid(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) from zubin-grid/helpers
  • reorderColumn(grid, activeColumnId, overColumnId) from zubin-grid/helpers

React adaptor

  • useCell(...) from zubin-grid/adaptors/react
  • useCellValue(...) from zubin-grid/adaptors/react
  • useGrid(grid, { onGridUpdate }) from zubin-grid/adaptors/react
  • useRowHead(...) / useColumnHead(...) from zubin-grid/adaptors/react
  • useRowTail(...) / useColumnTail(...) from zubin-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