Controlled state
React only. Five groups of the chart can be yours: tasks, links, viewMode, selectedKeys and
expandedKeys. This example keeps all five in one useReducer and gives them to <Gantt> as
props. The chart never changes them. What it does by itself (a drag, a click, an arrow you draw)
reaches your reducer through a handler, and the chart shows whatever your state says afterwards.
Any other part of your application can change the state too, and the chart follows. The inspector
under the chart is that state, with the field the last action changed marked.
pnpm add @ganttlane/react react react-domnpm install @ganttlane/react react react-domyarn add @ganttlane/react react react-dombun add @ganttlane/react react react-domimport type { Link, Task } from '@ganttlane/core';
// 2026-03-02 is a Monday. The tasks are written in four different shapes on purpose: when a drag// is written back into them with `patchTasks`, each one keeps the shape it has (an ISO string// stays an ISO string, a `duration` stays a `duration`, a `Date` stays a `Date`, epoch ms stay a// number), and the inspector under the chart shows that.//// `new Date(2026, 2, 16)` is local midnight of 16 March. A number is read the same way, through// the local clock, so it is made from a `Date` here: an epoch literal would land on another day// in some time zone.export const tasks: Task[] = [ // Phase: Design, with two children. ISO strings, and a `duration` instead of an `end`. { id: 'design', name: 'Design', start: '2026-03-02', end: '2026-03-13' }, { id: 'wireframes', name: 'Wireframes', parentId: 'design', start: '2026-03-02', end: '2026-03-06', }, { id: 'visuals', name: 'Visuals', parentId: 'design', start: '2026-03-09', duration: 5 }, // Phase: Build. Epoch milliseconds, and `Date` objects. { id: 'build', name: 'Build', start: '2026-03-16', end: '2026-03-27' }, { id: 'backend', name: 'Backend', parentId: 'build', start: new Date(2026, 2, 16).getTime(), end: new Date(2026, 2, 27).getTime(), }, { id: 'frontend', name: 'Frontend', parentId: 'build', start: new Date(2026, 2, 18), end: new Date(2026, 2, 27), }, { id: 'release', name: 'Release', type: 'milestone', start: '2026-03-30' },];
export const links: Link[] = [ { source: 'wireframes', target: 'visuals' }, { source: 'design', target: 'build' }, { source: 'build', target: 'release' },];import type { GanttOptions } from '@ganttlane/dom';
export const options = { // The scale of the timeline. In `<Gantt>` it is a controlled prop, so `react.tsx` passes the one // your state holds (this value is where the state starts). viewMode: 'week', // Rows and bars a little smaller than the default, so that the plan and its inspector fit. layout: { rowHeight: 36, barHeight: 22 }, // Open on the plan. The default, 'today', is far from these dates. scroll: { initial: 'first-task' },} satisfies Partial<GanttOptions>;import '@ganttlane/react/styles.css';import './styles.css';
import { Gantt, type GanttHandle } from '@ganttlane/react';import { useReducer, useRef, useState } from 'react';
import { Inspector } from './inspector';import { options } from './options';import { type Action, initial, reduce } from './state';
const VIEW_MODES = ['day', 'week', 'month'];
export default function Example() { const chart = useRef<GanttHandle>(null); // The application's state: the five groups below are props of the chart, and this is where they // live. Any component of the application can change them (the toolbar, the inspector), and the // chart follows. const [state, dispatch] = useReducer(reduce, initial); // For the experiment in the toolbar: whether the reducer takes in what the chart does by itself. const [refuse, setRefuse] = useState(false);
/** What the chart changed by itself (a method of the handle): your state may take it in. */ const take = (action: Action): void => { if (!refuse) dispatch(action); };
return ( <> <div role="toolbar" aria-label="Application state"> <label> View{' '} <select value={state.viewMode} onChange={(event) => { dispatch({ type: 'viewMode', id: event.target.value, by: 'the select' }); }} > {VIEW_MODES.map((id) => ( <option key={id} value={id}> {id} </option> ))} </select> </label> <button type="button" onClick={() => { dispatch({ type: 'expandedKeys', keys: [], by: 'a button' }); }} > Collapse all </button> <button type="button" onClick={() => { dispatch({ type: 'expandedKeys', keys: 'all', by: 'a button' }); }} > Expand all </button> <button type="button" onClick={() => { dispatch({ type: 'selectedKeys', keys: [], by: 'a button' }); }} > Clear selection </button> <button type="button" onClick={() => { dispatch({ type: 'reset' }); }} > Reset </button> </div> <div role="toolbar" aria-label="The chart changes things by itself"> <button type="button" onClick={() => { // A method of the handle: the chart changes the open phases itself, and tells // `onExpandedChange`. chart.current?.collapseAll(); }} > Chart: collapseAll() </button> <label> <input type="checkbox" checked={refuse} onChange={(event) => { setRefuse(event.target.checked); }} />{' '} Refuse what the chart changes by itself </label> </div> <Gantt ref={chart} // The chart fills its container and scrolls inside it, so the container needs a height. style={{ height: 330 }} // `options` first: `viewMode` in it is only where the state starts, and the props below win. {...options} // The five controlled groups. A group is controlled when its prop is given the first time // the chart renders: the prop is the truth, and the chart follows it. (There is no // `dataMode` prop: `tasks` is what makes the data controlled.) tasks={state.tasks} links={state.links} viewMode={state.viewMode} selectedKeys={state.selectedKeys} expandedKeys={state.expandedKeys} // What the chart did by itself, for your state to take in. The view mode, the selection // and the open phases go back to their prop at once if your state does not follow (tick // the box above to see it); a task change stays a draft until you hand new tasks back, // and an arrow is added only when you hand back the links. onTasksChange={(changes) => { // `model` is the one the change set was computed on: the chart's current one. const model = chart.current?.core.getSnapshot().state.derived.model; if (model) dispatch({ type: 'tasks', changes, model }); }} onLinksChange={(links) => { dispatch({ type: 'links', links }); }} onViewModeChange={(viewMode) => { take({ type: 'viewMode', id: viewMode.id, by: 'onViewModeChange' }); }} onSelectionChange={(keys) => { take({ type: 'selectedKeys', keys, by: 'onSelectionChange' }); }} onExpandedChange={(keys) => { take({ type: 'expandedKeys', keys, by: 'onExpandedChange' }); }} // A click on a bar selects it in the state. onTaskClick={(record) => { dispatch({ type: 'selectedKeys', keys: [record.id], by: 'onTaskClick' }); }} /> <Inspector state={state} onSelect={(id) => { dispatch({ type: 'selectedKeys', keys: [id], by: 'the inspector' }); }} /> </> );}import type { Task, TaskId } from '@ganttlane/react';import type { ReactNode } from 'react';
import type { Field, State } from './state';
/** A value as it is written in the application's state: a `Date` and a string look different. */function show(value: unknown): string { if (value instanceof Date) { const day = [value.getFullYear(), value.getMonth() + 1, value.getDate()] .map((part) => String(part).padStart(2, '0')) .join('-'); return `Date(${day})`; } return JSON.stringify(value);}
/** The second date of a task as it is stored: an `end`, or a `duration` instead. */function endOf(task: Task): string { if (task.end !== undefined) return `end ${show(task.end)}`; if (task.duration !== undefined) return `duration ${show(task.duration)}`; return '';}
function Value({ name, changed, children,}: { name: Field; changed: boolean; children: ReactNode;}) { return ( <div className="inspector__value" data-changed={changed}> <dt>{name}</dt> <dd> <code>{children}</code> </dd> </div> );}
/** * The application's state, as it is right now: what the chart is given as props. The field the * last action changed (and the tasks a change set touched) is marked, so a drag, a click or a * button can be followed into the state. */export function Inspector({ state, onSelect }: { state: State; onSelect: (id: TaskId) => void }) { const { last } = state; const changed = (field: Field): boolean => last.field === field;
return ( <div className="inspector"> <dl className="inspector__values"> <Value name="viewMode" changed={changed('viewMode')}> {show(state.viewMode)} </Value> <Value name="selectedKeys" changed={changed('selectedKeys')}> {show(state.selectedKeys)} </Value> <Value name="expandedKeys" changed={changed('expandedKeys')}> {show(state.expandedKeys)} </Value> <Value name="links" changed={changed('links')}> {state.links.map((link) => `${String(link.source)} → ${String(link.target)}`).join(', ')} </Value> </dl> <table className="inspector__tasks"> <caption data-changed={changed('tasks')}>tasks, as stored</caption> <thead> <tr> <th scope="col">id</th> <th scope="col">start</th> <th scope="col">end, or duration</th> </tr> </thead> <tbody> {state.tasks.map((task) => ( <tr key={task.id} data-changed={changed('tasks') && last.ids.includes(task.id)} data-selected={state.selectedKeys.includes(task.id)} > <th scope="row"> {/* A task list of your own: the chart follows `selectedKeys`. */} <button type="button" className="inspector__pick" aria-pressed={state.selectedKeys.includes(task.id)} onClick={() => { onSelect(task.id); }} > {task.parentId === undefined ? '' : '↳ '} {task.id} </button> </th> <td> <code>{show(task.start)}</code> </td> <td> <code>{endOf(task)}</code> </td> </tr> ))} </tbody> </table> <p className="inspector__note">{last.note}</p> </div> );}import { type ChangeSet, type ExpandedKeys, type Link, patchTasks, type Task, type TaskId,} from '@ganttlane/react';
import { links, tasks } from './data';import { options } from './options';
/** The `model` a change set was computed on: `chart.core.getSnapshot().state.derived.model`. */type Model = Parameters<typeof patchTasks>[2];
/** The five groups of the chart that this application owns. */export type Field = 'tasks' | 'links' | 'viewMode' | 'selectedKeys' | 'expandedKeys';
/** * The application's state, which is the truth about five groups of the chart: they are given to * `<Gantt>` as props, and the chart follows them. (`last` is only for the inspector: what the * last action changed.) */export interface State { readonly tasks: readonly Task[]; readonly links: readonly Link[]; readonly viewMode: string; readonly selectedKeys: readonly TaskId[]; readonly expandedKeys: ExpandedKeys; readonly last: { readonly field: Field | undefined; /** For `tasks`: the tasks the change set changed. */ readonly ids: readonly TaskId[]; readonly note: string; };}
export type Action = | { type: 'tasks'; changes: ChangeSet; model: Model } | { type: 'links'; links: readonly Link[] } | { type: 'viewMode'; id: string; by: string } | { type: 'selectedKeys'; keys: readonly TaskId[]; by: string } | { type: 'expandedKeys'; keys: ExpandedKeys; by: string } | { type: 'reset' };
export const initial: State = { tasks, links, viewMode: options.viewMode, selectedKeys: [], expandedKeys: 'all', last: { field: undefined, ids: [], note: 'The first state.' },};
const sameKeys = (a: readonly TaskId[], b: readonly TaskId[]): boolean => a.length === b.length && a.every((key, at) => key === b[at]);const sameExpanded = (a: ExpandedKeys, b: ExpandedKeys): boolean => typeof a === 'string' || typeof b === 'string' ? a === b : sameKeys(a, b);
/** The application's reducer: the one place where the chart's changes become state. */export function reduce(state: State, action: Action): State { switch (action.type) { case 'tasks': { // `patchTasks` writes a change set into your tasks, in the shape each one has, and returns // a new array (the same one if nothing changed). Passing it back ends the chart's draft. const next = patchTasks(state.tasks, action.changes, action.model); const ids = action.changes.changes.map((change) => change.id); return { ...state, tasks: next, last: { field: 'tasks', ids, note: `onTasksChange (${action.changes.reason}): patchTasks wrote ${String(ids.length)} ${ids.length === 1 ? 'task' : 'tasks'}.`, }, }; } case 'links': return { ...state, links: action.links, last: { field: 'links', ids: [], note: 'onLinksChange: your links, with the change applied.', }, }; case 'viewMode': if (action.id === state.viewMode) return state; return { ...state, viewMode: action.id, last: { field: 'viewMode', ids: [], note: `${action.by}: the view mode is ${action.id}.` }, }; case 'selectedKeys': // The same keys again are no change: do not give the chart a new array for nothing. if (sameKeys(action.keys, state.selectedKeys)) return state; return { ...state, selectedKeys: action.keys, last: { field: 'selectedKeys', ids: [], note: `${action.by}: the selection changed.` }, }; case 'expandedKeys': if (sameExpanded(action.keys, state.expandedKeys)) return state; return { ...state, expandedKeys: action.keys, last: { field: 'expandedKeys', ids: [], note: `${action.by}: the open phases changed.` }, }; case 'reset': return initial; }}/* * The look of the inspector under the chart: the application's state in a small table. It uses * no colour of the page but the text's own, so it follows a light or a dark theme. */.inspector { display: grid; gap: 0.5rem; margin-top: 0.5rem; font-size: 0.75rem; line-height: 1.35;}
.inspector code { font-family: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; font-size: 0.6875rem;}
/* The four values that are not tasks: two columns, one column on a narrow screen. */.inspector__values { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 0.25rem 1rem; margin: 0;}
.inspector__value { display: flex; gap: 0.5rem; padding: 0.125rem 0.25rem; border-radius: 0.25rem;}
/* The list of links is the longest value: it takes the whole row. */.inspector__value:last-child { grid-column: 1 / -1;}
.inspector__value dt { flex: none; min-width: 6rem; font-weight: 600;}
.inspector__value dd { margin: 0; min-width: 0; overflow-wrap: anywhere;}
.inspector__tasks { width: 100%; border-collapse: collapse;}
.inspector__tasks caption { text-align: left; font-weight: 600; padding: 0.125rem 0.25rem; border-radius: 0.25rem;}
.inspector__tasks th,.inspector__tasks td { padding: 0.0625rem 0.25rem; text-align: left; font-weight: normal; white-space: nowrap;}
.inspector__tasks thead th { font-weight: 600; opacity: 0.7;}
.inspector__tasks tr[data-selected='true'] th { font-weight: 700;}
/* What the last action changed. */.inspector [data-changed='true'] { background: rgb(245 159 0 / 0.3);}
/* A button that reads as a cell of the table, not as a toolbar button. */.inspector .inspector__pick { padding: 0; border: 0; background: none; color: inherit; font: inherit; text-decoration: underline; text-underline-offset: 2px;}Files
data.ts: the plan, with its dates written in four shapes, and its links.options.ts: the view mode the state starts with, the size of the rows and the first scroll.state.ts: the state, the actions and the reducer, withpatchTasks.react.tsx:useReducer,<Gantt>with the controlled props and their handlers, and the toolbars.inspector.tsxandstyles.css: the inspector.
Options
| Option | Value | What it does |
|---|---|---|
tasks |
state.tasks |
The data is yours; the chart reports changes and shows them as drafts |
links |
state.links |
The links, the same way |
viewMode |
state.viewMode |
The scale of the timeline |
selectedKeys |
state.selectedKeys |
The selected tasks |
expandedKeys |
state.expandedKeys |
The open phases: 'all' or a list of ids |
onTasksChange |
a function | A ChangeSet for every change; the reducer writes it with patchTasks |
onLinksChange |
a function | Your links with the arrow that was drawn or removed |
onViewModeChange, onSelectionChange, onExpandedChange |
functions | What the chart changed by itself, for your state to take in |
onTaskClick |
a function | A click on a bar selects the task in the state |
ref |
useRef<GanttHandle> |
The model a change set needs, and collapseAll() for the experiment |
layout.rowHeight, layout.barHeight |
36, 22 |
Smaller rows, so that the plan and the inspector fit |
scroll.initial |
'first-task' |
Opens on the plan |
What to try
- Drag a bar and watch the inspector. Drag Design: the change set holds Design, its two
children and everything that follows along the links, and
patchTaskswrites all seven into your tasks. They keep the shape they had: an ISO string stays an ISO string, Visuals keeps itsdurationand only its start changes, the epoch numbers of Backend stay numbers, and theDates of Frontend stayDates. - Click a bar.
onTaskClickputs the task inselectedKeys, and the chart draws it selected. Click a task in the inspector’s list instead: the state changes from outside the chart and the chart follows. - Press the buttons. The view mode, Collapse all and Expand all change the state, and the chart is brought to it.
- Draw an arrow. Hover a bar and drag its round handle onto another bar: the chart gives the
list with the new arrow to
onLinksChange, and draws it once your state has it. - Refuse a change. Tick the box and press Chart: collapseAll(). The chart tries to close
the phases and tells
onExpandedChange, which the reducer now ignores: the phases open again at once, becauseexpandedKeysis still'all'. Untick the box, and the reducer takes the change in:expandedKeysbecomes[].
Notes
- A group is controlled by its prop, given the first time the chart renders, and it stays
that way (switching is reported in development and ignored).
defaultTasks,defaultLinks,defaultViewMode,defaultSelectedKeysanddefaultExpandedKeysare the uncontrolled twins.<Gantt>has nodataModeprop:tasksis what makes the data controlled. - A handler is the chart’s report, not a change. The view mode, the selection and the open phases go back to their prop at once when your state does not follow, as the experiment shows. A task change stays a draft, drawn over your tasks, until you hand new tasks back (the same array again is nothing; a new one is your answer, and a new one with the old content refuses the change). An arrow is added only when you hand back the links.
patchTasks(tasks, changes, model)writes aChangeSetinto your own tasks, in the shape each one has, and returns a new array (the same one if nothing changed). It never mutates what you pass. Themodelis the one the change set was computed on, which is the chart’s own:chart.core.getSnapshot().state.derived.model. The reducer stays a pure function because the model travels in the action.- Give the chart the same array until something changes. The reducer returns the state it was
given when an action changes nothing, so a click on the selected task does not hand the chart a
new
selectedKeys. Anything you derive from the tasks for the chart should be memoized; the inspector derives for itself, and what it shows is not given to the chart. onSelectionChangealso reports the selected arrow, as its second argument. A click on an arrow does not changeselectedKeys, so the reducer sees the same keys and does nothing.- The epoch numbers depend on the time zone. They are made from
new Date(2026, 2, 16), the local midnight, so they differ from one machine to another, and read as the same days on each. useStateworks as well. The five groups are plain props; a reducer is only convenient because they change together (Reset puts all five back). For the simpler case with one group see Controlled data, and for an edit session that saves or cancels, Edit session.