Перейти к основному содержимому

React Gantt - TanStack Query + Supabase Tutorial

This tutorial extends Using React Gantt with TanStack Query by replacing the local JSON backend with Supabase (PostgreSQL + Realtime) and adding live multi-user synchronization. When one user creates, edits, or deletes a task the change is reflected instantly in every other open tab - no page refresh needed.

The guide focuses on what is new and different from the base TanStack Query tutorial:

  • Supabase database setup
  • Realtime subscriptions
  • batchSave instead of save for grouping mutations
  • Server-side sortorder management for persistent task ordering
  • XSS sanitization on the backend
  • Persistence-aware undo/redo via applySnapshotDiff

If you are not familiar with the base setup (Vite + React + TypeScript, TanStack Query, Zustand, basic CRUD mutations, snapshot-based undo/redo), read the TanStack Query tutorial first.

Prerequisites

Project setup

npm create vite@latest react-gantt-tanstack-supabase-demo -- --template react-ts
cd react-gantt-tanstack-supabase-demo

Install dependencies. This demo uses shadcn/ui (Tailwind-based) instead of MUI:

npm install @tanstack/react-query zustand @supabase/supabase-js \
express cors dotenv \
tailwindcss @tailwindcss/vite lucide-react \
radix-ui class-variance-authority clsx tailwind-merge

Dev dependencies:

npm install -D tsx nodemon @types/express @types/cors @types/node

Install React Gantt:

npm install @dhtmlx/trial-react-gantt

Add scripts to package.json:

"scripts": {
"dev": "vite",
"start:server": "nodemon --exec tsx src/server.ts"
}

Configuring Supabase environment variables

Create .env (based on .env.example):

VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_ANON_KEY=your-anon-key

The VITE_ prefix exposes variables to the Vite frontend bundle. The unprefixed variables are used by the Express backend at runtime via dotenv.

Setting up the database

In the Supabase SQL Editor run the migration from setup.sql:

-- Create a table for the tasks for Supabase
CREATE TABLE tasks (
id varchar(255) NOT NULL,
text TEXT NOT NULL,
start_date TIMESTAMPTZ NOT NULL,
end_date TIMESTAMPTZ NOT NULL,
duration INT NOT NULL,
type TEXT,
progress FLOAT,
parent varchar(255),
sortorder INT NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (parent) REFERENCES tasks(id) ON DELETE CASCADE
);

-- Create a table for the links for Supabase
CREATE TABLE links (
id varchar(255) NOT NULL,
source varchar(255) NOT NULL,
target varchar(255) NOT NULL,
type TEXT NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (source) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (target) REFERENCES tasks(id) ON DELETE CASCADE
);

-- Clear existing data (optional)
DELETE FROM links;
DELETE FROM tasks;

-- Insert tasks
INSERT INTO tasks (id, text, start_date, end_date, duration, type, progress, parent, sortorder)
VALUES
('1', 'Project #1', '2026-04-01', '2026-04-10', 9, 'project', 0.4, NULL, 1),

('2', 'Task 1', '2026-04-01', '2026-04-03', 2, 'task', 0.6, '1', 2),
('3', 'Task 2', '2026-04-03', '2026-04-06', 3, 'task', 0.3, '1', 3),
('4', 'Task 3', '2026-04-06', '2026-04-10', 4, 'task', 0.1, '1', 4),

('5', 'Milestone', '2026-04-10', '2026-04-10', 0, 'milestone', 1, '1', 5);

-- Insert links (dependencies)
INSERT INTO links (id, source, target, type)
VALUES
('1', '2', '3', '0'),
('2', '3', '4', '0'),
('3', '4', '5', '0');

Key design points:

  • sortorder is a server-managed integer that preserves the visual order tasks appear in the Gantt grid.
  • Both links.source and links.target have ON DELETE CASCADE so deleting a task automatically removes any dependent links.
  • tasks.parent also cascades, so deleting a parent removes all children.

Supabase Realtime must be enabled for both tables. In the Supabase dashboard go to Database → Replication and add the tasks and links tables to the replication publication.

Dual Supabase clients

The demo uses two separate Supabase client instances because the frontend and backend run in different environments.

src/db/supabaseClient.ts - browser client, reads env via import.meta.env:

import { createClient, SupabaseClient } from '@supabase/supabase-js';

const supabaseUrlClient = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKeyClient = import.meta.env.VITE_SUPABASE_ANON_KEY;

if (!supabaseUrlClient || !supabaseAnonKeyClient) {
throw new Error('Missing Supabase environment variables');
}

export const supabaseClient: SupabaseClient = createClient(supabaseUrlClient, supabaseAnonKeyClient);

src/db/supabaseServer.ts - server-side client, reads env via process.env (loaded by dotenv):

import { createClient } from '@supabase/supabase-js';
import 'dotenv/config';

const supabaseUrlServer = process.env.SUPABASE_URL;
const supabaseAnonKeyServer = process.env.SUPABASE_ANON_KEY;

if (!supabaseUrlServer || !supabaseAnonKeyServer) {
throw new Error('Missing Supabase environment variables');
}

export const supabaseServer = createClient(supabaseUrlServer, supabaseAnonKeyServer);

supabaseClient is used exclusively for Realtime subscriptions in GanttComponent.tsx. All database writes go through supabaseServer in the Express layer.

заметка

This starter uses the anon key server-side because the demo schema has no Row-Level Security policies and there is no authentication. In production with auth and RLS, the server should hold the service role key (kept off the frontend) to bypass RLS for trusted operations.

TypeScript types

src/types/types.ts defines the database row shapes and service interfaces:

export interface TaskRow {
id: string;
text: string;
start_date: string;
end_date: string;
duration: number;
type?: string;
progress?: number;
parent: string | null;
sortorder: number;
}

export interface LinkRow {
id: string;
source: string;
target: string;
type: string;
}

export interface ITaskService {
createTask(task: Omit<TaskRow, 'sortorder'>): Promise<TaskRow>;
updateTask(id: string, fields: Partial<TaskRow>, target?: string): Promise<TaskRow>;
deleteTask(id: string): Promise<TaskRow>;
}

export interface ILinkService {
createLink(link: LinkRow): Promise<LinkRow>;
updateLink(id: string, fields: Partial<LinkRow>): Promise<LinkRow>;
deleteLink(id: string): Promise<LinkRow>;
}

export interface IGanttDataService {
getData(): Promise<{ tasks: TaskRow[]; links: LinkRow[] }>;
}

ITaskService.updateTask accepts an optional target parameter. This is the drop-target task id sent by the Gantt when the user reorders tasks by drag-and-drop, and it triggers the server-side reorder logic.

Building the backend services

ganttDataService

src/services/ganttDataService.ts fetches all tasks ordered by sortorder:

import { supabaseServer } from '@/db/supabaseServer';
import type { IGanttDataService, LinkRow, TaskRow } from '@/types/types';

class GanttDataService implements IGanttDataService {
async getData(): Promise<{ tasks: TaskRow[]; links: LinkRow[] }> {
const { data: tasks, error: tasksError } = await supabaseServer
.from('tasks')
.select('*')
.order('sortorder', { ascending: true });
if (tasksError) throw tasksError;

const { data: links, error: linksError } = await supabaseServer.from('links').select('*');
if (linksError) throw linksError;

const normalizedTasks = tasks.map((t) => ({ ...t, parent: t.parent ?? '0' }));
return { tasks: normalizedTasks, links };
}
}

const ganttDataService = new GanttDataService();
export default ganttDataService;

parent is stored as NULL in the database for root-level tasks. The Gantt expects the string '0' for the same role, so the service normalizes the value on read.

sanitize helper

src/services/sanitize.ts HTML-escapes text fields before they reach the database:

const TEXT_FIELDS: string[] = ['text'];

function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

export function sanitize<T extends object>(obj: T): T {
const out = { ...obj } as Record<string, unknown>;
for (const key of TEXT_FIELDS) {
if (typeof out[key] === 'string') {
out[key] = escapeHtml(out[key] as string);
}
}
return out as T;
}

Every write service calls sanitize() before inserting or updating. Add field names to TEXT_FIELDS when the schema gains additional user-editable text columns.

taskService - sortorder management

src/services/taskService.ts is the most complex service because it manages the persistent task order.

import { supabaseServer } from '@/db/supabaseServer';
import { type TaskRow, type ITaskService } from '../types/types';
import { sanitize } from './sanitize';

class TaskService implements ITaskService {
private normalizeParent<T extends { parent?: string | null }>(data: T): T {
return { ...data, parent: data.parent === '0' ? null : data.parent };
}

async createTask(task: Omit<TaskRow, 'sortorder'>): Promise<TaskRow> {
const cleanData = sanitize(this.normalizeParent(task));

const { data: lastOrderIndex } = await supabaseServer
.from('tasks')
.select('sortorder')
.order('sortorder', { ascending: false })
.limit(1)
.maybeSingle();

const nextOrder = (lastOrderIndex?.sortorder ?? 0) + 1;
const { data, error } = await supabaseServer
.from('tasks')
.insert({ ...cleanData, sortorder: nextOrder })
.select()
.single();

if (error) throw error;
return { ...data, parent: data.parent ?? '0' };
}

async updateTask(id: string, fields: Partial<TaskRow>, target?: string): Promise<TaskRow> {
const cleanData = sanitize(this.normalizeParent(fields));

const { data, error } = await supabaseServer
.from('tasks')
.update(cleanData)
.eq('id', id)
.select()
.single();

if (error) throw error;
if (target) await this.reorder(id, target);
return { ...data, parent: data.parent ?? '0' };
}

async deleteTask(id: string): Promise<TaskRow> {
const { data, error } = await supabaseServer.from('tasks').delete().eq('id', id).select().single();
if (error) throw error;
return data;
}

private async reorder(taskId: string, target: string): Promise<void> {
let nextTask = false;
let targetId = target;

if (targetId.startsWith('next:')) {
targetId = targetId.slice('next:'.length);
nextTask = true;
}

const { data: targetTask } = await supabaseServer
.from('tasks')
.select('sortorder')
.eq('id', targetId)
.single();

if (!targetTask) return;

let targetOrder = targetTask.sortorder;
if (nextTask) targetOrder++;

const { data: toShift } = await supabaseServer
.from('tasks')
.select('id, sortorder')
.gte('sortorder', targetOrder)
.neq('id', taskId)
.order('sortorder', { ascending: false });

for (const task of toShift ?? []) {
await supabaseServer
.from('tasks')
.update({ sortorder: task.sortorder + 1 })
.eq('id', task.id);
}

await supabaseServer.from('tasks').update({ sortorder: targetOrder }).eq('id', taskId);
}
}

const taskService = new TaskService();
export default taskService;

The reorder method:

  1. Reads the sortorder of the drop target.
  2. If the target string is prefixed with next: the dragged task should be placed after the target, so the order is incremented by one.
  3. Shifts all tasks whose sortorder is ≥ targetOrder down by one to make a gap.
  4. Sets the dragged task's sortorder to the gap position.

The tasks are iterated in descending order before shifting to avoid unique-constraint collisions during sequential updates.

linkService

src/services/linkService.ts is straightforward CRUD backed by Supabase:

import type { ILinkService, LinkRow } from '@/types/types';
import { sanitize } from './sanitize';
import { supabaseServer } from '@/db/supabaseServer';

