Skip to content

This tutorial shows two GraphQL approaches for Handsontable. The first approach loads data on the client with fetch, loadData(), and updateData(). The second approach uses the server-side DataProvider architecture for paging, sorting, filtering, and CRUD callbacks.

TypeScript
/* file: app.component.ts */
import { Component, OnInit } from '@angular/core';
import { GridSettings, HotTableModule } from '@handsontable/angular-wrapper';
type ApiUser = {
id: number;
name: string;
username: string;
email: string;
address?: { city?: string };
company?: { name?: string };
};
type UserRow = {
id: number;
name: string;
username: string;
email: string;
city: string;
company: string;
};
const USERS_QUERY = `
query {
users {
data {
id
name
username
email
address {
city
}
company {
name
}
}
}
}
`;
@Component({
standalone: true,
imports: [HotTableModule],
selector: 'example1-load-data-graphql',
template: `
<div>
<div style="display: flex; gap: 12px; align-items: center; margin-bottom: 8px;">
<p
style="margin: 0; font-family: Arial, sans-serif; font-size: 14px;"
[style.color]="hasError ? '#c62828' : '#202124'"
>
{{ status }}
</p>
@if (hasError) {
<button type="button" (click)="loadUsers()">Retry</button>
}
</div>
<hot-table [data]="rows" [settings]="gridSettings"></hot-table>
</div>
`,
})
export class AppComponent implements OnInit {
status = 'Loading users...';
hasError = false;
rows: UserRow[] = [];
readonly gridSettings: GridSettings = {
colHeaders: ['ID', 'Name', 'Username', 'Email', 'City', 'Company'],
columns: [
{ data: 'id', type: 'numeric', width: 70 },
{ data: 'name', type: 'text', width: 190 },
{ data: 'username', type: 'text', width: 150 },
{ data: 'email', type: 'text', width: 220 },
{ data: 'city', type: 'text', width: 140 },
{ data: 'company', type: 'text', width: 180 },
],
rowHeaders: true,
height: 360,
width: '100%',
stretchH: 'all',
autoWrapRow: true,
};
ngOnInit(): void {
this.loadUsers();
}
async loadUsers(): Promise<void> {
this.status = 'Loading users...';
this.hasError = false;
try {
const users = await this.fetchUsers();
this.rows = this.mapUsersToGridRows(users);
this.status = 'Loaded users from GraphQL API.';
} catch (_error) {
this.rows = [];
this.hasError = true;
this.status = 'Failed to load users. Try again.';
}
}
private async fetchUsers(): Promise<ApiUser[]> {
const response = await fetch('https://graphqlzero.almansi.me/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: USERS_QUERY }),
});
if (!response.ok) {
throw new Error(`Request failed with status: ${response.status}`);
}
const payload = (await response.json()) as {
data?: { users?: { data?: ApiUser[] } };
errors?: Array<{ message?: string }>;
};
// GraphQL APIs can return HTTP 200 and still include execution errors.
if (payload.errors?.length) {
throw new Error(payload.errors[0]?.message ?? 'GraphQL request failed.');
}
return payload.data?.users?.data ?? [];
}
private mapUsersToGridRows(users: ApiUser[]): UserRow[] {
return users.map((user) => ({
id: user.id,
name: user.name,
username: user.username,
email: user.email,
city: user.address?.city ?? '',
company: user.company?.name ?? '',
}));
}
}
/* end-file */
/* file: app.config.ts */
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { registerAllModules } from 'handsontable/registry';
import { HOT_GLOBAL_CONFIG, HotGlobalConfig, NON_COMMERCIAL_LICENSE } from '@handsontable/angular-wrapper';
registerAllModules();
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
{
provide: HOT_GLOBAL_CONFIG,
useValue: { license: NON_COMMERCIAL_LICENSE } as HotGlobalConfig,
},
],
};
/* end-file */
HTML
<div><example1-load-data-graphql></example1-load-data-graphql></div>

Approach 1 - Client-side fetch with loadData() and updateData()

  • Sending a GraphQL POST request to https://graphqlzero.almansi.me/api from the browser.
  • Initializing Handsontable with an empty dataset.
  • Filling the table with hot.loadData() when the response arrives.
  • Showing loading, success, and error states in the interface.
  • Defining a column configuration that matches API fields.

How it works

  1. Create Handsontable with data: [].
  2. Create a status element and retry button above the grid.
  3. Start loadUsers() and set status to loading.
  4. Send a GraphQL query and map nested fields (company.name, address.city) to flat row objects.
  5. Call hot.loadData(rows) and show a success message.
  6. If the request fails, show an error message and keep the table empty.

Using updateData() to preserve sorting and other states

The first example resets all grid state on every data load - column sort order, selection, and column order all go back to defaults. This is fine when no user state exists yet, but it creates a jarring experience in a running app where the user has already sorted or filtered the data.

hot.updateData() replaces the dataset while keeping every registered grid state intact. The second example demonstrates this: sort any column by clicking its header, then click Refresh. The sort order survives the data update.

