본문으로 건너뛰기

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.save callback 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:

MethodPathAction
GET/dataReturns all tasks and links
POST/tasksCreates a task, assigns a stable DB id
PUT/tasks/:idUpdates a task
DELETE/tasks/:idDeletes a task
POST/linksCreates a link, assigns a stable DB id
PUT/links/:idUpdates a link
DELETE/links/:idDeletes 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 appears contained.

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.

Imports and initial setup

import { useMemo, useRef, useCallback } from 'react';
import ReactGantt, {
type ReactGanttProps,
type Link,
type ReactGanttRef,
type SerializedTask,
} from '@dhtmlx/trial-react-gantt';
import '@dhtmlx/trial-react-gantt/dist/react-gantt.css';

import Toolbar from './Toolbar';
import { fetchData, createTask, updateTask, deleteTask, createLink, updateLink, deleteLink } from '../api';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { type Snapshot, useGanttStore } from '../store';
import { type ZoomLevel } from '../seed/Seed';

Fetching data with useQuery

export default function DemoTanstackQuery() {
const ganttRef = useRef<ReactGanttRef>(null);
const queryClient = useQueryClient();

const {
data: fetchedData,
isLoading,
isError,
error,
} = useQuery<{ tasks: SerializedTask[]; links: Link[] }>({ queryKey: ['data'], queryFn: fetchData });

const { tasks, links } = fetchedData || { tasks: [], links: [] };

useQuery fetches all tasks and links from the server when the component mounts. The result is stored in the TanStack Query cache under the ['data'] key.

  • isLoading - true while the initial fetch is in progress.
  • isError / error - populated if the fetch fails.
  • Fallback to empty arrays (fetchedData || { tasks: [], links: [] }) ensures Gantt receives valid props even before the first response arrives.

Reading Zustand state

const { undo, redo, setZoom, config, recordHistory, past, future } = useGanttStore();

Only UI-related state comes from Zustand - zoom configuration and the undo/redo history stacks. Tasks and links live in the TanStack Query cache, not in Zustand.

Creating a snapshot helper

const makeSnapshot = useCallback(
(): Snapshot => ({
tasks: structuredClone(tasks),
links: structuredClone(links),
config: structuredClone(config),
}),
[tasks, links, config],
);

makeSnapshot captures a deep copy of the current tasks, links, and config as a single Snapshot object. It is called before every mutation so the previous state can be restored by undo.

Defining mutations

Each CRUD operation is wrapped in a useMutation hook. All six mutations share the same three lifecycle hooks:

const onError = useCallback((err: Error) => {
console.error('Mutation failed:', err.message);
}, []);

const createTaskMutation = useMutation({
mutationFn: createTask,
onMutate: () => {
recordHistory(makeSnapshot());
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['data'] }),
onError,
});

const updateTaskMutation = useMutation({
mutationFn: updateTask,
onMutate: () => {
recordHistory(makeSnapshot());
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['data'] }),
onError,
});

const deleteTaskMutation = useMutation({
mutationFn: deleteTask,
onMutate: () => {
recordHistory(makeSnapshot());
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['data'] }),
onError,
});

const createLinkMutation = useMutation({
mutationFn: createLink,
onMutate: () => {
recordHistory(makeSnapshot());
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['data'] }),
onError,
});

const updateLinkMutation = useMutation({
mutationFn: updateLink,
onMutate: () => {
recordHistory(makeSnapshot());
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['data'] }),
onError,
});

const deleteLinkMutation = useMutation({
mutationFn: deleteLink,
onMutate: () => {
recordHistory(makeSnapshot());
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['data'] }),
onError,
});
  • onMutate - fires synchronously before the API call. We record a snapshot here so the undo stack captures the state immediately before the change.
  • onSuccess - calls queryClient.invalidateQueries which marks the ['data'] cache as stale and triggers a background refetch. The Gantt re-renders with the fresh server response once the refetch completes.
  • onError - logs the failure. You can extend this to show a notification or roll back optimistic updates.

Connecting mutations to the Gantt via data.save

const templates: ReactGanttProps['templates'] = useMemo(
() => ({
format_date: (d) => d.toISOString(),
parse_date: (s) => new Date(s),
}),
[],
);

const data: ReactGanttProps['data'] = useMemo(
() => ({
save: (entity, action, payload, id) => {
if (entity === 'task') {
const task = payload as SerializedTask;
if (action === 'create') return createTaskMutation.mutate(task);
else if (action === 'update') updateTaskMutation.mutate(task);
else if (action === 'delete') deleteTaskMutation.mutate(id);
} else if (entity === 'link') {
const link = payload as Link;
if (action === 'create') return createLinkMutation.mutate(link);
else if (action === 'update') updateLinkMutation.mutate(link);
else if (action === 'delete') deleteLinkMutation.mutate(id);
}
},
}),
[
createTaskMutation,
updateTaskMutation,
deleteTaskMutation,
createLinkMutation,
updateLinkMutation,
deleteLinkMutation,
],
);
노트

Since v9.1.3, Gantt automatically detects ISO date strings and the templates overrides are no longer needed. They are shown here for compatibility with earlier Gantt versions. See Loading dates in ISO format.

The data.save callback is the bridge between the Gantt chart and TanStack Query. Whenever the user drags a task, edits text, creates a link, or performs any other change:

  1. The Gantt calls data.save with the entity type (task or link), the action (create, update, or delete), the full item payload, and its id.
  2. We route that to the appropriate mutation.
  3. The mutation calls the API function and, on success, invalidates the cache.

If you need a deeper explanation of this callback, see Handling changes with data.save in the Basics guide.

Undo, redo and zoom handlers

const handleUndo = () => {
const snapshot = undo(makeSnapshot());
if (snapshot) {
queryClient.setQueryData(['data'], snapshot);
}
};

const handleRedo = () => {
const snapshot = redo(makeSnapshot());
if (snapshot) {
queryClient.setQueryData(['data'], snapshot);
}
};

const handleZoom = (level: ZoomLevel) => {
recordHistory(makeSnapshot());
setZoom(level);
};
  • handleUndo passes the current snapshot to the Zustand undo action (so it can push it onto future) and receives the previous snapshot in return. It then writes that snapshot directly into the TanStack Query cache with setQueryData. React re-renders the Gantt with the restored data immediately - no server round-trip needed.
  • handleRedo works the same way in reverse.
  • handleZoom records a history snapshot first, then calls the Zustand setZoom action to update config.zoom.

This pattern keeps undo/redo fast and offline since it operates entirely on the client-side cache.

Rendering

  if (isLoading) {
return <div style={{ padding: '20px' }}>Loading project data...</div>;
}

if (isError) {
return <div style={{ padding: '20px', color: 'red' }}>Failed to load data: {error?.message}</div>;
}

return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', padding: '10px' }}>
<Toolbar
onUndo={handleUndo}
onRedo={handleRedo}
canUndo={past.length > 0}
canRedo={future.length > 0}
currentZoom={config.zoom.current}
onZoom={handleZoom}
/>
<ReactGantt ref={ganttRef} tasks={tasks} links={links} config={config} templates={templates} data={data} />
</div>
);
}
  • Loading and error states are handled before the chart renders.
  • canUndo and canRedo are derived from the Zustand history stacks so the toolbar buttons are disabled when there is nothing to undo or redo.
  • tasks and links always come from the TanStack Query cache; config always comes from Zustand.

Update App.tsx

Update src/App.tsx to use the Gantt component:

import './App.css';
import GanttComponent from './components/GanttComponent';

function App() {
return (
<div style={{ height: '100dvh', width: '100dvw' }}>
<GanttComponent />
</div>
);
}

export default App;

Setting Up the Zustand Store

Zustand manages only local UI state: zoom configuration and the undo/redo history stacks. Tasks and links are owned by TanStack Query.

Create src/store.ts:

import { create } from 'zustand';
import type { Link, GanttConfig, SerializedTask } from '@dhtmlx/trial-react-gantt';
import { defaultZoomLevels, type ZoomLevel } from './seed/Seed';

export type Snapshot = {
tasks: SerializedTask[];
links: Link[];
config: GanttConfig;
};

type State = {
config: GanttConfig;

past: Snapshot[];
future: Snapshot[];
maxHistory: number;

recordHistory: (snapshot: Snapshot) => void;
undo: (snapshot: Snapshot) => Snapshot | null;
redo: (snapshot: Snapshot) => Snapshot | null;

setZoom: (level: ZoomLevel) => void;
};

export const useGanttStore = create<State>((set, get) => ({
config: { zoom: defaultZoomLevels },

past: [],
future: [],
maxHistory: 50,

recordHistory: (snapshot) => {
const { past, maxHistory } = get();
set({
past: [...past.slice(-maxHistory + 1), snapshot],
future: [],
});
},

undo: (snapshot: Snapshot) => {
const { past, future } = get();
if (!past.length) return null;

const previous = past[past.length - 1];
set({
past: past.slice(0, -1),
future: [{ ...snapshot }, ...future],
config: previous.config,
});

return previous;
},

redo: (snapshot: Snapshot) => {
const { past, future } = get();
if (!future.length) return null;

const next = future[0];
set({
past: [...past, { ...snapshot }],
future: future.slice(1),
config: next.config,
});

return next;
},

setZoom: (level) => {
set({
config: {
...get().config,
zoom: { ...get().config.zoom, current: level },
},
});
},
}));

What the store manages

  • config - Gantt zoom configuration passed directly to the <ReactGantt> config prop.
  • past / future - snapshot stacks for undo and redo. Each snapshot includes tasks, links, and config so a full rollback restores everything at once.
  • maxHistory - limits the history to the last 50 snapshots.

Why undo and redo accept a snapshot parameter

In the pure-Zustand tutorial the store owns tasks and links, so undo() can just swap the previous snapshot in. Here, tasks and links live in the TanStack Query cache. To keep the store decoupled from TanStack Query, each undo/redo call:

  1. Receives the current snapshot from the component as an argument (so the store can push it onto the opposite stack without knowing about TanStack Query).
  2. Returns the target snapshot so the component can write it into the cache with queryClient.setQueryData.

This clear separation means Zustand manages history bookkeeping only, while TanStack Query remains the single source of truth for server data.

Run the application

Start the Express backend in one terminal:

npm run start:server

or:

yarn start:server

Then start the Vite dev server in another terminal:

npm run dev

or:

yarn dev

Open http://localhost:3000. The Gantt chart loads data from the backend, and every change you make is persisted to the server automatically.

Summary

In this tutorial you've:

  • set up a Vite + React + TypeScript project with TanStack Query and Zustand
  • created an Express REST backend that serves and persists tasks and links as JSON
  • used useQuery to fetch all Gantt data from the server on load
  • defined six useMutation hooks - one per CRUD operation - and wired them to the data.save callback
  • implemented snapshot-based undo/redo by storing history in Zustand and restoring snapshots into the TanStack Query cache via queryClient.setQueryData

This keeps the Gantt component fully declarative: server state is owned by TanStack Query, UI state is owned by Zustand, and the data.save callback connects user interactions to mutations without the component knowing about any persistence logic.

GitHub demo repository

A complete working project that follows this tutorial is provided on GitHub.

What's next

To go further:

Need help?
Got a question about the documentation? Reach out to our technical support team for help and guidance. For custom component solutions, visit the Services page.