리액트 스케줄러 - XState 튜토리얼
이 튜토리얼은 DHTMLX React Scheduler를 XState 상태 머신에 연결하는 방법을 보여줍니다. 이벤트와 UI 상태(view/date/config)를 머신에 유지하고, Scheduler 편집을 data.save를 통해 라우팅하며, 스냅샷 기반 히스토리로 undo/redo를 추가합니다.
전체 소스 코드는 GitHub에서 확인 가능.
전제 조건
- Node.js (권장 버전 LTS)
- 리액트 + 타입스크립트 기본 이해
- XState 기초 지식(머신, 이벤트, 액션). 복습이 필요하다면 XState 문서를 참조하세요: https://stately.ai/docs/xstate
빠른 설정 - 프로젝트 생성
Vite + 리액트 + TS 프로젝트를 생성합니다:
npm create vite@latest scheduler-xstate-demo -- --template react-ts
cd scheduler-xstate-demo
npm install
XState + React 바인딩을 설치합니다:
npm install xstate @xstate/react
Material UI 설치(데모 도구 모음에 사용):
npm install @mui/material @mui/icons-material @emotion/react @emotion/styled
React Scheduler 설치
React Scheduler를 React Scheduler 설치 가이드에 따라 설치합니다.
이 튜토리얼에서는 평가용 패키지를 사용합니다:
npm install @dhtmlx/trial-react-scheduler
또는
yarn add @dhtmlx/trial-react-scheduler
Professional 패키지를 이미 사용 중인 경우, 명령과 임포트에서 @dhtmlx/trial-react-scheduler를 @dhx/react-scheduler로 교체합니다.
개발 서버를 실행합니다:
npm run dev
Scheduler를 전체 페이지에 차지하도록 하려면 src/App.css의 기본 스타일을 제거하고 다음을 추가합니다:
#root {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
}
body {
margin: 0;
}
샘플 데이터 설정
몇 개의 이벤트와 초기 UI 상태를 가진 src/seed/data.ts를 생성합니다. 주의: date는 숫자(타임스탬프)로 저장되어 머신 컨텍스트가 직렬화 가능하게 유지됩니다.
export type SchedulerView = "day" | "week" | "month";
export type SchedulerConfig = Record<string, unknown>;
export interface SchedulerEvent {
id: string | number;
start_date: string;
end_date: string;
text: string;
[key: string]: unknown;
}
export const seedEvents: SchedulerEvent[] = [
{ id: 1, classname: "blue", start_date: "2025-08-11T02:00:00Z", end_date: "2025-08-11T10:20:00Z", text: "Product Strategy Hike" },
{ id: 2, classname: "violet", start_date: "2025-08-12T06:00:00Z", end_date: "2025-08-12T11:00:00Z", text: "Tranquil Tea Time" },
{ id: 3, classname: "blue", start_date: "2025-08-15T03:00:00Z", end_date: "2025-08-15T08:00:00Z", text: "Demo and Showcase" },
];
export const seedDate = Date.parse("2025-08-15T00:00:00Z");
export const seedView: SchedulerView = "week";
SchedulerEvent는 런타임에 추가 필드를 달 수 있도록 인덱스 시그니처를 사용합니다.
동반 데모에는 시각적 효과를 풍부하게 하는 색상 클래스가 추가로 포함되어 있습니다.
XState 머신 설정
src/machine.ts를 생성합니다. 이 머신은 다음을 저장합니다:
events(스케줄러 데이터)date(타임스탬프로 저장)view(day | week | month)config(스케줄러 구성 객체)past/future(undo/redo용 스냅샷 배열)
Undo/redo는 스냅샷을 사용하여 머신에 직접 통합됩니다. 데이터를 수정하기 전마다 saveToHistory가 현재의 이벤트, 뷰, 날짜의 스냅샷을 저장합니다. undo와 redo 트랜지션은 히스토리에서 스냅샷과 현재 상태를 교환합니다.
import { createMachine, assign } from "xstate";
import {
seedEvents,
seedView,
seedDate,
type SchedulerView,
type SchedulerEvent,
type SchedulerConfig,
} from "./seed/data";
export interface SchedulerMachineContext {
events: SchedulerEvent[];
view: SchedulerView;
date: number;
config: SchedulerConfig;
past: SchedulerSnapshot[];
future: SchedulerSnapshot[];
maxHistory: number;
}
interface SchedulerSnapshot {
events: SchedulerEvent[];
view: SchedulerView;
date: number;
}
type SchedulerMachineEvent =
| { type: "SET_VIEW"; view: SchedulerView }
| { type: "SET_DATE"; date: number }
| { type: "CREATE_EVENT"; event: SchedulerEvent }
| { type: "UPDATE_EVENT"; event: SchedulerEvent }
| { type: "DELETE_EVENT"; id: string | number }
| { type: "UNDO" }
| { type: "REDO" };
const deepClone = <T,>(value: T): T => {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
};
const takeSnapshot = (ctx: SchedulerMachineContext): SchedulerSnapshot => ({
events: deepClone(ctx.events),
view: ctx.view,
date: ctx.date,
});
export const schedulerMachine = createMachine({
id: "scheduler",
types: {
context: {} as SchedulerMachineContext,
events: {} as SchedulerMachineEvent,
},
context: {
events: seedEvents,
view: seedView,
date: seedDate,
config: {},
past: [],
future: [],
maxHistory: 50,
},
initial: "ready",
states: {
ready: {
on: {
SET_VIEW: { actions: ['saveToHistory', 'setView'] },
SET_DATE: { actions: ['saveToHistory', 'setDate'] },
CREATE_EVENT: { actions: ['saveToHistory', 'createEvent'] },
UPDATE_EVENT: { actions: ['saveToHistory', 'updateEvent'] },
DELETE_EVENT: { actions: ['saveToHistory', 'deleteEvent'] },
UNDO: {
guard: ({ context }) => context.past.length > 0,
actions: ['undo']
},
REDO: {
guard: ({ context }) => context.future.length > 0,
actions: ['redo']
},
}
}
},
},
{
actions: {
saveToHistory: assign({
past: ({ context }) => {
const newPast = [...context.past, takeSnapshot(context)];
if (newPast.length > context.maxHistory) {
newPast.shift();
}
return newPast;
},
future: () => [],
}),
setView: assign({
view: ({ event }) => (event as { type: "SET_VIEW"; view: SchedulerView }).view
}),
setDate: assign({
date: ({ event }) => (event as { type: "SET_DATE"; date: number }).date
}),
createEvent: assign({
events: ({ context, event }) => {
const newId = `id_${Date.now()}`;
const newEvent = { ...(event as { type: "CREATE_EVENT"; event: SchedulerEvent }).event, id: newId };
return [...context.events, newEvent];
}
}),
updateEvent: assign({
events: ({ context, event }) =>
context.events.map(ev =>
String(ev.id) === String((event as { type: "UPDATE_EVENT"; event: SchedulerEvent }).event.id)
? { ...ev, ...(event as { type: "UPDATE_EVENT"; event: SchedulerEvent }).event }
: ev
)
}),
deleteEvent: assign({
events: ({ context, event }) =>
context.events.filter(ev => String(ev.id) !== String((event as { type: "DELETE_EVENT"; id: string | number }).id))
}),
undo: assign(({ context }) => {
const currentState = takeSnapshot(context);
const previousState = context.past[context.past.length - 1];
const newPast = context.past.slice(0, -1);
const newFuture = [currentState, ...context.future];
return {
...previousState,
past: newPast,
future: newFuture,
};
}),
redo: assign(({ context }) => {
const currentState = takeSnapshot(context);
const nextState = context.future[0];
const newFuture = context.future.slice(1);
const newPast = [...context.past, currentState];
return {
...nextState,
past: newPast,
future: newFuture,
};
})
}
}
);
이 시점에서 이 머신은 다음을 제공합니다:
- Scheduler 속성의 단일 소스
- 스냅샷 기반 Undo/Redo(히스토리는 참조가 아닌 복사본을 포함)
- 단일
SET_DATE이벤트 — 전/다음/오늘 탐색 로직은 컴포넌트에 있습니다
현대 브라우저만 대상으로 한다면, deepClone() 헬퍼는 이미 structuredClone()을 우선적으로 사용하고, 더 오래된 환경에서는 JSON 복제를 대체로 사용합니다.