TypeScript
/* file: app.component.ts */
import { Component, ViewChild, AfterViewInit } from '@angular/core';
import { GridSettings, HotTableComponent, HotTableModule } from '@handsontable/angular-wrapper';
type ApiUser = {
id: number;
name: string;
username: string;
email: string;
address?: { city?: string };
company?: { name?: string };
};
type UserRow = {
id: number;
name: string;
username: string;
email: string;
city: string;
company: string;
};
const USERS_QUERY = `
query {
users {
data {
id
name
username
email
address {
city
}
company {
name
}
}
}
}
`;
@Component({
standalone: true,
imports: [HotTableModule],
selector: 'example2-load-data-graphql',
template: `
<div>
<div style="display: flex; gap: 12px; align-items: center; margin-bottom: 8px;">
<p
style="margin: 0; font-family: Arial, sans-serif; font-size: 14px;"
[style.color]="hasError ? '#c62828' : '#202124'"
>
{{ status }}
</p>
@if (showRefresh && !hasError) {
<button type="button" (click)="refreshUsers()" style="margin-bottom: 0">Refresh</button>
}
@if (hasError) {
<button type="button" (click)="initialLoad()" style="margin-bottom: 0">Retry</button>
}
</div>
<hot-table [settings]="gridSettings"></hot-table>
</div>
`,
})
export class AppComponent implements AfterViewInit {
@ViewChild(HotTableComponent, { static: false }) readonly hotTable!: HotTableComponent;
status = 'Loading users...';
hasError = false;
showRefresh = false;
readonly gridSettings: GridSettings = {
colHeaders: ['ID', 'Name', 'Username', 'Email', 'City', 'Company'],
columns: [
{ data: 'id', type: 'numeric', width: 70 },
{ data: 'name', type: 'text', width: 190 },
{ data: 'username', type: 'text', width: 150 },
{ data: 'email', type: 'text', width: 220 },
{ data: 'city', type: 'text', width: 140 },
{ data: 'company', type: 'text', width: 180 },
],
// Enables clickable sort indicators on column headers.
columnSorting: true,
rowHeaders: true,
height: 360,
width: '100%',
stretchH: 'all',
autoWrapRow: true,
};
ngAfterViewInit(): void {
this.initialLoad();
}
// Step 5: Initial load uses loadData() directly on the hot instance, which resets all grid states.
// This is correct for a first load -- there is no existing state to preserve.
async initialLoad(): Promise<void> {
this.status = 'Loading users...';
this.hasError = false;
this.showRefresh = false;
try {
const users = await this.fetchUsers();
this.hotTable.hotInstance?.loadData(this.mapUsersToGridRows(users));
this.status =
'Users loaded. Sort a column, then click "Refresh" to see that the column sort order is preserved.';
this.showRefresh = true;
} catch (_error) {
this.hotTable.hotInstance?.loadData([]);
this.hasError = true;
this.status = 'Failed to load users. Try again.';
}
}
// Step 6: Subsequent refreshes use updateData(), which replaces the data
// without resetting column sort order, selection, or column order.
async refreshUsers(): Promise<void> {
this.status = 'Refreshing...';
this.hasError = false;
this.showRefresh = false;
try {
const users = await this.fetchUsers();
this.hotTable.hotInstance?.updateData(this.mapUsersToGridRows(users));
this.status = 'Data refreshed -- column sort order was preserved.';
this.showRefresh = true;
} catch (_error) {
// On error, do not clear the grid -- the existing data is still valid.
this.hasError = true;
this.status = 'Failed to load users. Try again.';
}
}
private async fetchUsers(): Promise<ApiUser[]> {
const response = await fetch('https://graphqlzero.almansi.me/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: USERS_QUERY }),
});
if (!response.ok) {
throw new Error(`Request failed with status: ${response.status}`);
}
const payload = (await response.json()) as {
data?: { users?: { data?: ApiUser[] } };
errors?: Array<{ message?: string }>;
};
// GraphQL APIs can return HTTP 200 and still include execution errors.
if (payload.errors?.length) {
throw new Error(payload.errors[0]?.message ?? 'GraphQL request failed.');
}
return payload.data?.users?.data ?? [];
}
private mapUsersToGridRows(users: ApiUser[]): UserRow[] {
return users.map((user) => ({
id: user.id,
name: user.name,
username: user.username,
email: user.email,
city: user.address?.city ?? '',
company: user.company?.name ?? '',
}));
}
}
/* end-file */
/* file: app.config.ts */
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { registerAllModules } from 'handsontable/registry';
import { HOT_GLOBAL_CONFIG, HotGlobalConfig, NON_COMMERCIAL_LICENSE } from '@handsontable/angular-wrapper';
registerAllModules();
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
{
provide: HOT_GLOBAL_CONFIG,
useValue: { license: NON_COMMERCIAL_LICENSE } as HotGlobalConfig,
},
],
};
/* end-file */
HTML
<div><example2-load-data-graphql></example2-load-data-graphql></div>

What the second example covers

  • Enabling columnSorting so the user can sort by any column header.
  • Using hot.loadData() for the first query - there is no existing state to preserve.
  • Using hot.updateData() for every subsequent refresh to keep column sort order, selection, and column order intact.
  • Extracting a shared fetchUsers() helper that both functions call.
  • Keeping the “Refresh” button hidden until the grid has data, and the “Retry” button visible only on error.

loadData() vs updateData()

Both methods replace the grid’s dataset. The difference is what they reset:

MethodResets sort orderResets selectionResets column orderUse when
loadData()YesYesYesInitial load, schema change, or hard reset
updateData()NoNoNoPeriodic refresh or live-data feed

Approach 2 - Server-side GraphQL with dataProvider

Use this approach when your dataset is large, or when you need server-driven pagination, sorting, filtering, and CRUD. Instead of downloading all rows in one browser request, Handsontable requests only the current page through dataProvider.fetchRows.

const hot = new Handsontable(container, {
dataProvider: {
rowId: 'id',
fetchRows: async (queryParameters, { signal }) => {
const response = await fetch('/graphql', {
method: 'POST',
signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: buildUsersQuery(queryParameters),
}),
});
if (!response.ok) {
throw new Error(`Request failed with status: ${response.status}`);
}
const payload = await response.json();
if (payload.errors?.length) {
throw new Error(payload.errors[0]?.message ?? 'GraphQL request failed.');
}
return {
rows: payload.data.users.data,
totalRows: payload.data.users.meta.totalCount,
};
},
onRowsCreate: async (rowsCreatePayload) => {
await createRowsMutation(rowsCreatePayload);
},
onRowsUpdate: async (rows) => {
await updateRowsMutation(rows);
},
onRowsRemove: async (rowIds) => {
await removeRowsMutation(rowIds);
},
},
pagination: { pageSize: 10 },
columnSorting: true,
filters: true,
emptyDataState: true,
notification: true,
licenseKey: 'non-commercial-and-evaluation',
});

The example above maps directly to the latest server-side data guides and APIs:

Client-side recipe vs server-side DataProvider

ApproachWhere data livesSorting/filteringPaginationCRUDUse when
Client-side fetch + loadData() / updateData()Browser memoryClient-sideManual, if you build itManualSmall/medium datasets, quick integration
dataProvider + GraphQL backendBackend + paged slices in browserServer-side via query parametersBuilt in with paginationBuilt in via callbacksLarge datasets, production server-driven flows

GraphQL request specifics

GraphQL requires one extra check compared to REST:

  • Send requests with method: 'POST'.
  • Use Content-Type: application/json.
  • Put your operation in body: JSON.stringify({ query: '...' }).
  • Check errors in the JSON response body, even when HTTP status is 200.

For basic scenarios, fetch is sufficient. In larger applications, a dedicated GraphQL client such as graphql-request or Apollo Client can simplify caching, retries, and schema-aware tooling.

  1. Define the GraphQL query

    Keep the user fields aligned with your Handsontable columns:

    const USERS_QUERY = `
    query {
    users {
    data {
    id
    name
    username
    email
    address { city }
    company { name }
    }
    }
    }
    `;

    What’s happening:

    • The query requests the same user dataset shape used by the REST recipe.
    • Nested GraphQL fields (address.city, company.name) are flattened later by mapUsersToGridRows().
  2. Send a GraphQL POST request

    Use fetch with a JSON payload that contains the query string.

    const response = await fetch('https://graphqlzero.almansi.me/api', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query: USERS_QUERY }),
    });

    What’s happening:

    • GraphQL APIs typically use POST for operations.
    • The payload format is always JSON with a query field.
  3. Validate both HTTP status and GraphQL errors

    GraphQL APIs can return HTTP 200 while still reporting operation errors.

    const payload = await response.json();
    if (payload.errors?.length) {
    throw new Error(payload.errors[0]?.message ?? 'GraphQL request failed.');
    }

    What’s happening:

    • HTTP status validation catches transport-level failures.
    • payload.errors catches GraphQL execution and validation failures.
    • The same catch block handles both cases and updates the recipe UI consistently.
  4. Use loadData() for initial query and updateData() for refreshes

    loadData() is used once for initial population, and updateData() is used for subsequent refreshes to preserve state.

    hot.loadData(mapUsersToGridRows(users)); // initial query
    hot.updateData(mapUsersToGridRows(users)); // refresh query

    What’s happening:

    • loadData() resets state - ideal for first render.
    • updateData() preserves state - ideal after users have interacted with the grid.

What you learned

  • How to send a GraphQL POST request with fetch using Content-Type: application/json and a query field in the body.
  • Why you must check payload.errors in addition to HTTP status, because GraphQL APIs return HTTP 200 even for operation errors.
  • How hot.loadData() resets all grid state on first load and hot.updateData() preserves column sort order and selection on subsequent refreshes.
  • How to use the dataProvider architecture for server-side pagination, sorting, filtering, and CRUD when your dataset is too large for client-side loading.

Next steps