gp-grid-logo
Guides

Server-Side Data

Integrate the React grid with server-side sorting, filtering, and windowed row loading by implementing a remote data source that streams chunks on-demand efficiently.

For very large datasets or when data must remain on the server, use server-side data operations.

When to Use Server-Side

  • Dataset too large to load into browser memory
  • Data requires real-time updates from server
  • Complex filtering/sorting logic on server
  • Security requirements to keep data server-side

Basic Setup

import { Grid, createServerDataSource } from "@gp-grid/react";

const dataSource = createServerDataSource(async (request) => {
  const response = await fetch("/api/grid-data", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(request),
  });

  return response.json();
});

function ServerGrid() {
  return (
    <Grid
      columns={columns}
      dataSource={dataSource}
      rowHeight={36}
    />
  );
}

Request Format

The server receives a request describing the absolute row range, plus sort/filter state. endRow is exclusive, so endRow - startRow is the number of rows to return.

interface DataSourceRequest {
  range: {
    startRow: number; // First row index to fetch (0-indexed, inclusive)
    endRow: number;   // First row index after the range (exclusive)
  };
  sort?: SortModel[];   // Sort configuration
  filter?: FilterModel; // Filter configuration
}

// Example request — the grid asks for rows 0..99 sorted by name asc
{
  range: { startRow: 0, endRow: 100 },
  sort: [{ colId: "name", direction: "asc" }],
  filter: {
    salary: {
      groups: [{
        conditions: [{ type: "number", operator: ">", value: 50000 }],
        combination: "and"
      }],
      combination: "and"
    }
  }
}

Response Format

Return rows and total count:

interface DataSourceResponse<TData> {
  rows: TData[];    // Current page of data
  totalRows: number; // Total rows matching filters
}

// Example response
{
  rows: [
    { id: 1, name: "Giovanni", salary: 75000 },
    { id: 2, name: "Luca", salary: 82000 },
    // ...
  ],
  totalRows: 10000
}

Express.js Example

// server.ts
import express from "express";

app.post("/api/grid-data", async (req, res) => {
  const { range, sort, filter } = req.body;

  // Build database query
  let query = db.select().from(employees);

  // Apply filters
  if (filter) {
    for (const [field, model] of Object.entries(filter)) {
      // Translate each condition to a database predicate, join conditions
      // with group.combination, then join groups with model.combination.
      query = query.where(buildColumnPredicate(field, model));
    }
  }

  // Apply sorting
  if (sort?.length) {
    for (const s of sort) {
      query = query.orderBy(s.colId, s.direction);
    }
  }

  // Get total count
  const totalRows = await query.clone().count();

  // Apply range (endRow is exclusive)
  const { startRow, endRow } = range;
  query = query.offset(startRow).limit(endRow - startRow);

  const rows = await query;

  res.json({ rows, totalRows });
});

buildColumnPredicate is application-specific because query builders expose different APIs. It must preserve both Boolean levels: group.combination inside each group and model.combination between groups.

Handling Sort Models

// SortModel structure
type SortModel = {
  colId: string;
  direction: "asc" | "desc";
};

// Multiple columns (for multi-sort)
[
  { colId: "department", direction: "asc" },
  { colId: "salary", direction: "desc" }
]

Handling Filter Models

// FilterModel structure
type FilterModel = Record<string, ColumnFilterModel>;

interface ColumnFilterModel {
  groups: FilterConditionGroup[];
  combination: "and" | "or"; // joins groups
}

interface FilterConditionGroup {
  conditions: FilterCondition[];
  combination: "and" | "or"; // joins conditions in this group
}

// Example: salary > 50000 AND salary < 100000
const salaryFilter: FilterModel = {
  salary: {
    groups: [{
      conditions: [
        { type: "number", operator: ">", value: 50000 },
        { type: "number", operator: "<", value: 100000 },
      ],
      combination: "and",
    }],
    combination: "and",
  },
};

// Example: (starts with A AND ends with z) OR equals Giovanni
const nameFilter: FilterModel = {
  name: {
    groups: [
      {
        conditions: [
          { type: "text", operator: "startsWith", value: "A" },
          { type: "text", operator: "endsWith", value: "z" },
        ],
        combination: "and",
      },
      {
        conditions: [
          { type: "text", operator: "equals", value: "Giovanni" },
        ],
        combination: "and",
      },
    ],
    combination: "or",
  },
};

// Example: values (checkbox) filter — selectedValues holds RAW cell values
const statusFilter: FilterModel = {
  status: {
    groups: [{
      conditions: [{
        type: "text",
        operator: "equals",
        selectedValues: new Set([0, 1]), // raw values, never display labels
        includeBlank: false, // true when the "(Blanks)" entry is ticked
      }],
      combination: "and",
    }],
    combination: "and",
  },
};

ColumnFilterModel.combination joins groups, while each group's combination joins its conditions. Filters for separate columns are always combined with AND.

The legacy flat shape is still accepted by GridCore.setFilter() and normalized without changing its left-to-right result. getFilterModel() and DataSourceRequest.filter always expose the canonical grouped shape.

selectedValues always contains raw cell values: a valueFormatter on the column changes only what the popup displays, never what your server receives. Match them directly against your database column (e.g. WHERE status IN (...)), and treat includeBlank: true as "also include NULL/empty rows".

selectedValues is a Set, and JSON.stringify serializes a Set as {}. Convert it before sending the request over the wire:

const serializeFilter = (filter: FilterModel) =>
  Object.fromEntries(
    Object.entries(filter).map(([field, model]) => [
      field,
      {
        ...model,
        groups: model.groups.map((group) => ({
          ...group,
          conditions: group.conditions.map((condition) =>
            "selectedValues" in condition && condition.selectedValues
              ? {
                  ...condition,
                  selectedValues: [...condition.selectedValues],
                }
              : condition,
          ),
        })),
      },
    ]),
  );

Error Handling

const dataSource = createServerDataSource(async (request) => {
  try {
    const response = await fetch("/api/grid-data", {
      method: "POST",
      body: JSON.stringify(request),
    });

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    return response.json();
  } catch (error) {
    console.error("Failed to fetch grid data:", error);
    // Return empty result on error
    return { rows: [], totalRows: 0 };
  }
});

Caching Considerations

For better UX, consider caching pages:

const cache = new Map<string, DataSourceResponse>();

const dataSource = createServerDataSource(async (request) => {
  const cacheKey = JSON.stringify(request);

  if (cache.has(cacheKey)) {
    return cache.get(cacheKey)!;
  }

  const result = await fetchFromServer(request);
  cache.set(cacheKey, result);

  return result;
});

Clear cache when data changes on the server.

On this page