React Gantt - Redux Toolkit Tutorial
This tutorial will guide you through creating a React TypeScript application with Vite, integrating DHTMLX React Gantt component, and managing state with Redux Toolkit.
Prerequisites
- Basic knowledge of React, TypeScript, and Redux
- 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-redux-demo -- --template react-ts
cd react-gantt-redux-demo
Now let's install the required dependencies.
- For npm:
npm install @reduxjs/toolkit react-redux @mui/material @mui/icons-material @emotion/react @emotion/styled
- For yarn:
yarn add @reduxjs/toolkit react-redux @mui/material @mui/icons-material @emotion/react @emotion/styled
Then we need to install the React Gantt package.
Installing React Gantt
Install React Gantt as described in .
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.
Now you can start the dev server:
npm run dev
You should now have your React project running on http://localhost:5173.
To make Gantt occupy the entire space of the body, you need to remove the default styles from the App.css file located in the src folder and add the following one:
#root {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
}
Configure Redux store
Create src/redux/store.ts. This wires the gantt slice into the Redux store:
import { configureStore } from '@reduxjs/toolkit';
import ganttReducer from './ganttSlice';
export const store = configureStore({
reducer: {
gantt: ganttReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
configureStore sets up Redux with sensible defaults (DevTools, thunk). Typing RootState and AppDispatch makes it easy to type useSelector and useDispatch across the app.
Create the Redux Slice
Create src/redux/ganttSlice.ts to handle all Gantt-related data: tasks, links (dependencies), and configuration settings like zoom.
This slice also introduces undo/redo functionality through snapshot history tracking, allowing users to revert or reapply previous changes in the chart.
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { SerializedTask, Task, Link, GanttConfig } from '@dhtmlx/trial-react-gantt';
import { defaultZoomLevels, seedLinks, seedTasks, type ZoomLevel } from '../common/Seed';
import { type WritableDraft } from 'immer';
interface Snapshot {
tasks: SerializedTask[];
links: Link[];
config: GanttConfig;
}
interface GanttState {
tasks: SerializedTask[];
links: Link[];
config: GanttConfig;
past: Snapshot[];
future: Snapshot[];
maxHistory: number;
}
const initialState: GanttState = {
tasks: seedTasks,
links: seedLinks,
config: {
zoom: defaultZoomLevels,
},
past: [],
future: [],
maxHistory: 50,
};
const createSnapshot = (state: GanttState): WritableDraft<Snapshot> => ({
tasks: JSON.parse(JSON.stringify(state.tasks)),
links: JSON.parse(JSON.stringify(state.links)),
config: JSON.parse(JSON.stringify(state.config)),
});
const pushHistory = (state: GanttState) => {
state.past.push(createSnapshot(state) as Snapshot);
if (state.past.length > state.maxHistory) state.past.shift();
state.future = [];
};
const ganttSlice = createSlice({
name: 'gantt',
initialState,
reducers: {
undo(state) {
if (state.past.length > 0) {
const previous = state.past[state.past.length - 1];
const newFuture = createSnapshot(state as GanttState);
state.tasks = previous.tasks;
state.links = previous.links;
state.config = previous.config;
state.past = state.past.slice(0, -1);
state.future = [newFuture, ...state.future];
}
},
redo(state) {
if (state.future.length > 0) {
const next = state.future[0];
const newPast = createSnapshot(state as GanttState);
state.tasks = next.tasks;
state.links = next.links;
state.config = next.config;
state.future = state.future.slice(1);
state.past = [...state.past, newPast];
}
},
updateTask(state, action: PayloadAction<SerializedTask>) {
pushHistory(state);
const updatedTask = action.payload;
const index = state.tasks.findIndex((task) => task.id === updatedTask.id);
if (index !== -1) {
state.tasks[index] = { ...state.tasks[index], ...updatedTask };
}
},
createTask(state, action: PayloadAction<SerializedTask>) {
pushHistory(state);
state.tasks.push({ ...action.payload, id: `DB_ID:${action.payload.id}` });
},
deleteTask(state, action: PayloadAction<string>) {
pushHistory(state);
state.tasks = state.tasks.filter((task) => String(task.id) !== action.payload);
},
updateLink(state, action: PayloadAction<Link>) {
pushHistory(state);
const updatedLink = action.payload;
const index = state.links.findIndex((link) => link.id === updatedLink.id);
if (index !== -1) {
state.links[index] = { ...state.links[index], ...updatedLink };
}
},
createLink(state, action: PayloadAction<Link>) {
pushHistory(state);
state.links.push({ ...action.payload, id: `DB_ID:${action.payload.id}` });
},
deleteLink(state, action: PayloadAction<string>) {
pushHistory(state);
state.links = state.links.filter((link) => String(link.id) !== action.payload);
},
setZoom(state, action: PayloadAction<ZoomLevel>) {
pushHistory(state);
state.config.zoom.current = action.payload;
},
},
});
export const { undo, redo, updateTask, createTask, deleteTask, updateLink, createLink, deleteLink, setZoom } =
ganttSlice.actions;
export default ganttSlice.reducer;
The GanttState includes three new fields: past, future, and maxHistory which together implement a time travel mechanism for undo/redo actions.
To support undo/redo, two helper functions are used:
-
createSnapshot(state)- deep-clones the current Gantt data to preserve an exact copy of tasks, links, and config at a given point in time. -
pushHistory(state)- saves the current snapshot to thepastarray before any modifying action, clearing thefuturestack (so redo applies only to the latest undo sequence).
Explanation of the ganttSlice.ts is provided below.
The createSlice function automatically generates:
- The reducers (functions that modify state).
- The action creators (functions you can dispatch from your UI).
Each reducer updates a specific part of the Gantt state:
- updateTask: updates an existing task's data (for example, when you edit a name, date, or duration).
- createTask: Adds a new task to the state. The fake
DB_ID:prefix simulates how a real backend might assign a unique ID after saving to a database. - deleteTask: removes a task from the store based on its ID.
- updateLink, createLink, deleteLink: These work exactly like the task reducers, but for links (dependencies between tasks).
- setZoom: updates the current zoom level in the config object, with history tracking.
- undo: restores the previous snapshot from
past, moving the current one into future. - redo: reapplies a previously undone state by moving a snapshot from
futureback topast.
Every modifying action first calls pushHistory(state), meaning the user can safely undo or redo any task, link, or configuration change.
Setting Up Sample Data and Configuration
Create sample data for our Gantt chart in src/common/Seed.ts which will contain the initial data:
import type { SerializedTask, Link, 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' }],
},
],
};
export const seedTasks: SerializedTask[] = [
{
id: 1,
text: 'Office itinerancy',
type: 'project',
start_date: new Date(2025, 3, 2).toISOString(),
duration: 17,
progress: 0.4,
parent: 0,
open: true,
}
// ...
];
export const seedLinks: Link[] = [
{ id: 2, source: 2, target: 3, type: '0' },
{ id: 3, source: 3, target: 4, type: '0' },
// ...
];
Building the Control Toolbar Component
Now, let's add a Toolbar component in src/common/Toolbar.tsx.
This component gives users quick access to common Gantt controls, like zooming between day, month, and year views, and performing undo/redo actions.
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';
export interface ToolbarProps {
onUndo?: () => void;
onRedo?: () => void;
onZoom?: (level: ZoomLevel) => void;
currentZoom?: ZoomLevel;
}
export default function Toolbar({ onUndo, onRedo, onZoom, currentZoom = 'month' }: ToolbarProps) {
return (
<div style={{ display: 'flex', justifyContent: 'start', padding: '10px 10px 20px', gap: '10px' }}>
<ButtonGroup>
<Button onClick={() => onUndo?.()}>
<UndoIcon />
</Button>
<Button onClick={() => onRedo?.()}>
<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>
);
}
We use Material UI components (Button, ButtonGroup, Divider, and icons) to create a simple, clean toolbar layout.
The toolbar accepts the following optional props:
onUndoandonRedo- callback functions for undo/redo actions.onZoom- a callback triggered when the user clicks one of the zoom buttons.currentZoom- tells the toolbar which zoom level is currently active, so the selected button can be highlighted.
The buttons for "Day", "Month", and "Year" call onZoom('day'), onZoom('month'), or onZoom('year') respectively. The selected zoom level button uses variant="contained", while the others are outlined, providing a clear visual cue for the current state.
Later in the tutorial, we'll connect this toolbar to our store actions:
- When a user clicks "Day", we'll call
setZoom('day')from our store - The Undo button will trigger the store's
undo()method to revert to previous states - The Redo button will call
redo()to reapply changes - All state changes (task edits, deletions, zoom adjustments, etc.) are tracked in our custom history system and can be reversed or reapplied seamlessly
This updates the Gantt chart's configuration in the global state, and the UI will automatically re-render with the new zoom level.
Let's create the core component in src/components/GanttComponent.tsx that brings together DHTMLX React Gantt with Redux Toolkit state management. This component serves as the central piece of our application, handling all Gantt chart interactions and state updates.
We use useMemo and useCallback hooks to optimize performance by preventing unnecessary re-renders. useMemo caches computed values (like configuration objects), while useCallback memoizes callback functions. This ensures that these objects and functions aren't recreated on every render if their dependencies haven't been changed.
We create the main component and set up Redux integration:
import React, { useRef, useEffect, useMemo, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import ReactGantt, { GanttConfig, ReactGanttProps, Link, ReactGanttRef, SerializedTask } from '@dhtmlx/trial-react-gantt';
import '@dhtmlx/trial-react-gantt/dist/react-gantt.css';
import {
undo,
redo,
updateTask,
createTask,
deleteTask,
updateLink,
createLink,
deleteLink,
setZoom,
} from '../redux/ganttSlice';
import type { RootState, AppDispatch } from '../redux/store';
import Toolbar from '../common/Toolbar';
import { type ZoomLevel } from '../common/Seed';
const ReactGanttExample: React.FC = () => {
const ganttRef = useRef<ReactGanttRef>(null);
const dispatch = useDispatch<AppDispatch>();
const { tasks, links, config } = useSelector((state: RootState) => state.gantt);
useEffect(() => {
document.title = 'DHTMLX React Gantt | Redux Toolkit';
}, []);
}
ganttRefgives us direct access to the Gantt instance for calling methods like undo/redodispatchis our function for sending actions to the Redux store- We use
useSelectorhooks to extract tasks, links, and configuration from the Redux state useEffectsets the document title when the component mounts
The component needs to handle user actions from the toolbar and the Gantt chart itself. We use useCallback to memoize these handler functions:
const handleUndo = useCallback(() => {
dispatch(undo());
}, [dispatch]);
const handleRedo = useCallback(() => {
dispatch(redo());
}, [dispatch]);
const handleZoomIn = useCallback(
(newZoom: ZoomLevel) => {
dispatch(setZoom(newZoom));
},
[dispatch]
);
handleZoomIndispatches an action to update the zoom level in Redux statehandleUndoandhandleRedodispatch theundo/redoactions from the slice, which restore the previous snapshot frompastorfuture- These functions are passed to the Toolbar component as callbacks
Now we configure the Gantt chart using useMemo to cache configuration objects:
const ganttConfig: GanttConfig = useMemo(() => ({ ...config }), [config]);
const templates: ReactGanttProps['templates'] = useMemo(
() => ({
format_date: (date: Date) => date.toISOString(),
parse_date: (date: string) => new Date(date),
}),
[]
);
We also need to handle all data changes from the Gantt chart:
const data: ReactGanttProps['data'] = useMemo(
() => ({
save: (entity, action, payload, id) => {
if (entity === 'task') {
const task = payload as SerializedTask;
if (action === 'update') {
dispatch(updateTask(task));
} else if (action === 'create') {
dispatch(createTask(task));
} else if (action === 'delete') {
dispatch(deleteTask(String(id)));
}
} else if (entity === 'link') {
const link = payload as Link;
if (action === 'update') {
dispatch(updateLink(link));
} else if (action === 'create') {
dispatch(createLink(link));
} else if (action === 'delete') {
dispatch(deleteLink(String(id)));
}
}
},
}),
[dispatch]
);
The data.save callback is called whenever any change occurs in the Gantt chart.
It receives four parameters:
entity: whether it's a 'task' or 'link'action: the type of operation ('create', 'update', 'delete')payload: the actual data being modifiedid: the identifier of the item being modified
Based on the entity and action, we dispatch the appropriate Redux action. This creates a seamless connection between the Gantt chart's internal state and our Redux store.
If you need a deeper explanation of this callback, see Handling changes with data.save in the Basics guide.
Finally, we render the complete component:
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Toolbar onUndo={handleUndo} onRedo={handleRedo} onZoom={handleZoomIn} currentZoom={config.zoom.current} />
<ReactGantt tasks={tasks} links={links} config={ganttConfig} templates={templates} data={data} ref={ganttRef} />
</div>
);
Integrating Redux Provider
Update your src/main.tsx to include the Redux Provider:
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './redux/store';
import './index.css';
import App from './App';
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
And then update your src/App.tsx to use our Gantt component:
import './App.css'
import GanttComponent from './components/GanttComponent'
function App() {
return (
<div style={{ height: '100vh', width: '95vw' }}>
<GanttComponent />
</div>
)
}
export default App
Finally, we can run the dev server and test our application:
npm run dev
or:
yarn dev
Summary
In this tutorial you've:
- created a Vite + React project
- added React Gantt and wired it to a Redux Toolkit store
- implemented snapshot-based undo/redo in the
ganttSlice - connected Material UI toolbar to zoom and history actions
- used the
data.savecallback so that every task/link change in the Gantt chart becomes a Redux action.
The result is a Gantt chart, whose tasks, links and configuration are fully driven by Redux state.
GitHub demo repository
A complete working project that follows this tutorial is provided on GitHub.
What's next
To go further:
- Revisit the concepts behind this example in Basics
- Combine Redux-driven state with advanced configuration and templating in
- Explore the same pattern with other state managers: