In one of our previous tutorials, we covered the basics of state management in DHTMLX React Gantt with Zustand. There, we focused on managing Gantt data on the client side. However, real project planning apps, in most cases, need to load tasks and dependency links from the server and save user edits there, which calls for a dedicated server-state layer. This is where TanStack Query comes into play.
In this post, we’ll explain how TanStack Query complements Zustand in the DHTMLX React Gantt component and highlight the key integration steps. As reference sources of information, we’ll use the GitHub repository of the starter project and the official TanStack Query integration guide.
Introduction to TanStack Query and Its Approach to Handling Server State in React
Before the TanStack Query release in late 2019, developers used various approaches for managing server state: built-in React hooks (useEffect, useState), client-side state managers (Redux, MobX, Zustand, etc.), or custom caching abstractions. These techniques can be effective to some extent, but they require considerable manual effort from developers and may lead to additional boilerplate and complexity. Another serious difficulty related to server state is that it can quickly become out of date, as server data may change independently of the client. TanStack Query is designed to address these well-known pain points in state management. It provides a clear mental model for fetching, caching, mutating, and synchronizing server data in React apps.
In our starter demo, TanStack Query and Zustand form a two-layer architecture with a clear split of responsibilities:
| Layer | Responsible for the state of | Role in the DHTMLX starter demo |
| TanStack Query | Tasks, links | Manages server data fetched from and synchronized with the server |
| Zustand | Zoom config, undo/redo history | Manages local UI state that does not require server synchronization |
Before we move to integration details, let us elaborate on the main TanStack Query mechanisms required for server state workflow:
| TanStack API item | Main function | Role in the DHTMLX starter demo |
| useQuery | Reading server data | Fetching and caching Gantt data (tasks+links) |
| useMutation | Modifying server data | Performing CRUD operations with tasks and links via API |
| queryClient | Working with the query cache | providing access to cached Gantt data and query operations |
Together, these API items organize the core data flow: useQuery loads tasks and links into the cache, useMutation sends user changes to the backend, and queryClient invalidates or updates the cache as needed.
Now, it is high time to take a closer look at how these pieces come together in the React Gantt + TanStack Query starter.
Bringing TanStack Query and Zustand Together in DHTMLX React Gantt
With the responsibilities of each layer clear, let’s see how they come together in code. We’ll put aside some general configurations already encountered in the Zustand-only approach, and dwell only on what is specific to TanStack Query.
Before we start, it is worth mentioning that our starter uses a minimal Express backend with JSON file storage to keep things simple and focus on the client-side integration. Swapping it out in a production application is straightforward, since the mutations work the same way regardless of what sits behind the API.
Setting Up the Query Client
Every hook from the library needs access to a QueryClient instance. You create it once at the application root, outside the render function, and pass it to a QueryClientProvider that wraps the app. In this case, the client is reachable from anywhere in the tree.
const client = new QueryClient();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={client}>
<App />
</QueryClientProvider>
</StrictMode>,
);
The client sits at module scope rather than inside App for a reason: creating a new QueryClient on every render would discard the existing cache. Every component inside the provider then shares the same instance.
Fetching Gantt Data with useQuery
In the starter’s environment, the useQuery hook serves as the single source of truth for the chart’s data layer. One call of this hook pulls the whole dataset on mount:
data: fetchedData,
isLoading,
isError,
error,
} = useQuery<{ tasks: SerializedTask[]; links: Link[] }>({ queryKey: ['data'], queryFn: fetchData });
const { tasks, links } = fetchedData || { tasks: [], links: [] };
The isLoading and isError values allow you to show a loading indicator or an error message before the chart is rendered. Returning early on either one keeps ReactGantt from ever seeing undefined data, and it prevents an empty Gantt from flashing while the request is in flight. The fallback to empty arrays keeps the destructuring safe while fetchedData is still undefined. It gives makeSnapshot() (the undo/redo helper covered below) real arrays to clone before the first response arrives.
Keeping two data collections (tasks and links) under one [‘data’] key is a deliberate simplification. One request brings both, and links never outrun their tasks. In this case, invalidating the key reloads everything.
Passing Dates Between the Server and Gantt
JSON has no date type, so Date objects arrive from the server as plain strings. Anyone connecting the chart to a backend hits this. Starting from v9.1.3, React Gantt parses ISO strings automatically, but the starter spells the conversion out through templates. You still need them on earlier versions, or when your API does not use ISO 8601:
() => ({
format_date: (d) => d.toISOString(),
parse_date: (s) => new Date(s),
}),
[],
);
The parse_date defines how Gantt reads incoming dates, the format_date – how it writes them back. Gantt detects ISO strings automatically, so both overrides are optional. The starter keeps them to make the conversion visible. Delete them in your own project unless you are on an earlier version or your API does not use ISO 8601.
Transferring Gantt UI Changes to the Server
Every user interaction (dragging a bar, drawing a dependency, deleting a task) is tracked via the data.save callback with the following signature:
Here, the entity is “task” or “link”, and the action is “create”, “update”, or “delete”. In the Zustand-only setup from the previous tutorial, this callback wrote to the store directly. Now it dispatches updates to one of six mutations, one per entity and action combination:
() => ({
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);
}
},
}), [/* mutation references */]);
All six mutations share the same shape:
mutationFn: createTask,
onMutate: () => {
recordHistory(makeSnapshot());
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['data'] }),
onError,
});
The useMutation hook allows you to run extra logic at different stages of a mutation through its lifecycle callbacks. The starter uses three of them. The onMutate callback fires first, pushing a copy of the current data onto the undo stack. After a successful request, the onSuccess callback refreshes cached data. If the request fails, the onError callback handles an error.
For a detailed explanation of how data.save works, refer to the Data Binding & State Management basics guide.
Cache Invalidation vs Optimistic Updates in React Gantt
Optimistic updates are among the headline features of TanStack Query, and this is the one the starter leaves untouched. The demo backend shows why:
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);
});
The server does not accept client IDs. The backend generates a new ID with a DB_ID: prefix, the way most backends with auto-increment or UUID keys do.
When a user creates a task, the Gantt generates a temporary client-side ID and draws the row. The real ID only exists after the server responds. Every link referencing that task has to point at the new value. Writing an optimistic row into the cache would mean writing a row with an ID you already know is wrong. So the starter refetches after the response and treats the server’s answer as authoritative.
The notable exception is an API that accepts client-generated UUIDs. If yours does, the ID is known before the request, and optimistic creates become a real option.
React Gantt has a first-class mechanism for this case, and the starter skips it. If save returns a promise that resolves to { id: newId }, the chart swaps the temporary ID for the permanent one itself and repoints the links that referenced it. No refetch involved. With TanStack Query, it means reaching for mutateAsync instead of mutate:
return createTaskMutation.mutateAsync(payload as SerializedTask)
.then((saved) => ({ id: saved.id }));
}
The api.ts helpers already return the parsed response body from the create calls, so the ID is right there. The starter discards it. What you buy by ignoring it is uniformity: all six mutations end up with the same shape, and there is one reconciliation strategy to reason about instead of two. That trade works for a demo. On a chart with thousands of rows it stops working, because a full refetch per creation is the cost you were trying to avoid in the first place.
Updates and deletes do not have this problem, since their IDs are already stable. You can apply them optimistically to make the app more responsive. A reasonable middle ground is to keep creation authoritative (via the returned ID or a refetch) and make updates optimistic, rather than committing to a single strategy for all six mutations.
Complementing React Gantt with Undo/Redo Functionality
The undo operation involves both layers, because data and its history live in different places. Zustand holds stacks and stores plain snapshots:
tasks: SerializedTask[];
links: Link[];
config: GanttConfig;
};
Keeping config next to the data means a zoom change is undone along with the edit. Users notice immediately when that is missing. The makeSnapshot helper clones all three with structuredClone, since the chart edits its task objects in place. The stacks are capped at 50 entries, and pushing a snapshot clears the redo branch, as users expect.
Undo and redo actions in the store take the current snapshot and return the target one. They never import queryClient and have no idea where the data they are shuffling lives. The component is the only place the two layers meet:
const snapshot = undo(makeSnapshot());
if (snapshot) {
queryClient.setQueryData(['data'], snapshot);
}
};
Because setQueryData bypasses the network, the chart redraws instantly. Tasks and links come back through the cache. The zoom level travels a different route: undo updates config in the store, and the chart receives it through the config prop.
One boundary is worth stating plainly: undo restores the cache and tells the server nothing. With the library defaults, data goes stale immediately and is refetched on window focus or remount, so the undone change comes back. Raising staleTime and disabling refetchOnWindowFocus narrows that window. If undo has to be durable, route it through a mutation like any other change. Send the restored snapshot to a batch endpoint and let onSuccess invalidate. Only handleUndo changes. The Zustand store stays as it is.
For more information on integrating DHTMLX React Gantt with TanStack Query and Zustand, check out the official guide available in our documentation.
Wrapping Up
The combination of TanStack Query is a practical solution for handling both server and local state in React Gantt apps. With TanStack Query, you don’t have to worry about fetching, caching, and synchronizing Gantt data with the backend, while Zustand deals with the local UI state (undo/redo, zooming). This clear separation of responsibilities gives a flexible React state management setup and simplifies the overall maintainability of the application architecture. Our starter demo and the official integration guide can help you apply this approach in real projects built around DHTMLX React Gantt.