Skip to content

Repository files navigation

gridline

A fast, virtualized data grid for React — sorting, filtering, inline editing, undo/redo, and CSV export without a framework tax.

CI npm version bundle size license


Why gridline?

Most data grid libraries fall into one of two traps: they're either heavyweight enterprise widgets with complex licensing, or they're tiny unstyled headless primitives that leave all the hard parts to you.

gridline lands in the middle. It ships a complete, production-ready grid component with every feature a real application needs, while staying small, composable, and dependency-light. The hooks are exported so you can build completely custom UIs using the same battle-tested logic underneath.

Built in the open from Stockpile, a manufacturing inventory management system that runs grids with 100k+ rows in production every day.


Features

Feature Description
Virtual scrolling Handles 100 000+ rows smoothly via react-virtuoso
Multi-column sort Shift-click headers; smart comparators for numbers, dates, semver, alphanumeric codes
Instant search Debounced full-text search across all visible columns
Constraint-aware filters Filter dropdowns that know about each other (cascading)
Column visibility Toggle columns on/off, persisted to localStorage
Row selection Single or multi-select; Shift-click range select
Inline editing Double-click any cell; bring your own editor component
Undo / redo Full edit history with Ctrl+Z / Ctrl+Y
CSV export Exports visible columns; respects active filters and selection
Persistence Column prefs and sort state survive page reloads
CSS custom properties Style via --gl-* tokens; no CSS-in-JS required
Composable hooks Every feature is a standalone hook you can use in your own UI

Installation

npm install @wjoffe93/gridline

Peer dependencies

npm install react react-dom react-virtuoso

Quick start

import { Grid } from '@wjoffe93/gridline';
import type { ColumnDef } from '@wjoffe93/gridline';

interface Employee {
  id: number;
  name: string;
  department: string;
  salary: number;
  startDate: string;
}

const columns: ColumnDef<Employee>[] = [
  { key: 'name',       header: 'Name',        sortable: true  },
  { key: 'department', header: 'Department',  sortable: true, filterable: true },
  { key: 'salary',     header: 'Salary',      align: 'right',
    render: v => `$${Number(v).toLocaleString()}` },
  { key: 'startDate',  header: 'Start date',  sortable: true  },
];

export default function EmployeeTable({ employees }: { employees: Employee[] }) {
  return (
    <Grid
      data={employees}
      columns={columns}
      rowKey="id"
      height={600}
      searchable
      exportable
      selectable
      persistKey="employees"
    />
  );
}

Column definitions

Every column is a ColumnDef<T> object. The only required fields are key and header.

interface ColumnDef<T> {
  key: string;             // Field name or dot-path ("address.city")
  header: string;          // Header label

  // Layout
  width?: number;          // Fixed width in px
  minWidth?: number;       // Min width when resizing (default 60)
  align?: 'left' | 'center' | 'right';
  pin?: 'left' | 'right';

  // Behaviour
  sortable?: boolean;      // default true
  filterable?: boolean;    // participates in filter panel
  editable?: boolean | ((row: T) => boolean);
  hidden?: boolean;        // hidden by default (user can re-show)
  exportable?: boolean;    // included in CSV export (default true)

  // Rendering
  render?: (value, row, ctx) => React.ReactNode;
  editor?: (props: EditorProps<T>) => React.ReactNode;
  tooltip?: boolean | ((value, row) => string);
  overflow?: 'ellipsis' | 'wrap' | 'clip';

  // Data
  getValue?: (row: T) => unknown;       // computed / derived values
  comparator?: (a, b, dir) => number;  // custom sort logic
  validate?: (value, row) => string | null;
}

Grid props

