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

Angular Scheduler + RxJS Tutorial

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

The result:

  • a BehaviorSubject holds events, active date, active view, and history,
  • the shell component exposes a view model with AsyncPipe,
  • Scheduler edits flow into the store through data.batchSave,
  • undo/redo and toolbar navigation are handled in the same service.

A complete working project that follows this tutorial is on GitHub: angular-scheduler-rxjs-starter.

Prerequisites

  • Angular app with Angular Scheduler installed (see Installation).
  • Working wrapper render (see Quick Start).
  • Basic Angular dependency injection and RxJS knowledge.

This tutorial uses the evaluation package @dhtmlx/trial-angular-scheduler. If you already use the Professional package, replace it with @dhx/angular-scheduler in the imports.

Project Layout

The starter splits the feature into a small set of files:

src/app/
scheduler-demo.component.* <-- feature shell and DHTMLX Scheduler host
scheduler-demo.store.ts <-- RxJS state, batch flow, history
scheduler-demo.data.ts <-- initial events
components/
scheduler/
scheduler-view.component.ts
scheduler-view.component.html
toolbar/
toolbar.component.ts
toolbar.component.html

SchedulerStore is provided by SchedulerDemoComponent, so each rendered demo shell gets isolated events and undo/redo history.

1. Define Seed Data

Create src/app/scheduler-demo.data.ts:

src/app/scheduler-demo.data.ts
import type { Event } from "@dhtmlx/trial-angular-scheduler";

export const mainDate = new Date("2026-08-15T00:00:00Z");

export const schedulerEvents: Event[] = [
{ id: 1, start_date: new Date("2026-08-10T02:00:00Z"), end_date: new Date("2026-08-10T10:20:00Z"), text: "Product Strategy Hike" },
{ id: 2, start_date: new Date("2026-08-10T12:00:00Z"), end_date: new Date("2026-08-10T16:00:00Z"), text: "Agile Meditation and Release" },
{ id: 3, start_date: new Date("2026-08-11T06:00:00Z"), end_date: new Date("2026-08-11T11:00:00Z"), text: "Tranquil Tea Time" },
{ id: 4, start_date: new Date("2026-08-11T11:30:00Z"), end_date: new Date("2026-08-11T19:00:00Z"), text: "Sprint Review and Retreat" },
{ id: 5, start_date: new Date("2026-08-12T01:00:00Z"), end_date: new Date("2026-08-12T03:00:00Z"), text: "Kayaking Workshop" },
{ id: 6, start_date: new Date("2026-08-12T06:00:00Z"), end_date: new Date("2026-08-12T08:00:00Z"), text: "Stakeholder Sunset Yoga Session" },
{ id: 7, start_date: new Date("2026-08-12T07:00:00Z"), end_date: new Date("2026-08-12T12:00:00Z"), text: "Roadmap Alignment Walk" },
{ id: 8, start_date: new Date("2026-08-12T13:00:00Z"), end_date: new Date("2026-08-12T18:00:00Z"), text: "Mindful Team Building" },
{ id: 9, start_date: new Date("2026-08-13T01:00:00Z"), end_date: new Date("2026-08-13T18:00:00Z"), text: "Cross-Functional Expedition" },
{ id: 10, start_date: new Date("2026-08-13T14:00:00Z"), end_date: new Date("2026-08-13T20:00:00Z"), text: "User Feedback Picnic" },
{ id: 11, start_date: new Date("2026-08-14T03:00:00Z"), end_date: new Date("2026-08-14T08:00:00Z"), text: "Demo and Showcase" },
{ id: 12, start_date: new Date("2026-08-14T11:00:00Z"), end_date: new Date("2026-08-14T17:00:00Z"), text: "Quality Assurance Spa Day" },
{ id: 13, start_date: new Date("2026-08-15T01:00:00Z"), end_date: new Date("2026-08-15T03:00:00Z"), text: "Motion Cycling Adventure" },
{ id: 14, start_date: new Date("2026-08-15T10:00:00Z"), end_date: new Date("2026-08-15T16:00:00Z"), text: "Competitor Analysis Beach Day" },
{ id: 15, start_date: new Date("2026-08-16T02:00:00Z"), end_date: new Date("2026-08-16T06:00:00Z"), text: "Creativity Painting Retreat" },
];

