Load data from a GraphQL API
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.
/* 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 */<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/apifrom 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
- Create Handsontable with
data: []. - Create a status element and retry button above the grid.
- Start
loadUsers()and set status to loading. - Send a GraphQL query and map nested fields (
company.name,address.city) to flat row objects. - Call
hot.loadData(rows)and show a success message. - 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.
/* 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 */<div><example2-load-data-graphql></example2-load-data-graphql></div>What the second example covers
- Enabling
columnSortingso 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:
| Method | Resets sort order | Resets selection | Resets column order | Use when |
|---|---|---|---|---|
loadData() | Yes | Yes | Yes | Initial load, schema change, or hard reset |
updateData() | No | No | No | Periodic 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:
- Overview and plugin behavior: Server-side data
- Required settings and query fields: Server-side configuration
- Fetch lifecycle, hooks, and REST/GraphQL examples: Server-side fetching and examples
- Mutation callbacks and rollback behavior: Server-side CRUD
- Upgrade checklist from client-only loading: Migrate to server-side data
Client-side recipe vs server-side DataProvider
| Approach | Where data lives | Sorting/filtering | Pagination | CRUD | Use when |
|---|---|---|---|---|---|
Client-side fetch + loadData() / updateData() | Browser memory | Client-side | Manual, if you build it | Manual | Small/medium datasets, quick integration |
dataProvider + GraphQL backend | Backend + paged slices in browser | Server-side via query parameters | Built in with pagination | Built in via callbacks | Large 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
errorsin the JSON response body, even when HTTP status is200.
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.
Define the GraphQL query
Keep the user fields aligned with your Handsontable columns:
const USERS_QUERY = `query {users {data {idnameusernameemailaddress { 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 bymapUsersToGridRows().
Send a GraphQL POST request
Use
fetchwith 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
queryfield.
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.errorscatches GraphQL execution and validation failures.- The same
catchblock handles both cases and updates the recipe UI consistently.
Use
loadData()for initial query andupdateData()for refreshesloadData()is used once for initial population, andupdateData()is used for subsequent refreshes to preserve state.hot.loadData(mapUsersToGridRows(users)); // initial queryhot.updateData(mapUsersToGridRows(users)); // refresh queryWhat’s happening:
loadData()resets state - ideal for first render.updateData()preserves state - ideal after users have interacted with the grid.
Related
What you learned
- How to send a GraphQL POST request with
fetchusingContent-Type: application/jsonand aqueryfield in the body. - Why you must check
payload.errorsin addition to HTTP status, because GraphQL APIs return HTTP 200 even for operation errors. - How
hot.loadData()resets all grid state on first load andhot.updateData()preserves column sort order and selection on subsequent refreshes. - How to use the
dataProviderarchitecture for server-side pagination, sorting, filtering, and CRUD when your dataset is too large for client-side loading.
Next steps
- Explore Load data from a REST API for the same client-side and server-side approaches with a REST backend.
- Read the Server-side data guides for the full
dataProviderconfiguration reference.