VitalGrid
Guides

Data Sources Guide

This guide explains how to connect VitalGrid to your own backend using the VitalGridDataSource pattern.

This guide explains how to connect VitalGrid to your own backend using the VitalGridDataSource pattern.

The goal is to keep your table fast and flexible (virtualized, drag and drop, dynamic columns) without locking you into a specific backend.

If you have not read it yet:


What is a data source?

A data source is a small adapter that tells VitalGrid:

  • How to read rows
  • How to read columns
  • How to create/update/delete both
  • What features your backend supports
  • How to get a row ID

Everything backend-specific lives in your data source.

VitalGrid core components:

  • Do NOT call your API directly
  • Only talk to the VitalGridDataSource interface

VitalGridDataSource shape (simplified)

At a high level:

  • Rows:
    • Current list of rows
    • CRUD operations
  • Columns:
    • Current list of VitalGridColumn
    • CRUD operations
  • Capabilities:
    • Which features are supported
  • Optional:
    • Views (save/load)
    • Global loading state
    • Row ID helper

Core Data Source Interface

The VitalGridDataSource<T> interface provides the contract between your backend and VitalGrid:

  • Rows Management: Access current rows and perform CRUD operations
  • Columns Management: Manage column definitions and metadata
  • Capabilities Declaration: Tell VitalGrid which features your backend supports
  • Optional Views: Save/load functionality for user preferences
  • Row ID Resolution: Help VitalGrid identify rows uniquely

Column Structure

The VitalGridColumn interface defines how columns are represented:

  • id: Unique identifier and accessor key
  • header: Display name shown in the column header
  • type: Field type key that maps to your registered field definitions
  • settings: Opaque configuration object for the field
  • width: Optional column width in pixels
  • order: Optional display order
  • pinned: Optional left/right pinning

Feature Configuration

The FeatureConfig interface controls which table features are enabled:

  • Core Features: Sorting, filtering, column resizing
  • Interaction: Column reordering, row reordering
  • Performance: Virtualization for large datasets
  • UI Elements: Selection column, add column button
  • Advanced: View management for saved layouts

View Configuration

The ViewConfig interface allows saving user preferences:

  • Metadata: View identifier, name, and description
  • Column Overrides: Specific column settings for this view
  • Sort State: Multi-column sorting configuration
  • Filter State: Applied filter values
  • Custom Data: Additional metadata for your application

Notes:

  • VitalGridColumn is the canonical column type the grid understands.
  • For backend-specific column shapes (like Convex), convert them to/from VitalGridColumn inside your data source.

How VitalGrid uses the data source

When you call your factory hook:

const VitalGrid = createVitalGrid({ /* features, fields */ });

function MyGrid() {
  const dataSource = useMyDataSource(); // returns VitalGridDataSource<T>

  const { grid } = VitalGrid.useVitalGrid({
    dataSource,
    data: dataSource.rows.current,
    columns: dataSource.columns.current.map(VitalGrid.createColumnDef),
  });

  return (
    <VitalGrid.Provider grid={grid} workspaceId="workspace" height={600}>
      <VitalGrid.Root height={600}>
        <VitalGrid.Toolbar showViewControls={grid.features.views} />
        <VitalGrid.Header />
        <VitalGrid.Body />
      </VitalGrid.Root>
    </VitalGrid.Provider>
  );
}

VitalGrid will:

  • Use dataSource.capabilities.supportedFeatures to turn features on/off
  • Use getRowId (if provided) for stable row IDs
  • Call rows and columns operations from inside UI actions (like add column, rename, etc.) via table.options.meta

You only need to implement your data source to match the interface.


Example: Local storage data source (built-in pattern)

This is a simplified sketch of how a local storage based data source works conceptually.

File Structure

For a local storage data source, you might organize your files like this:

local.ts
types.ts
index.ts
my-local-grid.tsx

Implementation Details

Key ideas:

  • Keep rows and columns in state
  • Mirror them to storage
  • Expose the required methods
"use client";

import { useEffect, useMemo, useState } from "react";
import type { VitalGridColumn, FeatureConfig } from "@/lib/types.js";
import type { VitalGridDataSource } from "@/lib/data-sources/types.js";

export function useLocalStorageDataSource<T extends { _id?: string; id?: string }>(
  namespace: string,
): VitalGridDataSource<T> {
  const getRowId = (row: T, index: number) =>
    (row as any)._id || (row as any).id || String(index);

  const [rows, setRows] = useState<T[]>([]);
  const [columns, setColumns] = useState<VitalGridColumn[]>([]);

  // Load from localStorage once
  useEffect(() => {
    const storedRows = window.localStorage.getItem(`${namespace}_rows`);
    const storedColumns = window.localStorage.getItem(`${namespace}_columns`);

    if (storedRows) setRows(JSON.parse(storedRows));
    if (storedColumns) setColumns(JSON.parse(storedColumns));
  }, [namespace]);

  // Persist when rows/columns change
  useEffect(() => {
    window.localStorage.setItem(`${namespace}_rows`, JSON.stringify(rows));
  }, [namespace, rows]);

  useEffect(() => {
    window.localStorage.setItem(`${namespace}_columns`, JSON.stringify(columns));
  }, [namespace, columns]);

  return useMemo(
    () => ({
      rows: {
        current: rows,
        async create(data) {
          const newRow = { ...data, _id: `local_${Date.now()}` } as T;
          const next = [...rows, newRow];
          setRows(next);
          return newRow;
        },
        async update(id, updates) {
          const next = rows.map((row) =>
            getRowId(row, 0) === id ? ({ ...row, ...updates } as T) : row,
          );
          setRows(next);
        },
        async delete(id) {
          const next = rows.filter((row) => getRowId(row, 0) !== id);
          setRows(next);
        },
      },
      columns: {
        current: columns,
        async create(column) {
          setColumns((prev) => [...prev, column]);
        },
        async update(id, updates) {
          setColumns((prev) =>
            prev.map((col) => (col.id === id ? { ...col, ...updates } : col)),
          );
        },
        async delete(id) {
          setColumns((prev) => prev.filter((col) => col.id !== id));
        },
      },
      capabilities: {
        supportedFeatures: {
          sorting: true,
          filtering: true,
          views: false,
          columnReordering: true,
          rowReordering: true,
          columnResizing: true,
          virtualization: true,
          selection: true,
          addColumn: true,
        } as FeatureConfig,
      },
      getRowId,
    }),
    [rows, columns],
  );
}

This shows the pattern without pulling in all internal helpers.


Example: REST API data source

This is a simple pattern you can adapt to your own HTTP API.

API Structure

Your REST API might have these endpoints:

GET.ts
POST.ts
PATCH.ts
DELETE.ts
GET.ts
POST.ts
PATCH.ts
DELETE.ts

Frontend Integration

Assumptions:

  • Rows:
    • GET /rowsT[]
    • POST /rows → new T
    • PATCH /rows/:id
    • DELETE /rows/:id
  • Columns:
    • GET /columns → backend column schema
    • You convert backend columns ↔ VitalGridColumn in this hook
"use client";

import { useEffect, useMemo, useState } from "react";
import type { VitalGridColumn, FeatureConfig } from "@/lib/types.js";
import type { VitalGridDataSource } from "@/lib/data-sources/types.js";

interface ApiRow {
  id: string;
  [key: string]: unknown;
}

interface ApiColumn {
  id: string;
  name: string;
  type: string;
  width?: number;
}

function apiColumnToVital(col: ApiColumn): VitalGridColumn {
  return {
    id: col.id,
    header: col.name,
    type: col.type,
    width: col.width,
  };
}

function vitalToApiColumn(col: VitalGridColumn): ApiColumn {
  return {
    id: col.id,
    name: col.header,
    type: col.type,
    width: col.width,
  };
}

export function useRestDataSource(baseUrl: string): VitalGridDataSource<ApiRow> {
  const [rows, setRows] = useState<ApiRow[]>([]);
  const [columns, setColumns] = useState<VitalGridColumn[]>([]);
  const [isLoading, setIsLoading] = useState(true);

  // Initial load
  useEffect(() => {
    let cancelled = false;

    async function load() {
      setIsLoading(true);
      try {
        const [rowsRes, colsRes] = await Promise.all([
          fetch(`${baseUrl}/rows`),
          fetch(`${baseUrl}/columns`),
        ]);

        if (cancelled) return;

        const rowsJson = (await rowsRes.json()) as ApiRow[];
        const colsJson = (await colsRes.json()) as ApiColumn[];

        setRows(rowsJson);
        setColumns(colsJson.map(apiColumnToVital));
      } finally {
        if (!cancelled) setIsLoading(false);
      }
    }

    load();

    return () => {
      cancelled = true;
    };
  }, [baseUrl]);

  const getRowId = (row: ApiRow) => row.id;

  return useMemo(
    () => ({
      rows: {
        current: rows,
        isLoading,
        async create(data) {
          const res = await fetch(`${baseUrl}/rows`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(data),
          });
          const created = (await res.json()) as ApiRow;
          setRows((prev) => [...prev, created]);
          return created;
        },
        async update(id, updates) {
          await fetch(`${baseUrl}/rows/${id}`, {
            method: "PATCH",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(updates),
          });
          setRows((prev) =>
            prev.map((row) => (row.id === id ? { ...row, ...updates } : row)),
          );
        },
        async delete(id) {
          await fetch(`${baseUrl}/rows/${id}`, { method: "DELETE" });
          setRows((prev) => prev.filter((row) => row.id !== id));
        },
      },
      columns: {
        current: columns,
        async create(column) {
          const payload = vitalToApiColumn(column);
          const res = await fetch(`${baseUrl}/columns`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(payload),
          });
          const created = (await res.json()) as ApiColumn;
          setColumns((prev) => [...prev, apiColumnToVital(created)]);
        },
        async update(id, updates) {
          const col = columns.find((c) => c.id === id);
          if (!col) return;
          const payload = vitalToApiColumn({ ...col, ...updates });
          await fetch(`${baseUrl}/columns/${id}`, {
            method: "PATCH",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(payload),
          });
          setColumns((prev) =>
            prev.map((c) => (c.id === id ? { ...c, ...updates } : c)),
          );
        },
        async delete(id) {
          await fetch(`${baseUrl}/columns/${id}`, { method: "DELETE" });
          setColumns((prev) => prev.filter((c) => c.id !== id));
        },
      },
      capabilities: {
        supportedFeatures: {
          sorting: true,
          filtering: true,
          views: false,
          columnReordering: true,
          rowReordering: false,
          columnResizing: true,
          virtualization: true,
          selection: true,
          addColumn: true,
        } as FeatureConfig,
      },
      getRowId,
      isLoading,
    }),
    [rows, columns, isLoading, baseUrl],
  );
}

This example:

  • Shows where to convert your backend column format to VitalGridColumn
  • Respects the VitalGridDataSource contract
  • Keeps backend details out of VitalGrid internals

Capabilities: telling VitalGrid what you support

capabilities.supportedFeatures is how your data source tells VitalGrid what is safe to enable.

Common flags (names may vary based on your FeatureConfig):

  • sorting
  • filtering
  • views
  • columnReordering
  • rowReordering
  • columnResizing
  • virtualization
  • selection
  • addColumn

VitalGrid will:

  • Take the features you enabled in createVitalGrid
  • AND them with supportedFeatures
  • Expose the result as grid.features

You should:

  • Only mark a feature true if your data source actually supports it
  • Use grid.features in your UI when deciding to show controls

Best practices for your own data source

Use these rules as a checklist:

  1. Keep backend logic in the data source

    • Fetch rows/columns here
    • Call your API here
    • Convert backend columns ↔ VitalGridColumn here
    • Do not call your backend from grid components
  2. Always expose VitalGridColumn to the grid

    • Do not pass backend-specific column types into useVitalGrid
    • Use converter helpers when needed
  3. Implement getRowId

    • If your rows use _id, id, or something else:
      • Implement getRowId so VitalGrid has stable keys
  4. Tell the truth about supportedFeatures

    • If you cannot reorder rows in the backend:
      • Set rowReordering: false
    • If you can save views:
      • Implement views and set views: true
  5. Make it stable

    • Wrap your returned object in useMemo where possible
    • Avoid recreating functions/objects on every render without reason
  6. Look at existing adapters

    • The local storage and Convex adapters in this repo are good references
    • Follow their structure and separation of concerns

Summary

To connect VitalGrid to your backend:

  • Implement VitalGridDataSource<T> as a small adapter hook
  • Map your backend's rows and columns into the shapes VitalGrid expects
  • Declare capabilities honestly
  • Plug it into VitalGrid.useVitalGrid({ dataSource, data, columns })

Do this, and you get:

  • Virtualized, drag-and-drop grid
  • Dynamic columns based on your schema
  • Clean separation between UI and data layer
  • A setup that is easy to maintain and extend