2. Build The Store

Create src/app/scheduler-demo.store.ts:

src/app/scheduler-demo.store.ts
import { Injectable } from "@angular/core";
import type { AngularSchedulerDataConfig, BatchChanges, Event } from "@dhtmlx/trial-angular-scheduler";
import { BehaviorSubject, map } from "rxjs";
import { mainDate, schedulerEvents } from "./scheduler-demo.data";

export type SchedulerView = "day" | "week" | "month";

interface Snapshot {
events: Event[];
date: Date;
view: SchedulerView;
}

interface SchedulerStoreState {
events: Event[];
date: Date;
view: SchedulerView;
past: Snapshot[];
future: Snapshot[];
}

export interface SchedulerViewModel {
events: Event[];
date: Date;
view: SchedulerView;
formattedDate: string;
canUndo: boolean;
canRedo: boolean;
}

const cloneDate = (value: unknown): Date | undefined => {
if (value instanceof Date) return new Date(value.getTime());
if (typeof value === "string") return new Date(value);
return undefined;
};

const cloneEvent = (event: Event): Event => ({
...event,
start_date: cloneDate(event.start_date),
end_date: cloneDate(event.end_date),
});

@Injectable()
export class SchedulerStore {
private readonly maxHistory = 50;
private readonly stateSubject = new BehaviorSubject<SchedulerStoreState>(this.createInitialState());
private readonly state$ = this.stateSubject.asObservable();

readonly vm$ = this.state$.pipe(map(state => this.createViewModel(state)));

readonly dataConfig: AngularSchedulerDataConfig = {
batchSave: changes => this.applyBatch(changes),
};

setView(view: SchedulerView): void {
const state = this.stateSubject.value;
if (state.view === view) return;

const withHistory = this.pushHistory(state);
this.stateSubject.next({ ...withHistory, view });
}

setDate(date: Date): void {
const state = this.stateSubject.value;
if (state.date.getTime() === date.getTime()) return;

const withHistory = this.pushHistory(state);
this.stateSubject.next({ ...withHistory, date: new Date(date) });
}

addDate(step: number): void {
const state = this.stateSubject.value;
const next = new Date(state.date);

if (state.view === "day") next.setDate(next.getDate() + step);
else if (state.view === "week") next.setDate(next.getDate() + step * 7);
else next.setMonth(next.getMonth() + step);

this.setDate(next);
}

undo(): void {
const state = this.stateSubject.value;
if (state.past.length === 0) return;

const previous = state.past[state.past.length - 1];
const current = this.createSnapshot(state);
const restored = this.restoreSnapshot(previous);

this.stateSubject.next({
...state,
...restored,
past: state.past.slice(0, -1),
future: [current, ...state.future],
});
}

redo(): void {
const state = this.stateSubject.value;
if (state.future.length === 0) return;

const next = state.future[0];
const current = this.createSnapshot(state);
const restored = this.restoreSnapshot(next);

this.stateSubject.next({
...state,
...restored,
past: this.trimPast([...state.past, current]),
future: state.future.slice(1),
});
}

applyBatch(changes: BatchChanges): void {
const updates = changes.events ?? [];
if (updates.length === 0) return;

const state = this.stateSubject.value;
const withHistory = this.pushHistory(state);
let events = withHistory.events;

updates.forEach(change => {
if (change.action === "delete") {
events = events.filter(event => String(event.id) !== String(change.id));
return;
}

const nextEvent = cloneEvent(change.data as Event);
events = events.some(event => String(event.id) === String(change.id))
? events.map(event => (String(event.id) === String(change.id) ? nextEvent : event))
: [...events, nextEvent];
});

this.stateSubject.next({ ...withHistory, events });
}

private createInitialState(): SchedulerStoreState {
return {
events: schedulerEvents.map(cloneEvent),
date: new Date(mainDate),
view: "week",
past: [],
future: [],
};
}

private createViewModel(state: SchedulerStoreState): SchedulerViewModel {
return {
events: state.events.map(cloneEvent),
date: new Date(state.date),
view: state.view,
formattedDate: state.date.toLocaleDateString(undefined, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
}),
canUndo: state.past.length > 0,
canRedo: state.future.length > 0,
};
}

