React Scheduler - Valtio 튜토리얼
이 튜토리얼은 Vite + React + TypeScript 앱에서 React Scheduler를 렌더링하고 Valtio로 상태를 관리하는 방법을 보여줍니다. 이벤트, 현재 날짜, 활성 보기를 Valtio 프록시 저장소에 보관한 다음, 사용자의 편집을 Scheduler의 data.save 콜백으로 라우팅합니다.
끝까지 따라오면 다음을 갖춘 Scheduler를 얻게 됩니다:
- 재사용 가능한 도구 모음(뷰 전환기, 날짜 탐색, 되돌리기/다 시 실행, 읽기 전용 토글)
- 저장소 기반 이벤트 CRUD(생성/수정/삭제)
- 스냅샷 기반 undo/redo(이벤트 + 구성)
전체 소스 코드는 GitHub에서 확인 가능.
Prerequisites
- React + TypeScript 기초
- Vite 기초
- Valtio에 대한 기본 이해
- 권장: React Scheduler 문서: 데이터 바인딩에서
data.save및 데이터 바인딩에 대해 읽어보기
Creating a project
Vite + React + TypeScript 프로젝트를 생성합니다:
npm create vite@latest scheduler-valtio-demo -- --template react-ts
cd scheduler-valtio-demo
Installing dependencies
이 튜토리얼은 다음을 사용합니다:
- Valtio를 상태 관리에 사용
- Material UI를 도구 모음 UI에 사용
패키지를 설치합니다:
npm install valtio @mui/material @mui/icons-material @emotion/react @emotion/styled
Yarn을 사용하는 경우:
yarn add valtio @mui/material @mui/icons-material @emotion/react @emotion/styled
Installing 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로 바꿔서 사용합니다.
Preparing app styles
React Scheduler는 결정된 높이를 가진 상위 컨테이너를 필요로 합니다. 기본 스타일을 src/App.css에서 다음으로 교체합니다:
#root {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
}
Adding seed data
작은 데이터 세트와 기본 뷰/날짜를 설정하고 src/seed/data.ts를 생성합니다:
export const seedEvents = [
{
id: 1,
start_date: "2025-08-11T02:00:00Z",
end_date: "2025-08-11T10:20:00Z",
text: "Product Strategy Hike",
},
{
id: 2,
start_date: "2025-08-12T06:00:00Z",
end_date: "2025-08-12T11:00:00Z",
text: "Tranquil Tea Time",
},
{
id: 3,
start_date: "2025-08-15T03:00:00Z",
end_date: "2025-08-15T08:00:00Z",
text: "Demo and Showcase",
},
{
id: 4,
start_date: "2025-08-12T11:30:00Z",
end_date: "2025-08-12T19:00:00Z",
text: "Sprint Review and Retreat",
},
];
export const seedDate = Date.parse("2025-08-15T00:00:00Z");
export type SchedulerView = "day" | "week" | "month";
export const seedView: SchedulerView = "week";
보조 데모에는 더 풍성한 시각 효과를 위한 추가 이벤트가 포함되어 있습니다.
Creating a Valtio store
src/store.ts를 생성합니다. 이 저장소는 다음을 소유합니다:
events(Scheduler에 전달되는 이벤트 배열)currentDate및view(또한 props로 전달)config(Scheduler 구성 객 체,readonly포함)_past/_future스택은 Undo/Redo 이력 관리
주요 포인트는 스냅샷 기반 이력입니다. 깊은 복제된 스냅샷을 저장하여 Undo/Redo가 mutable 배열에 대한 참조를 유지하지 않도록 합니다. 탐색(setCurrentDate/setView)은 의도적으로 Undoable하지 않으며 — 데이터를 변경하는 동작(CRUD, 구성 변경)만 이력 스택에 푸시됩니다.
import { proxy, snapshot } from "valtio";
import { seedEvents, seedView, seedDate, type SchedulerView } from "./seed/data";
export type SchedulerEvent = {
id: string | number;
start_date: string;
end_date: string;
text: string;
[key: string]: unknown;
};
export type SchedulerConfig = Record<string, unknown>;
type HistorySnapshot = {
events: SchedulerEvent[];
config: SchedulerConfig;
};
const deepClone = <T,>(value: T): T => {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
};
const normalizeId = (value: unknown) => String(value);
export const createSchedulerStore = () => {
const state = proxy({
events: seedEvents as SchedulerEvent[],
currentDate: seedDate as number,
view: seedView as SchedulerView,
config: {} as SchedulerConfig,
// Undo/redo history stacks and capacity
_past: [] as HistorySnapshot[],
_future: [] as HistorySnapshot[],
_cap: 50,
});
const getHistorySnapshot = (): HistorySnapshot => {
const snap = snapshot(state);
return {
events: deepClone(snap.events as SchedulerEvent[]),
config: deepClone(snap.config as SchedulerConfig),
};
};
const recordHistory = () => {
const snapshotItem = getHistorySnapshot();
state._past = [...state._past.slice(-state._cap + 1), snapshotItem];
state._future = [];
};
const actions = {
updateEvent: (payload: Partial<SchedulerEvent> & { id?: string | number }) => {
const payloadId = payload.id;
if (payloadId === undefined || payloadId === null) {
return;
}
recordHistory();
const normalizedPayloadId = normalizeId(payloadId);
state.events = state.events.map((eventItem) => {
if (normalizeId(eventItem.id) !== normalizedPayloadId) {
return eventItem;
}
return { ...eventItem, ...payload, id: eventItem.id };
});
},
createEvent: (payload: Partial<SchedulerEvent>) => {
recordHistory();
const newEventId = `id_${Date.now().toString()}`;
const newEvent = { ...payload, id: newEventId } as SchedulerEvent;
state.events = [...state.events, newEvent];
return newEvent;
},
deleteEvent: (id: string | number) => {
recordHistory();
const normalizedId = normalizeId(id);
state.events = state.events.filter((eventItem) => {
return normalizeId(eventItem.id) !== normalizedId;
});
},
// Navigation is not an undoable user action in this demo.
setCurrentDate: (date: number) => {
state.currentDate = date;
},
// Navigation is not an undoable user action in this demo.
setView: (view: SchedulerView) => {
state.view = view;
},
updateConfig: (partial: Partial<SchedulerConfig>) => {
recordHistory();
state.config = { ...state.config, ...partial };
},
undo: () => {
if (state._past.length === 0) return;
const previous = state._past[state._past.length - 1];
const current = getHistorySnapshot();
state._past = state._past.slice(0, -1);
state._future = [current, ...state._future];
state.events = previous.events;
state.config = previous.config;
},
redo: () => {
if (state._future.length === 0) return;
const next = state._future[0];
const current = getHistorySnapshot();
state._future = state._future.slice(1);
state._past = [...state._past.slice(-state._cap + 1), current];
state.events = next.events;
state.config = next.config;
},
};
return { state, actions };
};
export const schedulerStore = createSchedulerStore();
export default schedulerStore;