Angular Scheduler + RxJS Tutorial
This tutorial shows a practical Angular pattern for state-driven Scheduler management using an injectable RxJS service.
The result:
- a
BehaviorSubjectholds 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:
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:
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.batchSaveis 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:
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:
<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.
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());
}
}
<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)"><</button>
<button type="button" (click)="today()">Today</button>
<button type="button" (click)="dateStep.emit(1)">></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:
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:
@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:
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:
- User edits an event in Scheduler.
- Scheduler emits low-level changes.
- The wrapper batches them and calls
data.batchSave(changes). - The shell forwards the stable callback to
SchedulerStore.applyBatch(changes). - The store applies all event changes in one transaction.
- The store pushes a snapshot to
past, clearsfuture, and emits the new state. 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.savewhen one user action can create many low-level changes.data.batchSavecollapses grouped edits into one store update. - Mutating
vm.eventsdirectly 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
instancemutations with controlledevents. If you add or delete events through the instance, update the store too.