React Gantt - TanStack Query Tutorial
This tutorial will guide you through creating a React TypeScript application with Vite, integrating the DHTMLX React Gantt component, and managing server state with TanStack Query. A small Zustand store handles local UI state - undo/redo history and zoom configuration.
The focus of this tutorial is the client-side integration: how TanStack Query fetches data, how mutations wire up to the Gantt's data.save callback, and how the query cache is used as the single source of truth for Gantt data. The backend included in the demo is intentionally minimal - it uses a local JSON file as storage instead of a real database. This is enough to demonstrate a working REST API without adding unrelated infrastructure. In a production application you would replace it with any persistent storage solution of your choice.
Prerequisites
- Basic knowledge of React, TypeScript, Vite, and TanStack Query
- Recommended: read Basics to understand the data binding mode and the
data.savecallback this tutorial builds on.
Quick setup - create the project
Before you start, install Node.js.
Create a Vite React + TypeScript project:
npm create vite@latest react-gantt-tanstack-query-demo -- --template react-ts
cd react-gantt-tanstack-query-demo
Now let's install the required dependencies.
- For npm:
npm install @tanstack/react-query zustand @mui/material @mui/icons-material @emotion/react @emotion/styled express cors
- For yarn:
yarn add @tanstack/react-query zustand @mui/material @mui/icons-material @emotion/react @emotion/styled express cors
We also need a few dev dependencies to run the Express backend server with TypeScript:
- For npm:
npm install -D tsx nodemon @types/express @types/node
- For yarn:
yarn add -D tsx nodemon @types/express @types/node
Then we need to install the React Gantt package.
Installing React Gantt
Install React Gantt as described in the React Gantt installation guide.
In this tutorial we use the evaluation package:
npm install @dhtmlx/trial-react-gantt
or
yarn add @dhtmlx/trial-react-gantt
If you already use the Professional package, replace @dhtmlx/trial-react-gantt with @dhx/react-gantt in the commands and imports.
Add the following scripts to package.json so you can start the backend and frontend separately:
"scripts": {
"dev": "vite",
"start:server": "nodemon --exec tsx src/server.ts"
}
To make Gantt occupy the entire space of the body, you need to remove the default styles from the App.css and index.css files located in the src folder and add the following one in the index.css file:
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
#root {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
}
Setting Up Sample Data and Configuration
Create src/seed/Seed.ts with the Gantt zoom configuration:
import type { GanttConfig } from '@dhtmlx/trial-react-gantt';
export type ZoomLevel = 'day' | 'month' | 'year';
export const defaultZoomLevels: NonNullable<GanttConfig['zoom']> = {
current: 'day',
levels: [
{ name: 'day', scale_height: 27, min_column_width: 80, scales: [{ unit: 'day', step: 1, format: '%d %M' }] },
{
name: 'month',
scale_height: 50,
min_column_width: 120,
scales: [
{ unit: 'month', format: '%F, %Y' },
{ unit: 'week', format: 'Week #%W' },
],
},
{ name: 'year', scale_height: 50, min_column_width: 30, scales: [{ unit: 'year', step: 1, format: '%Y' }] },
],
};
Also create src/seed/data.json with the initial tasks and links that the backend will serve:
{
"tasks": [
{
"id": 1,
"text": "Office itinerancy",
"type": "project",
"start_date": "2025-04-02T00:00:00.000Z",
"duration": 17,
"progress": 0.4,
"parent": 0,
"open": true
},
{
"id": 2,
"text": "Office facing",
"type": "project",
"start_date": "2025-04-02T00:00:00.000Z",
"duration": 8,
"progress": 0.6,
"parent": 1,
"open": true
},
{
"id": 3,
"text": "Furniture installation",
"type": "project",
"start_date": "2025-04-11T00:00:00.000Z",
"duration": 8,
"progress": 0.6,
"parent": 1,
"open": true
},
{
"id": 4,
"text": "The employee relocation",
"type": "project",
"start_date": "2025-04-13T00:00:00.000Z",
"duration": 5,
"progress": 0.5,
"parent": 1,
"open": true
},
{
"id": 5,
"text": "Interior office",
"type": "task",
"start_date": "2025-04-03T00:00:00.000Z",
"duration": 7,
"progress": 0.6,
"parent": 2
},
{
"id": 6,
"text": "Air conditioners check",
"type": "task",
"start_date": "2025-04-03T00:00:00.000Z",
"duration": 7,
"progress": 0.6,
"parent": 2
},
{
"id": 7,
"text": "Workplaces preparation",
"type": "task",
"start_date": "2025-04-12T00:00:00.000Z",
"duration": 8,
"progress": 0.6,
"parent": 3
},
{
"id": 8,
"text": "Preparing workplaces",
"type": "task",
"start_date": "2025-04-14T00:00:00.000Z",
"duration": 5,
"progress": 0.5,
"parent": 4
},
{
"id": 9,
"text": "Workplaces importation",
"type": "task",
"start_date": "2025-04-21T00:00:00.000Z",
"duration": 4,
"progress": 0.5,
"parent": 4
},
{
"id": 10,
"text": "Workplaces exportation",
"type": "task",
"start_date": "2025-04-27T00:00:00.000Z",
"duration": 3,
"progress": 0.5,
"parent": 4
}
],
"links": [
{ "id": 2, "source": 2, "target": 3, "type": "0" },
{ "id": 3, "source": 3, "target": 4, "type": "0" },
{ "id": 7, "source": 8, "target": 9, "type": "0" }
]
}
Building the Backend Server
The server below is a demo convenience, not a production recommendation. It stores all data in a single JSON file so you can run the full tutorial without setting up a database. Replace it with any real persistence layer - PostgreSQL, MongoDB, a cloud API, etc. - when building a production application. The client-side TanStack Query integration remains the same regardless of what the backend uses.
Create src/server.ts. This is a lightweight Express server that reads and writes a JSON file to simulate a real REST API:
import express from 'express';
import cors from 'cors';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import os from 'os';
const app = express();
app.use(express.json());
app.use(cors());
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const SEED_PATH = join(__dirname, 'seed', 'data.json');
const DB_PATH = join(os.tmpdir(), 'gantt-tanstack-demo-db.json');
const PORT = 3001;
// Copy seed data to a runtime location on startup so the seed stays pristine
if (!fs.existsSync(DB_PATH)) {
fs.copyFileSync(SEED_PATH, DB_PATH);
}
interface Task {
id: string | number;
[key: string]: unknown;
}
interface Link {
id: string | number;
[key: string]: unknown;
}
interface DB {
tasks: Task[];
links: Link[];
}
const readDB = (): DB => JSON.parse(fs.readFileSync(DB_PATH, 'utf-8'));
const writeDB = (data: DB) => fs.writeFileSync(DB_PATH, JSON.stringify(data, null, 2));
app.get('/data', (_req, res) => {
res.json(readDB());
});
app.post('/tasks', (req, res) => {
const db = readDB();
const task = req.body as Task;
const newTask = { ...task, id: `DB_ID:${task.id}` };
db.tasks.push(newTask);
writeDB(db);
res.json(newTask);
});
app.put('/tasks/:id', (req, res) => {
const db = readDB();
db.tasks = db.tasks.map((t) => (String(t.id) === req.params.id ? { ...t, ...req.body } : t));
writeDB(db);
res.sendStatus(200);
});
app.delete('/tasks/:id', (req, res) => {
const db = readDB();
db.tasks = db.tasks.filter((t) => String(t.id) !== req.params.id);
writeDB(db);
res.sendStatus(200);
});
app.post('/links', (req, res) => {
const db = readDB();
const link = req.body as Link;
const newLink = { ...link, id: `DB_ID:${link.id}` };
db.links.push(newLink);
writeDB(db);
res.json(newLink);
});
app.put('/links/:id', (req, res) => {
const db = readDB();
db.links = db.links.map((l) => (String(l.id) === req.params.id ? { ...l, ...req.body } : l));
writeDB(db);
res.sendStatus(200);
});
app.delete('/links/:id', (req, res) => {
const db = readDB();
db.links = db.links.filter((l) => String(l.id) !== req.params.id);
writeDB(db);
res.sendStatus(200);
});
app.listen(PORT, () => console.log(`Server running on ${PORT}`));
The server exposes these endpoints:
| Method | Path | Action |
|---|---|---|
| GET | /data | Returns all tasks and links |
| POST | /tasks | Creates a task, assigns a stable DB id |
| PUT | /tasks/:id | Updates a task |
| DELETE | /tasks/:id | Deletes a task |
| POST | /links | Creates a link, assigns a stable DB id |
| PUT | /links/:id | Updates a link |
| DELETE | /links/:id | Deletes a link |
When a task or link is created, the server prefixes the client-generated id with DB_ID: and returns the new record. The Gantt component uses the returned id to update its internal reference.
Creating the API Layer
Create src/api.ts with plain fetch-based functions that TanStack Query will call:
import { type Link, type SerializedTask } from '@dhtmlx/trial-react-gantt';
const BASE = window.location.origin;
async function request(url: string, options?: RequestInit) {
const res = await fetch(url, options);
if (!res.ok) {
throw new Error(`${options?.method ?? 'GET'} ${url} failed: ${res.status}`);
}
return res;
}
export const fetchData = async () => {
const res = await request(`${BASE}/data`);
return await res.json();
};
export const createTask = async (task: SerializedTask) => {
const res = await request(`${BASE}/tasks`, {
method: 'POST',
body: JSON.stringify(task),
headers: { 'Content-Type': 'application/json' },
});
return await res.json();
};
export const updateTask = async (task: SerializedTask) => {
await request(`${BASE}/tasks/${task.id}`, {
method: 'PUT',
body: JSON.stringify(task),
headers: { 'Content-Type': 'application/json' },
});
};
export const deleteTask = async (id: string | number) => {
await request(`${BASE}/tasks/${id}`, { method: 'DELETE' });
};
export const createLink = async (link: Link) => {
const res = await request(`${BASE}/links`, {
method: 'POST',
body: JSON.stringify(link),
headers: { 'Content-Type': 'application/json' },
});
return await res.json();
};
export const updateLink = async (link: Link) => {
await request(`${BASE}/links/${link.id}`, {
method: 'PUT',
body: JSON.stringify(link),
headers: { 'Content-Type': 'application/json' },
});
};
export const deleteLink = async (id: string | number) => {
await request(`${BASE}/links/${id}`, { method: 'DELETE' });
};
Each function throws on a non-2xx response so TanStack Query can catch the error and trigger its onError handler.
Building the Control Toolbar Component
Add a Toolbar component in src/components/Toolbar.tsx:
import Divider from '@mui/material/Divider';
import ButtonGroup from '@mui/material/ButtonGroup';
import UndoIcon from '@mui/icons-material/Undo';
import RedoIcon from '@mui/icons-material/Redo';
import Button from '@mui/material/Button';
import type { ZoomLevel } from '../seed/Seed';
export interface ToolbarProps {
onUndo?: () => void;
onRedo?: () => void;
canUndo?: boolean;
canRedo?: boolean;
onZoom?: (level: ZoomLevel) => void;
currentZoom?: ZoomLevel;
}
export default function Toolbar({
onUndo,
onRedo,
canUndo = false,
canRedo = false,
onZoom,
currentZoom = 'month',
}: ToolbarProps) {
return (
<div style={{ display: 'flex', justifyContent: 'start', padding: '0px 0px 20px', gap: '10px' }}>
<ButtonGroup>
<Button onClick={() => onUndo?.()} disabled={!canUndo}>
<UndoIcon />
</Button>
<Button onClick={() => onRedo?.()} disabled={!canRedo}>
<RedoIcon />
</Button>
</ButtonGroup>
<Divider orientation="vertical"></Divider>
<ButtonGroup>
<Button onClick={() => onZoom?.('day')} variant={currentZoom === 'day' ? 'contained' : 'outlined'}>
Day
</Button>
<Button onClick={() => onZoom?.('month')} variant={currentZoom === 'month' ? 'contained' : 'outlined'}>
Month
</Button>
<Button onClick={() => onZoom?.('year')} variant={currentZoom === 'year' ? 'contained' : 'outlined'}>
Year
</Button>
</ButtonGroup>
</div>
);
}
The toolbar accepts these props:
canUndo/canRedo- boolean flags that enable or disable the undo/redo buttons based on the history stack length.onUndo/onRedo- callbacks that trigger undo/redo logic in the parent component.onZoom- a callback that updates the zoom level when users click a zoom button.currentZoom- indicates the active zoom level so the correct button appearscontained.
Setting Up TanStack Query in main.tsx
Wrap the application with QueryClientProvider so every component can access the TanStack Query client:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const client = new QueryClient();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={client}>
<App />
</QueryClientProvider>
</StrictMode>,
);
QueryClient is created once outside the render tree to prevent it from being recreated on every render.
Creating the Main Gantt Component
Create src/components/GanttComponent.tsx. This is where TanStack Query drives all data operations.