class LinkService implements ILinkService {
async createLink(link: LinkRow): Promise<LinkRow> {
const cleanData = sanitize(link);
const { data, error } = await supabaseServer.from('links').insert(cleanData).select().single();
if (error) throw error;
return data;
}
async updateLink(id: string, fields: Partial<LinkRow>): Promise<LinkRow> {
const cleanData = sanitize(fields);
const { data, error } = await supabaseServer.from('links').update(cleanData).eq('id', id).select().single();
if (error) throw error;
return data;
}
async deleteLink(id: string): Promise<LinkRow> {
const { data, error } = await supabaseServer.from('links').delete().eq('id', id).select().single();
if (error) throw error;
return data;
}
}

const linkService = new LinkService();
export default linkService;

Building the Express backend

src/server.ts is a thin HTTP layer over the services. It delegates every route to the appropriate service and uses a shared error handler:

import express, { type Response, type Request } from 'express';
import cors from 'cors';
import 'dotenv/config';
import ganttDataService from './services/ganttDataService';
import taskService from './services/taskService';
import linkService from './services/linkService';

const app = express();
app.use(express.json());
app.use(cors());
const PORT = 3001;

app.get('/data', async (_req, res) => {
try { res.json(await ganttDataService.getData()); }
catch (error) { handleError(error as Error, res, 'Failed to fetch data'); }
});

app.post('/tasks', async (req, res) => {
try { res.json(await taskService.createTask(req.body)); }
catch (error) { handleError(error as Error, res, 'Failed to create task'); }
});

app.put('/tasks/:id', async (req, res) => {
try {
const { target, ...taskFields } = req.body;
res.json(await taskService.updateTask(req.params.id, taskFields, target));
} catch (error) { handleError(error as Error, res, 'Failed to update task'); }
});

app.delete('/tasks/:id', async (req, res) => {
try { res.json(await taskService.deleteTask(req.params.id)); }
catch (error) { handleError(error as Error, res, 'Failed to delete task'); }
});

// ... link routes follow the same pattern

function handleError(error: Error, res: Response, message: string) {
console.error(error);
res.status(500).json({ error: message });
}

app.listen(PORT, () => console.log(`Server running on ${PORT}`));

The PUT /tasks/:id handler destructures target out of the request body before forwarding taskFields to taskService.updateTask. target is only present when the Gantt signals a drag-and-drop reorder.

Creating the API layer

src/api.ts is similar to the base TanStack Query demo - plain fetch wrappers that throw on non-2xx responses. The key difference: every mutation now returns the server response JSON (the updated/deleted row) instead of discarding it. The returned id is used by mutations to register pending operations for deduplication.

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) => {
const res = await request(`${BASE}/tasks/${task.id}`, {
method: 'PUT',
body: JSON.stringify(task),
headers: { 'Content-Type': 'application/json' },
});
return await res.json();
};

export const deleteTask = async (id: string | number) => {
const res = await request(`${BASE}/tasks/${id}`, { method: 'DELETE' });
return await res.json();
};

// createLink, updateLink, deleteLink follow the same pattern against /links

Frontend requests hit the same origin as the Vite dev server (http://localhost:3000); a proxy in vite.config.ts forwards /data, /tasks, and /links to the Express backend on port 3001:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';

const API_URL = 'http://localhost:3001';

export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: { alias: { '@': path.resolve(__dirname, './src') } },
server: {
port: 3000,
open: true,
proxy: { '/data': API_URL, '/tasks': API_URL, '/links': API_URL },
},
});

Zustand store changes

The store (src/store.ts) gains two additions compared to the base tutorial:

Extended config with Gantt options for drag-and-drop reordering:

config: {
zoom: defaultZoomLevels,
open_tree_initially: true,
order_branch: true, // enables drag reorder within the same parent
order_branch_free: true, // enables drag reorder across parents
cascade_delete: false,
root_id: '0',
},

diffSnapshots utility that computes the minimal diff between two snapshots:

export type SnapshotDiff = {
tasks: { created: SerializedTask[]; updated: SerializedTask[]; deleted: (string | number)[] };
links: { created: Link[]; updated: Link[]; deleted: (string | number)[] };
};

