Managing Complex UI Logic in React Gantt and Scheduler with XState
State management in a React Gantt app rarely stays simple for long. Once real workflows kick in, updates start triggering other updates, and keeping everything in sync becomes less predictable.
We’ve tried Redux Toolkit, Zustand, and MobX with DHTMLX components, which are solid tools, but all built around the same idea of directly updating state.
XState flips that perspective. Instead of changing state, you describe how it’s allowed to change. Let’s see what that looks like in React Gantt and Scheduler projects.
Understanding XState Through Real React Use Cases
XState usually appears in a project at a very specific moment, not at the start, but when state logic becomes annoying to track.
You start noticing it when simple updates aren’t enough anymore. A task update triggers something else. A UI state depends on multiple async steps. Things stop being local and start affecting each other.
It works fine in plain JavaScript and integrates with React without friction, but it’s rarely introduced early because the mental shift is real.
The core idea is finite state machines. Sounds theoretical, but in practice it’s more restrictive than complex: you define allowed states and explicitly describe how transitions happen between them. Nothing “just changes” anymore. That restriction is the point. The image below clearly illustrates the core ideas behind state machines in XState.
Every change comes from an event, usually something user-driven. Once that event happens, XState runs actions that update context (its internal state container). Instead of spreading logic across components, everything gets centralized in one place.
As applications grow, simple state machines don’t always stay simple. That’s where statecharts come in — nested states, parallel flows, separated concerns inside one system. This becomes useful when the UI isn’t doing one thing at a time anymore.
And when even that isn’t enough, XState introduces actors — separate machines running independently and communicating. It sounds heavy at first, but in larger apps, it often reduces confusion rather than adding to it.
The end result is less about “managing state” and more about defining allowed behavior.
Instead of reacting to whatever state happens to be, you define what can happen. That removes entire classes of bugs where the UI ends up in impossible combinations.
There’s also a broader ecosystem around it, known as the Stately tools, mostly focused on visualizing and inspecting machines. That becomes useful once systems stop fitting in your head.
We won’t go deeper into theory here. The more interesting question is where this actually helps compared to other approaches.
When XState Actually Makes Sense
XState is not something most teams pick by default. Most React apps start with Redux Toolkit, Zustand, or MobX because the flow is familiar: update state, UI reacts, done.
XState doesn’t follow that pattern. You don’t “update state” directly; you describe behavior. That difference takes a bit of adjustment.
In small apps, it can feel unnecessary. There’s no benefit in modeling everything as a machine if the logic is simple. Where it starts to make sense is when things stop being linear. That usually happens in real-world interfaces with overlapping processes: async operations, dependent updates, and multiple UI states influencing each other.
This is exactly what shows up in tools like DHTMLX React Gantt or DHTMLX React Scheduler.
At that point, XState stops being “a state library” and becomes a way to control how the system behaves over time.
We’ll skip architecture discussions and focus on a real integration example instead.
Building a Gantt App with XState: Main Integration Steps
The integration flow itself isn’t radically different from other state tools. What changes is how the state is represented. With XState, you’re no longer wiring a store, you’re defining a machine.
We’ll focus on DHTMLX React Gantt, but the same approach applies to DHTMLX React Scheduler.
If you want to try this setup in your own project, we’ve put together a step-by-step guide for integrating XState with DHTMLX React Gantt and a similar walkthrough for React Scheduler. If you prefer to see the full setup in action, there’s also a complete Gantt demo project on GitHub.
Building the XState Machine Structure
Everything starts with defining what the machine actually holds. Before transitions or events, you define the context shape or what lives inside the system.
import { createMachine, assign } from 'xstate';
import type { Link, GanttConfig, SerializedTask } from '@dhtmlx/trial-react-gantt';
import { seedTasks, seedLinks, defaultZoomLevels, type ZoomLevel } from './seed/Seed';
export interface Snapshot {
tasks: SerializedTask[];
links: Link[];
config: GanttConfig;
}
export interface ContextType {
tasks: SerializedTask[];
links: Link[];
config: GanttConfig;
past: Snapshot[];
future: Snapshot[];
maxHistory: number;
}What matters here isn’t syntax. It’s separation. Context holds the live state (tasks, links, config, history). Snapshot exists for one reason: so undo/redo doesn’t turn into guesswork. Instead of reconstructing previous states, you store them explicitly. That small decision makes history logic much easier to reason about later.
How State Changes Are Defined
Once the structure is in place, things shift from modeling to interaction. Now it’s about what happens when the user actually does something.
With XState, every meaningful interaction becomes an event. In a Gantt app, that’s basically everything (task edits, link updates, zoom changes, undo/redo actions).
So instead of spreading logic across components, you describe events explicitly:
type SetZoomEvent = { type: 'SET_ZOOM'; level: ZoomLevel };
type UndoEvent = { type: 'UNDO' };
type RedoEvent = { type: 'REDO' };
type AddTaskEvent = { type: 'ADD_TASK'; task: SerializedTask };
type UpsertTaskEvent = { type: 'UPSERT_TASK'; task: SerializedTask };
type DeleteTaskEvent = { type: 'DELETE_TASK'; id: string | number };
type AddLinkEvent = { type: 'ADD_LINK'; link: Link };
type UpsertLinkEvent = { type: 'UPSERT_LINK'; link: Link };
type DeleteLinkEvent = { type: 'DELETE_LINK'; id: string | number };
type EventType =
| SetZoomEvent
| UndoEvent
| RedoEvent
| AddTaskEvent
| UpsertTaskEvent
| DeleteTaskEvent
| AddLinkEvent
| UpsertLinkEvent
| DeleteLinkEvent;Nothing fancy here, just a list of possible actions in the system. But it changes the way you think about UI: nothing happens implicitly anymore. From there, each event maps to an action that updates the state.
addTask: assign(({ context: ctx, event }) => ({
tasks: [...ctx.tasks, { ...(event as AddTaskEvent).task, id: `DB_ID:${(event as AddTaskEvent).task.id}` }],
})),
upsertTask: assign(({ context: ctx, event }) => ({
tasks: ctx.tasks.map((task) =>
String(task.id) === String((event as UpsertTaskEvent).task.id)
? { ...task, ...(event as UpsertTaskEvent).task }
: task
),
})),
deleteTask: assign(({ context, event }) => ({
tasks: context.tasks.filter((t) => String(t.id) !== String((event as DeleteTaskEvent).id)),
})),After a few of these, a pattern emerges. It’s repetitive, but intentionally so. The important shift is that the state is no longer updated directly. Everything goes throughassign, which builds a new context from the current state and the event.
That alone changes debugging behavior. You stop searching for hidden mutations and instead follow event flow.
Creating the Machine Configuration
At some point, everything gets wired together — context, events, and actions inside one machine.
Before that, there’s one detail worth calling out snapshots:
const createSnapshot = (ctx: ContextType): Snapshot => ({
tasks: structuredClone(ctx.tasks),
links: structuredClone(ctx.links),
config: structuredClone(ctx.config),
});Not exciting code, but without it, undo/redo becomes unreliable quickly. Now the machine itself:
export const ganttMachine = createMachine(
{
id: 'gantt',
types: {
context: {} as ContextType,
events: {} as EventType,
},
context: {
tasks: seedTasks,
links: seedLinks,
config: { zoom: defaultZoomLevels },
past: [],
future: [],
maxHistory: 50,
},
initial: 'ready',
states: {
ready: {
on: {
SET_ZOOM: { actions: ['pushHistory', 'setZoom'] },
UNDO: { actions: 'undo' },
REDO: { actions: 'redo' },
ADD_TASK: { actions: ['pushHistory', 'addTask'] },
UPSERT_TASK: { actions: ['pushHistory', 'upsertTask'] },
DELETE_TASK: { actions: ['pushHistory', 'deleteTask'] },
ADD_LINK: { actions: ['pushHistory', 'addLink'] },
UPSERT_LINK: { actions: ['pushHistory', 'upsertLink'] },
DELETE_LINK: { actions: ['pushHistory', 'deleteLink'] },
},
},
},
},
)There’s only one real state here, namelyready. That’s intentional. The machine acts more like a controlled dispatcher than a multi-state workflow. Every event and action goes through it. Compared to Redux Toolkit or Zustand, where you call functions directly, this feels more indirect at first. But that’s also what makes it traceable. You don’t mutate anything manually. You send an event, and the machine decides what happens next.
Wiring Gantt UI Actions to the State Machine
This is where UI finally connects to the machine. In DHTMLX React Gantt, everything flows through data.save.
const data: ReactGanttProps['data'] = useMemo(
() => ({
save: (entity, action, item, id) => {
if (entity === 'task') {
const task = item as SerializedTask;
if (action === 'create') {
send({ type: 'ADD_TASK', task });
} else if (action === 'update') {
send({ type: 'UPSERT_TASK', task });
} else if (action === 'delete') {
send({ type: 'DELETE_TASK', id });
}
} else if (entity === 'link') {
const link = item as Link;
if (action === 'create') {
send({ type: 'ADD_LINK', link });
} else if (action === 'update') {
send({ type: 'UPSERT_LINK', link });
} else if (action === 'delete') {
send({ type: 'DELETE_LINK', id });
}
}
},
}),
[send]
);Each user interaction hits this callback first. Instead of updating the state locally, we just send an event into XState. After that, the UI doesn’t decide anything. It just reports what happened.
The Gantt component itself stays intentionally “dumb”; it doesn’t own state, doesn’t mutate anything. It only renders props. That separation is what keeps behavior predictable when the app grows.
The full loop looks like this:
User action → data.save → event sent → machine processes it → context updates → props update → UI re-renders
The same idea applies to DHTMLX React Scheduler.
Conclusion
XState isn’t something you reach for by default. In smaller apps, it often feels like unnecessary structure. A bit heavy, a bit rigid. But once UI logic stops being linear, especially in systems like Gantt charts or schedulers, things change. State stops being just data. It becomes behavior over time. In those cases, modeling transitions explicitly tends to reduce uncertainty as the system grows. Not simpler upfront, but usually more stable later. Still, it’s not a universal choice. If the problem doesn’t need that level of structure, XState is probably more than you want.
