跳到主要内容

Angular Gantt + RxJS Tutorial

This tutorial shows a practical Angular pattern for state-driven Gantt management using an injectable RxJS service.

The result:

  • a BehaviorSubject holds tasks, links, and Gantt config,
  • the component composes a view model with combineLatest and binds with AsyncPipe,
  • 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 uses string | number and 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 typed GanttConfig into the Partial<GanttConfigOptions> shape the wrapper expects - the store's domain model and the wrapper's input shape are intentionally not the same type.
  • distinctUntilChanged is applied only to streams of primitives (zoomLevel$, canUndo$, canRedo$). On tasks$ / 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 to past and clears future, so any state-changing action is undoable. setZoom goes through commit for the same reason.
  • Snapshots clone tasks and links shallowly ({ ...task }) and structuredClone the 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.
  • combineLatest waits until every contributing stream has emitted at least once. That is fine here because the BehaviorSubject and the derived distinctUntilChanged streams all have an initial value.
  • templates and dataConfig live 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):

  1. User edits a task in the chart.
  2. Gantt emits multiple low-level changes.
  3. The wrapper batches them and calls data.batchSave(changes).
  4. The shell forwards the call to GanttStateService.applyBatch(changes).
  5. The service runs applyEntityChanges on tasks and links, then commit({ tasks, links }).
  6. commit pushes a HistorySnapshot onto past, clears future, and emits the new state.
  7. tasks$, links$, wrapperConfig$, and the canUndo$ / canRedo$ flags all push through the combineLatest, 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.save instead of batchSave for auto-scheduling-heavy pages. A single drag with auto-scheduling on can produce dozens of low-level changes; data.save would commit a snapshot per change, blowing up history and triggering many re-renders. batchSave collapses one gesture into one commit.
  • Mutating vm.tasks or vm.links directly 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. snapshotState clones 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.instance mutations 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 from tasks$ will overwrite your imperative change.

9. Continue With

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.