Angular Gantt + RxJS Tutorial
This tutorial shows a practical Angular pattern for state-driven Gantt management using an injectable RxJS service.
The result:
- a
BehaviorSubjectholds tasks, links, and Gantt config, - the component composes a view model with
combineLatestand binds withAsyncPipe, - Gantt edits flow into the store through
data.batchSave, - undo/redo and zoom changes are handled in the same service against snapshots of the state.
A complete working project that follows this tutorial is on GitHub: angular-gantt-rxjs-starter.
Prerequisites
- Angular app with Angular Gantt installed (see Installation)
- Working wrapper render (see Quick Start)
- Basic Angular DI and RxJS knowledge
Project layout
We split the Gantt feature into three folders so each piece has one job:
src/app/
data/
gantt-seed.data.ts initial tasks, links, and zoom config
gantt/
gantt-shell.component.* feature shell and DHTMLX Gantt host
gantt-toolbar.component.ts zoom and history controls
gantt.types.ts shared Gantt feature types
state/
apply-batch-changes.ts pure task/link batch change helper
gantt-state.models.ts RxJS state and history types
gantt-state.service.ts RxJS state, batch flow, zoom, history
GanttStateService is provided by GanttShellComponent (not at root), so each rendered shell gets isolated tasks, links, and undo/redo history.
1. Define types and seed data
Shared types: src/app/gantt/gantt.types.ts
import type { GanttConfigOptions, ZoomLevel } from '@dhtmlx/trial-angular-gantt';
export type ZoomLevelName = 'day' | 'month' | 'year';
export interface GanttConfig {
zoom: {
current: ZoomLevelName;
levels: readonly ZoomLevel[];
};
options?: Partial<GanttConfigOptions>;
}
ZoomLevelName is a narrow string union the toolbar and store exchange. GanttConfig keeps zoom state alongside any additional Gantt options the feature needs.
State models: src/app/state/gantt-state.models.ts
import type { SerializedLink, SerializedTask } from '@dhtmlx/trial-angular-gantt';
import type { GanttConfig } from '../gantt/gantt.types';
export interface HistorySnapshot {
tasks: SerializedTask[];
links: SerializedLink[];
config: GanttConfig;
}
export interface GanttState {
tasks: SerializedTask[];
links: SerializedLink[];
config: GanttConfig;
past: HistorySnapshot[];
future: HistorySnapshot[];
maxHistory: number;
}
Snapshots include config (which carries the current zoom level), so undo restores zoom along with data.
Seed data: src/app/data/gantt-seed.data.ts
import type { SerializedLink, SerializedTask, ZoomLevel } from '@dhtmlx/trial-angular-gantt';
import type { GanttConfig } from '../gantt/gantt.types';
export const defaultZoomLevels: readonly ZoomLevel[] = [
{
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: 36,
scales: [{ unit: 'year', step: 1, format: '%Y' }],
},
];
export const seedTasks: SerializedTask[] = [
{ id: '1', text: 'Project setup', type: 'project', start_date: '2026-04-02T00:00:00.000Z', duration: 8, progress: 0.35, parent: 0, open: true },
{ id: '2', text: 'Install Angular shell', type: 'task', start_date: '2026-04-02T00:00:00.000Z', duration: 2, progress: 1, parent: '1' },
{ id: '3', text: 'Verify Gantt wrapper', type: 'task', start_date: '2026-04-04T00:00:00.000Z', duration: 3, progress: 0.4, parent: '1' },
{ id: '4', text: 'Static render ready', type: 'milestone', start_date: '2026-04-08T00:00:00.000Z', duration: 0, progress: 0, parent: '1' },
];
export const seedLinks: SerializedLink[] = [
{ id: '1', source: '2', target: '3', type: '0' },
{ id: '2', source: '3', target: '4', type: '0' },
];
export const defaultGanttConfig: GanttConfig = {
zoom: {
current: 'day',
levels: defaultZoomLevels,
},
options: {
row_height: 36,
bar_height: 24,
},
};
Use string ids and ISO date strings throughout. The wrapper accepts both, but mixing styles makes diffs and snapshots harder to reason about.
2. Define the batch apply helper
src/app/state/apply-batch-changes.ts is a pure function that applies a list of DataCallbackChange records to an entity array. It is shared by tasks and links via a generic, so the store calls it twice with different types.
import type { DataCallbackChange, TaskId } from '@dhtmlx/trial-angular-gantt';
type EntityWithId = {
id: TaskId;
};
function idsMatch(left: TaskId, right: TaskId): boolean {
return String(left) === String(right);
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isSupportedAction(action: string): action is 'create' | 'update' | 'delete' {
return action === 'create' || action === 'update' || action === 'delete';
}
export function applyEntityChanges<T extends EntityWithId>(
entities: T[],
changes: DataCallbackChange[] = [],
): T[] {
const nextEntities = [...entities];
changes.forEach((change) => {
if (!isSupportedAction(change.action)) {
return;
}
const index = nextEntities.findIndex((entity) => idsMatch(entity.id, change.id));
if (change.action === 'delete') {
if (index !== -1) {
nextEntities.splice(index, 1);
}
return;
}
if (!isObject(change.data)) {
return;
}
if (change.action === 'create') {
nextEntities.push({ ...(change.data as T) });
return;
}
if (index !== -1) {
nextEntities[index] = {
...nextEntities[index],
...(change.data as Partial<T>),
};
}
});
return nextEntities;
}
This is the reducer-like core for grouped Gantt changes. The function:
- Returns a new array (never mutates input).
- Coerces ids with
String()because the wrapper usesstring | numberand either form should match. - Skips unsupported actions and non-object payloads instead of throwing - the Gantt callback runs in the wrapper's render cycle, so a malformed change should not crash the page.
3. Build the GanttStateService
src/app/state/gantt-state.service.ts owns the state, derives observable streams for the component, and applies batches.
import { Injectable } from '@angular/core';
import type {
BatchChanges,
GanttConfigOptions,
SerializedLink,
SerializedTask,
TaskId,
} from '@dhtmlx/trial-angular-gantt';
import { BehaviorSubject, distinctUntilChanged, map } from 'rxjs';
import { defaultGanttConfig, seedLinks, seedTasks } from '../data/gantt-seed.data';
import type { GanttConfig, ZoomLevelName } from '../gantt/gantt.types';
import { applyEntityChanges } from './apply-batch-changes';
import type { GanttState, HistorySnapshot } from './gantt-state.models';
function buildZoomConfig(config: GanttConfig, zoomLevel: ZoomLevelName): GanttConfig {
return {
...config,
zoom: { ...config.zoom, current: zoomLevel },
};
}
function snapshotState(state: GanttState): HistorySnapshot {
return {
tasks: state.tasks.map((task) => ({ ...task })),
links: state.links.map((link) => ({ ...link })),
config: structuredClone(state.config),
};
}
function idsMatch(left: TaskId, right: TaskId): boolean {
return String(left) === String(right);
}
@Injectable()
export class GanttStateService {
private readonly stateSubject = new BehaviorSubject<GanttState>({
tasks: seedTasks,
links: seedLinks,
config: buildZoomConfig(defaultGanttConfig, defaultGanttConfig.zoom.current),
past: [],
future: [],
maxHistory: 50,
});
readonly state$ = this.stateSubject.asObservable();
readonly tasks$ = this.state$.pipe(map((state) => state.tasks));
readonly links$ = this.state$.pipe(map((state) => state.links));
readonly config$ = this.state$.pipe(map((state) => state.config));
private buildWrapperConfig(config: GanttConfig): Partial<GanttConfigOptions> {
const zoomConfig = config.zoom.levels.find((level) => level.name === config.zoom.current);
if (!zoomConfig) {
return config.options ?? {};
}
return {
...config.options,
scale_height: zoomConfig.scale_height,
min_column_width: zoomConfig.min_column_width,
scales: zoomConfig.scales.map((scale) => ({ ...scale })) as typeof zoomConfig.scales,
};
}
readonly wrapperConfig$ = this.config$.pipe(map((config) => this.buildWrapperConfig(config)));
readonly zoomLevel$ = this.config$.pipe(
map((config) => config.zoom.current),
distinctUntilChanged<ZoomLevelName>(),
);
readonly canUndo$ = this.state$.pipe(
map((state) => state.past.length > 0),
distinctUntilChanged<boolean>(),
);
readonly canRedo$ = this.state$.pipe(
map((state) => state.future.length > 0),
distinctUntilChanged<boolean>(),
);
applyBatch(changes: BatchChanges): void {
const hasChanges = Boolean(changes.tasks?.length) || Boolean(changes.links?.length);
if (!hasChanges) return;
const state = this.stateSubject.value;
const tasks = applyEntityChanges<SerializedTask>(state.tasks, changes.tasks);
const links = applyEntityChanges<SerializedLink>(state.links, changes.links);
this.commit({ tasks, links });
}
setZoom(zoomLevel: ZoomLevelName): void {
const state = this.stateSubject.value;
if (state.config.zoom.current === zoomLevel) return;
this.commit({ config: buildZoomConfig(state.config, zoomLevel) });
}
undo(): void {
const state = this.stateSubject.value;
const previous = state.past.at(-1);
if (!previous) return;
this.stateSubject.next({
...state,
tasks: previous.tasks,
links: previous.links,
config: previous.config,
past: state.past.slice(0, -1),
future: [snapshotState(state), ...state.future],
});
}
redo(): void {
const state = this.stateSubject.value;
const next = state.future[0];
if (!next) return;
this.stateSubject.next({
...state,
tasks: next.tasks,
links: next.links,
config: next.config,
past: [...state.past, snapshotState(state)].slice(-state.maxHistory),
future: state.future.slice(1),
});
}
// CRUD helpers (createTask, updateTask, deleteTask, createLink, updateLink, deleteLink)
// are omitted here for brevity. Each goes through `commit()` so it lands in history.
// See the demo for the full implementations.
private commit(change: Partial<Pick<GanttState, 'tasks' | 'links' | 'config'>>): void {
const state = this.stateSubject.value;
this.stateSubject.next({
...state,
...change,
past: [...state.past, snapshotState(state)].slice(-state.maxHistory),
future: [],
});
}
}
Why this shape works:
- The service exposes multiple narrow streams (
tasks$,links$,wrapperConfig$,zoomLevel$,canUndo$,canRedo$) instead of a single bundled view model. The component picks the streams it needs and the framework rebinds only those<dhx-gantt>inputs that actually changed. wrapperConfig$projects the demo's typedGanttConfiginto thePartial<GanttConfigOptions>shape the wrapper expects - the store's domain model and the wrapper's input shape are intentionally not the same type.distinctUntilChangedis applied only to streams of primitives (zoomLevel$,canUndo$,canRedo$). Ontasks$/links$/config$the operator would do nothing - every commit produces a fresh array or object, so the default reference equality test never sees a duplicate.commit()always pushes a snapshot topastand clearsfuture, so any state-changing action is undoable.setZoomgoes throughcommitfor the same reason.- Snapshots clone tasks and links shallowly (
{ ...task }) andstructuredClonethe config - enough to keep undo/redo independent of subsequent edits without a deep-clone tax on every commit.
Since v9.1.3 the wrapper auto-detects ISO date strings, so this demo skips format_date/parse_date overrides on the store. The shell component still installs them as templates (see step 5) for the case where a chart is fed Date instances mixed with strings - the parse_date template normalizes both. See Loading dates in ISO format for the wrapper's full date-handling story.
4. Build the toolbar component
src/app/gantt/gantt-toolbar.component.ts is a presentation-only component: inputs for the current state, outputs for the user's intent. It does not know about the store.
import { Component, EventEmitter, Input, Output } from '@angular/core';
import type { ZoomLevelName } from './gantt.types';
@Component({
selector: 'app-gantt-toolbar',
standalone: true,
template: `
<div className="gantt-toolbar" aria-label="Gantt toolbar">
<div className="gantt-toolbar-group" aria-label="Zoom level">
<button type="button" [class.active]="zoomLevel === 'day'" (click)="zoomSelected.emit('day')">Day</button>
<button type="button" [class.active]="zoomLevel === 'month'" (click)="zoomSelected.emit('month')">Month</button>
<button type="button" [class.active]="zoomLevel === 'year'" (click)="zoomSelected.emit('year')">Year</button>
</div>
<div className="gantt-toolbar-group" aria-label="History controls">
<button type="button" [disabled]="!canUndo" (click)="undoSelected.emit()">Undo</button>
<button type="button" [disabled]="!canRedo" (click)="redoSelected.emit()">Redo</button>
</div>
</div>
`,
})
export class GanttToolbarComponent {
@Input({ required: true }) zoomLevel!: ZoomLevelName;
@Input({ required: true }) canUndo = false;
@Input({ required: true }) canRedo = false;
@Output() readonly zoomSelected = new EventEmitter<ZoomLevelName>();
@Output() readonly undoSelected = new EventEmitter<void>();
@Output() readonly redoSelected = new EventEmitter<void>();
}
Keeping the toolbar dumb means the shell can change how state is sourced (different store, different input names) without touching the toolbar.
5. Compose the shell with combineLatest
src/app/gantt/gantt-shell.component.ts provides the store, builds a single view model from the store's narrow streams, and exposes the templates/dataConfig that the wrapper needs.
import { AsyncPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import {
DhxGanttComponent,
type AngularGanttDataConfig,
type AngularGanttTemplates,
} from '@dhtmlx/trial-angular-gantt';
import { combineLatest } from 'rxjs';
import type { ZoomLevelName } from './gantt.types';
import { GanttStateService } from '../state/gantt-state.service';
import { GanttToolbarComponent } from './gantt-toolbar.component';
@Component({
selector: 'app-gantt-shell',
standalone: true,
imports: [AsyncPipe, DhxGanttComponent, GanttToolbarComponent],
providers: [GanttStateService],
templateUrl: './gantt-shell.component.html',
})
export class GanttShellComponent {
private readonly ganttState = inject(GanttStateService);
protected readonly vm$ = combineLatest({
tasks: this.ganttState.tasks$,
links: this.ganttState.links$,
config: this.ganttState.wrapperConfig$,
zoomLevel: this.ganttState.zoomLevel$,
canUndo: this.ganttState.canUndo$,
canRedo: this.ganttState.canRedo$,
});
protected readonly templates: AngularGanttTemplates = {
parse_date: (value: string | Date) => this.parseDate(value),
format_date: (date: string | Date) => this.formatDate(date),
};
protected readonly dataConfig: AngularGanttDataConfig = {
batchSave: (changes) => {
this.ganttState.applyBatch(changes);
},
};
protected setZoom(zoomLevel: ZoomLevelName): void {
this.ganttState.setZoom(zoomLevel);
}
protected undo(): void {
this.ganttState.undo();
}
protected redo(): void {
this.ganttState.redo();
}
private parseDate(value: string | Date): Date {
return value instanceof Date ? value : new Date(value);
}
private formatDate(value: string | Date): string {
return this.parseDate(value).toISOString();
}
}
The template uses Angular 17+ control flow:
@if (vm$ | async; as vm) {
<section class="gantt-feature-shell" aria-label="Gantt feature area">
<app-gantt-toolbar
[zoomLevel]="vm.zoomLevel"
[canUndo]="vm.canUndo"
[canRedo]="vm.canRedo"
(zoomSelected)="setZoom($event)"
(undoSelected)="undo()"
(redoSelected)="redo()">
</app-gantt-toolbar>
<div class="gantt-host">
<dhx-gantt
[tasks]="vm.tasks"
[links]="vm.links"
[config]="vm.config"
[templates]="templates"
[data]="dataConfig">
</dhx-gantt>
</div>
</section>
}
A few details worth highlighting:
providers: [GanttStateService]on the shell, not on the root - every shell instance gets its own store. Two side-by-side Gantts on the same page won't share undo history.combineLatestwaits until every contributing stream has emitted at least once. That is fine here because theBehaviorSubjectand the deriveddistinctUntilChangedstreams all have an initial value.templatesanddataConfiglive on the component, not on the service. The service stays unaware of how the wrapper formats dates or where its callbacks come from - only the shell knows the wrapper's API surface.
6. Wire it into the app
src/app/app.ts mounts the shell as the root view (or as a route target):
import { Component } from '@angular/core';
import { GanttShellComponent } from './gantt/gantt-shell.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [GanttShellComponent],
template: `<app-gantt-shell></app-gantt-shell>`,
})
export class AppComponent {}
7. Data flow and rationale
For a typical edit (for example, dragging a task):
- User edits a task in the chart.
- Gantt emits multiple low-level changes.
- The wrapper batches them and calls
data.batchSave(changes). - The shell forwards the call to
GanttStateService.applyBatch(changes). - The service runs
applyEntityChangeson tasks and links, thencommit({ tasks, links }). commitpushes aHistorySnapshotontopast, clearsfuture, and emits the new state.tasks$,links$,wrapperConfig$, and thecanUndo$/canRedo$flags all push through thecombineLatest, the view model rebuilds, and Angular rebinds the changed<dhx-gantt>inputs.
This keeps Angular state as the source of truth and still handles high-volume chart actions efficiently - Gantt's batchSave collapses one user gesture into one store update.
8. Common Pitfalls
- Using
data.saveinstead ofbatchSavefor auto-scheduling-heavy pages. A single drag with auto-scheduling on can produce dozens of low-level changes;data.savewould commit a snapshot per change, blowing up history and triggering many re-renders.batchSavecollapses one gesture into one commit. - Mutating
vm.tasksorvm.linksdirectly in the component. The view model's arrays are the same references the store holds. Mutating them in place corrupts both current state and any snapshots that share those references. - Reusing snapshot arrays without cloning when implementing undo/redo.
snapshotStateclones each task and link with a spread so future commits cannot retroactively change history. Skipping that step looks fine until your second undo. - Mixing imperative
gantt.instancemutations with store-driven inputs. If you reach into the underlying Gantt instance to add a task, the store does not see it - the next emission fromtasks$will overwrite your imperative change.