private pushHistory(state: SchedulerStoreState): SchedulerStoreState {
return {
...state,
past: this.trimPast([...state.past, this.createSnapshot(state)]),
future: [],
};
}

private createSnapshot(state: SchedulerStoreState): Snapshot {
return {
events: state.events.map(cloneEvent),
date: new Date(state.date),
view: state.view,
};
}

private restoreSnapshot(snapshot: Snapshot): Pick<SchedulerStoreState, "events" | "date" | "view"> {
return {
events: snapshot.events.map(cloneEvent),
date: new Date(snapshot.date),
view: snapshot.view,
};
}

private trimPast(past: Snapshot[]): Snapshot[] {
return past.length <= this.maxHistory ? past : past.slice(past.length - this.maxHistory);
}
}

Why this shape works:

  • The store clones event dates when exposing state, so Scheduler edits cannot mutate history snapshots by reference.
  • dataConfig.batchSave is stable and delegates all grouped changes to the store.
  • pushHistory() makes event edits, date changes, and view changes undoable.
  • ID comparisons use String() because Scheduler event IDs can be strings or numbers.

3. Create The Scheduler View Component

Create src/app/components/scheduler/scheduler-view.component.ts:

src/app/components/scheduler/scheduler-view.component.ts
import { Component, Input } from "@angular/core";
import {
DhxSchedulerComponent,
type AngularSchedulerDataConfig,
type Event,
type SchedulerXY,
} from "@dhtmlx/trial-angular-scheduler";
import type { SchedulerView } from "../../scheduler-demo.store";

@Component({
selector: "app-scheduler-view",
standalone: true,
imports: [DhxSchedulerComponent],
templateUrl: "./scheduler-view.component.html",
})
export class SchedulerViewComponent {
@Input() events: Event[] = [];
@Input() date = new Date();
@Input() view: SchedulerView = "week";
@Input() data: AngularSchedulerDataConfig | null = null;
@Input() xy: SchedulerXY = {};
}

Create src/app/components/scheduler/scheduler-view.component.html:

src/app/components/scheduler/scheduler-view.component.html
<dhx-scheduler
[events]="events"
[date]="date"
[view]="view"
[data]="data"
[xy]="xy">
</dhx-scheduler>

The view component is deliberately thin. It knows only how to pass inputs to the wrapper.

4. Build a custom toolbar component

src/app/components/toolbar/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.

src/app/components/toolbar/toolbar.component.ts
import { Component, EventEmitter, Input, Output } from '@angular/core';
import type { SchedulerView } from '../../scheduler-demo.store';

@Component({
selector: 'app-toolbar',
standalone: true,
templateUrl: './toolbar.component.html',
styleUrl: './toolbar.component.css',
})
export class ToolbarComponent {
@Input() canUndo = false;
@Input() canRedo = false;
@Input() view: SchedulerView = 'week';
@Input() formattedDate = '';

@Output() undoClick = new EventEmitter<void>();
@Output() redoClick = new EventEmitter<void>();
@Output() viewChange = new EventEmitter<SchedulerView>();
@Output() dateStep = new EventEmitter<number>();
@Output() dateChange = new EventEmitter<Date>();

today(): void {
this.dateChange.emit(new Date());
}
}
src/app/components/toolbar/toolbar.component.html
<div class="demo-toolbar timeline-toolbar">
<button type="button" [disabled]="!canUndo" (click)="undoClick.emit()">
Undo
</button>
<button type="button" [disabled]="!canRedo" (click)="redoClick.emit()">
Redo
</button>
<button
type="button"
[class.active]="view === 'day'"
(click)="viewChange.emit('day')"
>
Day
</button>
<button
type="button"
[class.active]="view === 'week'"
(click)="viewChange.emit('week')"
>
Week
</button>
<button
type="button"
[class.active]="view === 'month'"
(click)="viewChange.emit('month')"
>
Month
</button>
<span class="toolbar-spacer"></span>
<span class="toolbar-date">{{ formattedDate }}</span>
<span class="toolbar-spacer"></span>
<button type="button" (click)="dateStep.emit(-1)">&lt;</button>
<button type="button" (click)="today()">Today</button>
<button type="button" (click)="dateStep.emit(1)">&gt;</button>
</div>

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

Create src/app/scheduler-demo.component.ts:

src/app/scheduler-demo.component.ts
import { AsyncPipe } from "@angular/common";
import { Component, inject } from "@angular/core";
import { type SchedulerXY } from "@dhtmlx/trial-angular-scheduler";
import { SchedulerViewComponent } from "./components/scheduler/scheduler-view.component";
import { ToolbarComponent } from "./components/toolbar/toolbar.component";
import { SchedulerStore, type SchedulerView } from "./scheduler-demo.store";

@Component({
selector: "app-scheduler-demo",
standalone: true,
imports: [AsyncPipe, ToolbarComponent, SchedulerViewComponent],
providers: [SchedulerStore],
templateUrl: "./scheduler-demo.component.html",
styleUrl: "./scheduler-demo.component.css"
})

export class SchedulerDemoComponent {
private readonly store = inject(SchedulerStore);

readonly vm$ = this.store.vm$;
readonly dataConfig = this.store.dataConfig;

xy: SchedulerXY = { nav_height: 0 };

setView(view: SchedulerView): void {
this.store.setView(view);
}

setDate(date: Date): void {
this.store.setDate(date);
}

addDate(step: number): void {
this.store.addDate(step);
}

undo(): void {
this.store.undo();
}

redo(): void {
this.store.redo();
}
}

Create src/app/scheduler-demo.component.html:

src/app/scheduler-demo.component.html
@if (vm$ | async; as vm) {
<section class="demo-panel" data-cy="state-management-demo" data-sample="state-management">
<app-toolbar
[canUndo]="vm.canUndo"
[canRedo]="vm.canRedo"
[view]="vm.view"
[formattedDate]="vm.formattedDate"
(undoClick)="undo()"
(redoClick)="redo()"
(viewChange)="setView($event)"
(dateChange)="setDate($event)"
(dateStep)="addDate($event)"
></app-toolbar>

<app-scheduler-view
[events]="vm.events"
[date]="vm.date"
[view]="vm.view"
[data]="dataConfig"
[xy]="xy"
></app-scheduler-view>
</section>
}

The toolbar markup can be split into its own component, as in the starter project. Keep it presentation-only: inputs for current state, outputs for user intent.

6. Wire It Into The App

Mount the shell as the root view or as a route target:

src/app/app.ts
import { Component } from '@angular/core';
import { SchedulerDemoComponent } from './scheduler-demo.component';

@Component({
selector: 'app-root',
imports: [SchedulerDemoComponent],
standalone: true,
template: `<app-scheduler-demo></app-scheduler-demo>`,
})

export class App {}

7. Data Flow And Rationale

For a typical edit, such as dragging an event:

  1. User edits an event in Scheduler.
  2. Scheduler emits low-level changes.
  3. The wrapper batches them and calls data.batchSave(changes).
  4. The shell forwards the stable callback to SchedulerStore.applyBatch(changes).
  5. The store applies all event changes in one transaction.
  6. The store pushes a snapshot to past, clears future, and emits the new state.
  7. vm$ emits a fresh view model, and Angular rebinds the changed <dhx-scheduler> inputs.

This keeps Angular state as the source of truth and still handles high-volume Scheduler actions efficiently.

Common Pitfalls

  • Using data.save when one user action can create many low-level changes. data.batchSave collapses grouped edits into one store update.
  • Mutating vm.events directly in a component. Mutating view-model arrays in place can corrupt current state and history.
  • Reusing snapshot arrays without cloning. Undo/redo should restore independent copies.
  • Mixing imperative instance mutations with controlled events. If you add or delete events through the instance, update the store too.

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.