// Builds Maps for both snapshots and classifies each item as created/updated/deleted
// by comparing JSON-serialized values
export function diffSnapshots(from: Snapshot, to: Snapshot): SnapshotDiff {
const fromTasksMap = new Map(from.tasks.map((t) => [t.id, t]));
const toTasksMap = new Map(to.tasks.map((t) => [t.id, t]));
const fromLinksMap = new Map(from.links.map((l) => [l.id, l]));
const toLinksMap = new Map(to.links.map((l) => [l.id, l]));

const tasksCreated: SerializedTask[] = [];
const tasksUpdated: SerializedTask[] = [];
const tasksDeleted: (string | number)[] = [];
const linksCreated: Link[] = [];
const linksUpdated: Link[] = [];
const linksDeleted: (string | number)[] = [];

for (const [id, task] of toTasksMap) {
if (!fromTasksMap.has(id)) {
tasksCreated.push(task);
} else if (JSON.stringify(fromTasksMap.get(id)) !== JSON.stringify(task)) tasksUpdated.push(task);
}
for (const id of fromTasksMap.keys()) {
if (!toTasksMap.has(id)) tasksDeleted.push(id);
}

for (const [id, link] of toLinksMap) {
if (!fromLinksMap.has(id)) {
linksCreated.push(link);
} else if (JSON.stringify(fromLinksMap.get(id)) !== JSON.stringify(link)) linksUpdated.push(link);
}
for (const id of fromLinksMap.keys()) {
if (!toLinksMap.has(id)) linksDeleted.push(id);
}

return {
tasks: { created: tasksCreated, updated: tasksUpdated, deleted: tasksDeleted },
links: { created: linksCreated, updated: linksUpdated, deleted: linksDeleted },
};
}

diffSnapshots is used by applySnapshotDiff in GanttComponent.tsx to persist undo/redo operations to the server.

Building the Gantt component

src/components/GanttComponent.tsx extends the base tutorial with three significant additions: Realtime subscriptions, batchSave, and persistence-aware undo/redo.

Imports and refs

import { supabaseClient } from '../db/supabaseClient';
import type { RealtimePostgresChangesPayload } from '@supabase/supabase-js';
import { type Snapshot, diffSnapshots, useGanttStore } from '../store';

// ...inside the component:
const pendingOperationsRef = useRef<Set<string>>(new Set());
const isUndoRedoRef = useRef(false);
const prevSnapshotRef = useRef<Snapshot | null>(null);
  • pendingOperationsRef tracks operations initiated by the local user so their Realtime echoes can be ignored.
  • isUndoRedoRef suppresses cache invalidation during undo/redo batches to avoid a server round-trip that would clobber the in-progress optimistic rollback.
  • prevSnapshotRef holds the snapshot taken just before batchSave fires, used as the from state for diffSnapshots.

Tracking the pre-mutation snapshot

useEffect(() => {
if (!isUndoRedoRef.current) {
prevSnapshotRef.current = makeSnapshot();
}
}, [makeSnapshot]);

This effect runs after every render while not in an undo/redo operation. It keeps prevSnapshotRef up to date so that when batchSave fires it always has the correct baseline to push onto the undo stack.

Realtime subscriptions

useEffect(() => {
const tasksChannel = supabaseClient
.channel('gantt-tasks')
.on('postgres_changes', { event: '*', schema: 'public', table: 'tasks' }, postgresChangesHandler<SerializedTask>)
.subscribe();

const linksChannel = supabaseClient
.channel('gantt-links')
.on('postgres_changes', { event: '*', schema: 'public', table: 'links' }, postgresChangesHandler<Link>)
.subscribe();

return () => {
supabaseClient.removeChannel(tasksChannel);
supabaseClient.removeChannel(linksChannel);
};
}, [postgresChangesHandler]);

Supabase sends a Postgres CDC (Change Data Capture) event for every row change. The cleanup function removes both channels when the component unmounts to avoid duplicate listeners on hot reload.

Deduplicating own-change echoes

Every time the local user triggers a mutation, the mutation's onSuccess callback registers the operation in pendingOperationsRef:

const createOperationKey = useCallback((action: string, id: string) => {
pendingOperationsRef.current.add(`${action}-${id}`);
}, []);

const createTaskMutation = useMutation({
mutationFn: createTask,
onSuccess: (data) => {
createOperationKey(OperationType.INSERT, data.id);
if (!isUndoRedoRef.current) queryClient.invalidateQueries({ queryKey: ['data'] });
},
onError,
});
// ... same pattern for other mutations

The postgresChangesHandler checks whether the arriving event matches a pending key and, if so, silently consumes it instead of triggering a refetch:

const postgresChangesHandler = useCallback(
<T extends ItemWithId>(payload: RealtimePostgresChangesPayload<T>) => {
const eventType = payload.eventType;
const id = (payload.new as T)?.id || (payload.old as T)?.id;
const operationKey = `${eventType}-${id}`;

if (pendingOperationsRef.current.has(operationKey)) {
pendingOperationsRef.current.delete(operationKey);
return;
}
queryClient.invalidateQueries({ queryKey: ['data'] });
},
[pendingOperationsRef, queryClient],
);

Without this pattern, every local mutation would trigger two refetches: one from onSuccess and one from the Realtime echo. With it, local changes invalidate the cache exactly once, and only changes from other clients cause an additional refetch.

заметка

Drag-and-drop reorders update sortorder on multiple rows server-side. Only the primary task is registered in pendingOperationsRef; the side-effect sortorder updates on other tasks produce untracked Realtime events that slip through to invalidateQueries. This is harmless - sortorder is server-only state, and TanStack Query deduplicates rapid invalidations into a single background refetch.

batchSave instead of save

This demo uses data.batchSave instead of data.save. batchSave groups all changes that result from a single user interaction (for example, a drag-and-drop that moves a task and updates its parent simultaneously) into one callback invocation:

const data: ReactGanttProps['data'] = useMemo(
() => ({
batchSave: (changes: BatchChanges) => {
const { tasks: tasksChanges, links: linksChanges } = changes;

if (prevSnapshotRef.current) {
recordHistory(prevSnapshotRef.current);
}

tasksChanges?.forEach((task) => {
if (task.action === 'create') createTaskMutation.mutate(task.data);
else if (task.action === 'update') updateTaskMutation.mutate(task.data);
else if (task.action === 'delete') deleteTaskMutation.mutate(task.data.id);
});

linksChanges?.forEach((link) => {
if (link.action === 'create') createLinkMutation.mutate(link.data);
else if (link.action === 'update') updateLinkMutation.mutate(link.data);
else if (link.action === 'delete') deleteLinkMutation.mutate(link.data.id);
});
},
}),
[
createTaskMutation,
updateTaskMutation,
deleteTaskMutation,
createLinkMutation,
updateLinkMutation,
deleteLinkMutation,
recordHistory,
],
);

Key differences from save:

  • A single undo entry covers the entire batch, not individual sub-operations.
  • The snapshot recorded is prevSnapshotRef.current - the state captured just before batchSave fired - so undo always reverts the complete interaction.
  • The Gantt calls batchSave once per user gesture even if that gesture produces multiple database writes.

For more about batchSave see Data Binding & State Management Basics.

Persistence-aware undo/redo

In the base TanStack Query tutorial, handleUndo and handleRedo write a snapshot into the client cache with setQueryData and that is it - changes are not persisted until the user makes the next manual edit.

In this demo, undo/redo must also persist the rollback to Supabase so that other connected clients see it. This is done with applySnapshotDiff:

const applySnapshotDiff = useCallback(
async (from: Snapshot, to: Snapshot) => {
const diff = diffSnapshots(from, to);

const mutations: Promise<unknown>[] = [];
const mutateAsync = <T,>(fn: (arg: T) => Promise<unknown>, arg: T) => {
mutations.push(fn(arg));
};

isUndoRedoRef.current = true;

// Links must be deleted before tasks (FK), tasks must be created before links (FK)
diff.links.deleted.forEach((id) => mutateAsync(deleteLinkMutation.mutateAsync, id));
diff.links.updated.forEach((link) => mutateAsync(updateLinkMutation.mutateAsync, link));
const batch1 = await Promise.allSettled(mutations.splice(0));

diff.tasks.deleted.forEach((id) => mutateAsync(deleteTaskMutation.mutateAsync, id));
diff.tasks.created.forEach((task) => mutateAsync(createTaskMutation.mutateAsync, task));
diff.tasks.updated.forEach((task) => mutateAsync(updateTaskMutation.mutateAsync, task));
const batch2 = await Promise.allSettled(mutations.splice(0));

diff.links.created.forEach((link) => mutateAsync(createLinkMutation.mutateAsync, link));
const batch3 = await Promise.allSettled(mutations.splice(0));

const results = [...batch1, ...batch2, ...batch3];
const rejected = results.filter((result) => result.status === 'rejected');

isUndoRedoRef.current = false;

if (rejected.length) {
console.error('Mutation failed:', rejected);
queryClient.invalidateQueries({ queryKey: ['data'] });
}
},
[
createTaskMutation,
updateTaskMutation,
deleteTaskMutation,
createLinkMutation,
updateLinkMutation,
deleteLinkMutation,
queryClient,
],
);

const handleUndo = () => {
const current = makeSnapshot();
const snapshot = undo(current);
if (snapshot) {
queryClient.setQueryData(['data'], snapshot); // instant optimistic update
applySnapshotDiff(current, snapshot); // persists to Supabase
}
};

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

The three-batch execution order is critical:

  1. Batch 1 – delete and update links first (they reference tasks, so tasks cannot be deleted while links exist).
  2. Batch 2 – delete, create, and update tasks.
  3. Batch 3 – create links last (their referenced tasks must exist).

isUndoRedoRef.current = true during this process prevents each individual mutation's onSuccess from calling invalidateQueries, which would cause a refetch that overwrites the optimistic setQueryData before the server confirms the changes.

Zoom handling

In the base tutorial handleZoom records a snapshot before changing the zoom level. In this demo zoom changes are not persisted (zoom is a purely local UI preference) so no snapshot is needed:

const handleZoom = (level: ZoomLevel) => {
setZoom(level);
};

The toolbar uses shadcn/ui ToggleGroup instead of MUI ButtonGroup:

<ToggleGroup type="single" value={currentZoom} onValueChange={(value) => value && onZoom?.(value as ZoomLevel)}>
<ToggleGroupItem value="day">Day</ToggleGroupItem>
<ToggleGroupItem value="month">Month</ToggleGroupItem>
<ToggleGroupItem value="year">Year</ToggleGroupItem>
</ToggleGroup>

Running the demo

Start the Express backend:

npm run start:server

Start the Vite dev server:

npm run dev

Open http://localhost:3000. To see real-time sync in action, open the same URL in a second browser tab - every change in one tab appears immediately in the other.

Summary

Compared to the base TanStack Query tutorial, this demo adds:

FeatureBase tutorialThis demo
Backend storageLocal JSON fileSupabase (PostgreSQL)
Real-time syncNoneSupabase Realtime subscriptions
Change groupingdata.save (per item)data.batchSave (per gesture)
Undo/redo persistenceClient-side onlyPersisted via applySnapshotDiff

The key architectural pattern is the pending-operations set: local mutations register their expected Realtime echoes before they arrive, so the handler can tell the difference between a remote change (trigger refetch) and the echo of a local change (ignore).

GitHub demo repository

The complete working project is available on GitHub.

What's next

This is the third tutorial in the React Gantt state-management sequence:

  1. Zustand - local in-memory state
  2. TanStack Query - server-backed state with a JSON file backend
  3. TanStack Query + Supabase - real-time multi-user sync (you are here)

From here you can:

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.