<Grid
  // Required
  data={rows}
  columns={columns}
  rowKey="id"              // or (row) => row.id.toString()

  // Dimensions
  height={500}             // required for virtual scrolling
  width="100%"

  // Features (all false/disabled by default)
  searchable
  sortable                 // true by default
  filterable
  selectable               // or "single" / "multi"
  editable
  undoable                 // enables Ctrl+Z for edits
  exportable
  resizable
  reorderable

  // Events
  onRowClick={(row, index, event) => {}}
  onSelectionChange={(selectedKeys, selectedRows) => {}}
  onSortChange={(sort) => {}}
  onEdit={(rowKey, colKey, next, prev) => true}  // return false to reject

  // Toolbar
  toolbarLeading={<MyCustomButton />}
  toolbarTrailing={<ColumnPicker />}

  // Persistence
  persistKey="my-grid"    // namespaces localStorage keys

  // Export
  exportFilename="employees-2024"

  // Initial / default state
  defaultSort={[{ key: 'name', direction: 'asc' }]}
  defaultHiddenColumns={['internalId']}
/>

Custom cell rendering

const columns: ColumnDef<Order>[] = [
  {
    key: 'status',
    header: 'Status',
    render: (value) => (
      <span className={`badge badge-${value}`}>
        {String(value)}
      </span>
    ),
  },
  {
    key: 'revenue',
    header: 'Revenue',
    align: 'right',
    render: (value) => new Intl.NumberFormat('en-US', {
      style: 'currency', currency: 'USD',
    }).format(Number(value)),
    comparator: (a, b, dir) => {
      const diff = Number(a) - Number(b);
      return dir === 'asc' ? diff : -diff;
    },
  },
];

Inline editing

import type { EditorProps, ColumnDef } from '@wjoffe93/gridline';

function SelectEditor<T>({ value, onCommit, onCancel }: EditorProps<T>) {
  return (
    <select
      autoFocus
      defaultValue={String(value)}
      onChange={e => onCommit(e.target.value)}
      onKeyDown={e => e.key === 'Escape' && onCancel()}
    >
      <option value="active">Active</option>
      <option value="inactive">Inactive</option>
    </select>
  );
}

const col: ColumnDef<User> = {
  key: 'status',
  header: 'Status',
  editable: true,
  editor: SelectEditor,
  validate: (v) => ['active', 'inactive'].includes(String(v)) ? null : 'Invalid status',
};

Enable editing on the grid and optionally hook into the commit event:

<Grid
  editable
  undoable
  onEdit={async (rowKey, colKey, next, prev) => {
    const ok = await api.updateField(rowKey, colKey, next);
    return ok; // return false to roll back the edit
  }}
/>

Using hooks directly

Every feature is a standalone hook. Use them to build completely custom table UIs:

import {
  useSort, useFilter, useSearch,
  useColumnVisibility, useRowSelection,
  sortRows, filterRows, searchRows,
} from '@wjoffe93/gridline';

function MyCustomTable({ data, columns }) {
  const { sort, onHeaderClick, getSortDir } = useSort({ persistKey: 'my-table' });
  const { filters, setFilter } = useFilter({});
  const { debouncedValue: search, setSearch } = useSearch({});
  const { visibleColumns, toggle } = useColumnVisibility(columns);
  const { selectedKeys, toggle: select } = useRowSelection(data, r => r.id);

  const rows = searchRows(filterRows(sortRows(data, sort, columns), filters, columns), search, columns);

  return (
    <div>
      <input onChange={e => setSearch(e.target.value)} placeholder="Search…" />
      {/* your own table JSX here */}
    </div>
  );
}

Theming

gridline uses CSS custom properties. Override them to match your design system:

.my-grid {
  --gl-row-height: 40px;
  --gl-header-height: 36px;
  --gl-border: #e2e8f0;
  --gl-header-bg: #f8fafc;
  --gl-header-color: #475569;
  --gl-selected-bg: #dbeafe;
  --gl-cell-x: 14px;       /* horizontal cell padding */
}

Docs


Contributing

See CONTRIBUTING.md. PRs welcome — please open an issue first for large changes.


License

MIT

About

Fast virtualized React data grid with sorting, filtering, inline editing, undo/redo, and CSV export as composable hooks

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages