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:
- Start with Getting Started
- See Concepts Guide for the big-picture model
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
VitalGridDataSourceinterface
VitalGridDataSource shape (simplified)
At a high level:
- Rows:
- Current list of rows
- CRUD operations
- Columns:
- Current list of
VitalGridColumn - CRUD operations
- Current list of
- 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 keyheader: Display name shown in the column headertype: Field type key that maps to your registered field definitionssettings: Opaque configuration object for the fieldwidth: Optional column width in pixelsorder: Optional display orderpinned: 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:
VitalGridColumnis the canonical column type the grid understands.- For backend-specific column shapes (like Convex), convert them to/from
VitalGridColumninside 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.supportedFeaturesto turn features on/off - Use
getRowId(if provided) for stable row IDs - Call
rowsandcolumnsoperations from inside UI actions (like add column, rename, etc.) viatable.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:
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:
Frontend Integration
Assumptions:
- Rows:
GET /rows→T[]POST /rows→ newTPATCH /rows/:idDELETE /rows/:id
- Columns:
GET /columns→ backend column schema- You convert backend columns ↔
VitalGridColumnin 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
VitalGridDataSourcecontract - 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):
sortingfilteringviewscolumnReorderingrowReorderingcolumnResizingvirtualizationselectionaddColumn
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
trueif your data source actually supports it - Use
grid.featuresin your UI when deciding to show controls
Best practices for your own data source
Use these rules as a checklist:
-
Keep backend logic in the data source
- Fetch rows/columns here
- Call your API here
- Convert backend columns ↔
VitalGridColumnhere - Do not call your backend from grid components
-
Always expose
VitalGridColumnto the grid- Do not pass backend-specific column types into
useVitalGrid - Use converter helpers when needed
- Do not pass backend-specific column types into
-
Implement
getRowId- If your rows use
_id,id, or something else:- Implement
getRowIdso VitalGrid has stable keys
- Implement
- If your rows use
-
Tell the truth about
supportedFeatures- If you cannot reorder rows in the backend:
- Set
rowReordering: false
- Set
- If you can save views:
- Implement
viewsand setviews: true
- Implement
- If you cannot reorder rows in the backend:
-
Make it stable
- Wrap your returned object in
useMemowhere possible - Avoid recreating functions/objects on every render without reason
- Wrap your returned object in
-
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