Controlled data
By default the chart owns the tasks: a drag changes its own copy and tells you. With
dataMode: 'controlled' you own them, for an application whose tasks live in a store, a query
cache or a database. The chart never changes your tasks; instead:
- every committed change (a drag,
updateTask, an undo withhistory: true) is reported once, as aChangeSet, toonTasksChange, and is shown as a draft over your tasks; - you write it into your own tasks with
patchTasks, which keeps the shape of each task (ISO strings stay ISO strings, adurationstays aduration); - you hand the result back, with
setTasks(a newtasksprop in React), which ends the draft. Handing back the old tasks refuses the change: the bar goes back to where your data has it.
While a change waits for your answer the chart is busy: no new drag starts. That makes a save request a natural place to answer from. Here the “server” answers after a moment, and a checkbox makes it say no.
pnpm add @ganttlane/dom @ganttlane/core # vanilla: patchTasks is the core'spnpm add @ganttlane/react react react-dom # React: it exports patchTasks toonpm install @ganttlane/dom @ganttlane/core # vanilla: patchTasks is the core'snpm install @ganttlane/react react react-dom # React: it exports patchTasks tooyarn add @ganttlane/dom @ganttlane/core # vanilla: patchTasks is the core'syarn add @ganttlane/react react react-dom # React: it exports patchTasks toobun add @ganttlane/dom @ganttlane/core # vanilla: patchTasks is the core'sbun add @ganttlane/react react react-dom # React: it exports patchTasks tooimport type { Link, Task } from '@ganttlane/core';
// The starting point of the application's own state: the chart never changes these arrays.export const tasks: Task[] = [ { id: 'design', name: 'Design', start: '2026-03-02', end: '2026-03-06', progress: 60 }, { id: 'build', name: 'Build', start: '2026-03-09', end: '2026-03-20' }, { id: 'qa', name: 'QA', start: '2026-03-23', duration: 5 }, { id: 'launch', name: 'Launch', type: 'milestone', start: '2026-03-30' },];
export const links: Link[] = [ { source: 'design', target: 'build' }, { source: 'build', target: 'qa' }, { source: 'qa', target: 'launch' },];import type { GanttOptions } from '@ganttlane/dom';
export const options = { // Your state owns the tasks. The chart never changes them: it reports every committed change to // `onTasksChange`, shows it as a draft, and waits for you to hand the tasks back (`setTasks`). // (<Gantt> has no `dataMode`: passing the `tasks` prop makes it controlled.) dataMode: 'controlled', viewMode: 'week', scroll: { initial: 'first-task' },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';
import { patchTasks } from '@ganttlane/core';import { createGantt, type Gantt, type Link, type Task } from '@ganttlane/dom';
import { links as initialLinks, tasks as initialTasks } from './data';import { linkId, saveTasks } from './helpers';import { options } from './options';
export function mount(container: HTMLElement): () => void { const root = document.createElement('div'); const toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); const refuse = document.createElement('input'); refuse.type = 'checkbox'; const refuseLabel = document.createElement('label'); refuseLabel.append(refuse, ' The server refuses the save'); toolbar.append(refuseLabel); const chart = document.createElement('div'); chart.style.height = '380px'; const status = document.createElement('p'); status.textContent = 'Drag a bar: the change is saved, or refused if you tick the box.'; root.append(toolbar, chart, status); container.append(root);
// Your own state: two variables here, a store in an application. let tasks: Task[] = initialTasks; let links: Link[] = initialLinks;
// The handlers read `gantt`, which they are passed to: give them a return type. const gantt: Gantt = createGantt(chart, { tasks, links, ...options,
onTasksChange: (changeSet): void => { // `model` is the one the change set was computed on: the chart's current one. const next = patchTasks(tasks, changeSet, gantt.core.getSnapshot().state.derived.model); status.textContent = 'Saving…'; // The bars show the change as a draft until the answer comes. void saveTasks(refuse.checked).then( () => { tasks = next; gantt.setTasks(tasks); // accepted: the draft becomes your data status.textContent = 'Saved.'; }, () => { gantt.setTasks(tasks); // refused: your old tasks, and the bars go back status.textContent = 'The server refused the change: the bars went back.'; }, ); },
// Links are yours the same way: the chart reports the one drawn or removed, you keep the list. onLinkCreate: (link): void => { links = [...links, link]; gantt.setLinks(links); }, onLinkDelete: (record): void => { links = links.filter((link) => linkId(link) !== record.id); gantt.setLinks(links); }, });
return () => { gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';
import { Gantt, type GanttHandle, type Link, patchTasks } from '@ganttlane/react';import { useRef, useState } from 'react';
import { links as initialLinks, tasks as initialTasks } from './data';import { saveTasks } from './helpers';import { options } from './options';
export default function Example() { const chart = useRef<GanttHandle>(null); // Your own state. `tasks` going in as a prop makes the chart controlled from its first render. const [tasks, setTasks] = useState(initialTasks); const [links, setLinks] = useState<readonly Link[]>(initialLinks); const [refuse, setRefuse] = useState(false); const [status, setStatus] = useState( 'Drag a bar: the change is saved, or refused if you tick the box.', );
return ( <> <div role="toolbar"> <label> <input type="checkbox" checked={refuse} onChange={(e) => setRefuse(e.target.checked)} />{' '} The server refuses the save </label> </div> <Gantt ref={chart} style={{ height: 380 }} {...options} tasks={tasks} links={links} onTasksChange={(changeSet) => { // `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) return; const next = patchTasks(tasks, changeSet, model); setStatus('Saving…'); // The bars show the change as a draft until you pass new tasks back. void saveTasks(refuse).then( () => { setTasks(next); // accepted: the draft becomes your data setStatus('Saved.'); }, () => { // Refused: a new array (even with the same content) ends the draft, and the bars go back. setTasks([...tasks]); setStatus('The server refused the change: the bars went back.'); }, ); }} // A link drawn or removed: your links with the change applied. onLinksChange={setLinks} /> <p>{status}</p> </> );}import type { Link } from '@ganttlane/core';
/** * The id a link has when it has none of its own: the chart's rule, which `onLinkDelete` reports * the link by. * * @param link - A link of your list. * @returns Its id. */export function linkId(link: Link): string { return link.id ?? `${String(link.source)}:${link.type ?? 'FS'}:${String(link.target)}`;}
/** * Stands for your API: resolves when the server keeps the new tasks, rejects when it does not. * * @param refuse - Whether the server says no, to see a refusal. * @returns A promise of the answer, after a short wait. */export function saveTasks(refuse: boolean): Promise<void> { return new Promise((resolve, reject) => { setTimeout(() => { if (refuse) reject(new Error('The server refused the change')); else resolve(); }, 400); });}Files
data.ts: the initial tasks and links, the start of your state.options.ts:dataMode: 'controlled', the view mode and the first scroll.helpers.ts:saveTasks, which stands for your API, andlinkId.vanilla.ts: the state in two variables,patchTasksandsetTasks.react.tsx: the state inuseState, and thetasksandlinksprops.
Options
| Option | Value | What it does |
|---|---|---|
dataMode |
'controlled' |
The chart never changes your tasks; it reports changes and shows them as drafts |
onTasksChange |
a function | Receives the ChangeSet of every committed change: write it into your tasks |
onLinkCreate/onLinkDelete |
functions (vanilla) | The link that was drawn or removed; you keep the list and hand it back with setLinks |
tasks, links |
your state (React) | The props that make <Gantt> controlled; onLinksChange gives the list with the change |
viewMode |
'week' |
The scale of the timeline |
scroll.initial |
'first-task' |
Opens on the plan |
What to try
Drag a bar. The change set holds the dragged task and every task that follows it along the links,
and patchTasks writes all of them. Tick the checkbox to see a refusal: the bars jump back. Draw a
link from the round handle beside a bar: your list gets it.
Notes
patchTasks(tasks, changeSet, model)takes the array you give it and returns a new one, or the same array when nothing changes. It never mutatestasks. Themodelis the one the change set was computed on,core.getSnapshot().state.derived.model.@ganttlane/domre-exports the types of the core (Task,Link,ChangeSet, …) but not its runtime functions, sopatchTasksis imported from@ganttlane/core: add it to your ownpackage.json, on the same version.@ganttlane/reactexports it itself.- React: a new array is your answer. The same
tasksarray again is nothing; a new one, even with equal contents, ends the draft. So derive nothing fromtasksin the render body withoutuseMemo, or any re-render (the status text here!) would discard the change that is being saved. The refusal above issetTasks([...tasks]). - The same answer is how a rollback works: see Edit session, where the whole Edit, Save and Cancel flow is built.
- In React the choice is made by props (
tasksordefaultTasks), withoutdataMode; see@ganttlane/react.