react-big-calendarを学ぶ(Step3:リファクタリング)

## はじめに
[前回](https://qiita.com/koji0705/items/32c78d80501290c6ebb5)までの記事の続きです。
前回までに作った内容だと、ちょっと扱いづらい部分があったので、リファクタリングを実施しました。
今回はreducerを使ってリファクタリングしています！か

## 成果物

![aa.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599049/7a00f9e8-28b8-4cb8-b940-349472d328f5.gif)

## ソースコード
```shell:
~/develop/my-calendar-app  (main)$ tree src/
src/
├── App.css
├── App.tsx
├── components
│   ├── AddEventDialog.tsx
│   └── ResizeConfirmDialog.tsx
├── config
│   └── calendar.ts
├── declarations.d.ts
├── hooks
│   └── useCalendar.ts
├── index.css
├── index.tsx
├── logo.svg
├── react-app-env.d.ts
├── reportWebVitals.ts
├── setupTests.ts
└── types
    └── calendar.ts
5 directories, 14 files
```

```tsx:src/App.tsx
// React DnDのプロバイダーとカレンダー拡張のインポート
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop";
import { Calendar } from "react-big-calendar";

// カレンダーのスタイル
import "react-big-calendar/lib/css/react-big-calendar.css";
import "react-big-calendar/lib/addons/dragAndDrop/styles.css";

// カスタムフックとダイアログコンポーネント、ローカライズ設定
import { useCalendar } from "./hooks/useCalendar";
import { AddEventDialog } from "./components/AddEventDialog";
import { ResizeConfirmDialog } from "./components/ResizeConfirmDialog";
import { localizer } from "./config/calendar";

// DnD対応カレンダーに拡張
const DragAndDropCalendar = withDragAndDrop(Calendar);

export default function App() {
	// カレンダーの状態と操作関数をカスタムフックから取得
	const {
		events,
		showDialog,
		resizedEvent,
		showAddDialog,
		addEventTitle,
		setAddEventTitle,
		onEventDrop,
		handleResizeWithDialog,
		handleAddDialogConfirm,
		handleAddDialogCancel,
		handleSelectSlot,
		setShowDialog,
	} = useCalendar();

	return (
		// DnD機能を有効化
		<DndProvider backend={HTML5Backend}>
			<div style={{ height: "100vh", padding: 20 }}>
				{/* カレンダー本体 */}
				<DragAndDropCalendar
					localizer={localizer} // 日付ローカライズ
					events={events} // 表示するイベント
					startAccessor="start" // イベント開始日時のプロパティ名
					endAccessor="end" // イベント終了日時のプロパティ名
					onEventDrop={onEventDrop} // イベントのドラッグ移動時
					onSelectSlot={handleSelectSlot} // 範囲選択（新規作成）
					selectable={true} // 範囲選択を有効化
					resizable // イベントのリサイズを有効化
					onEventResize={handleResizeWithDialog} // リサイズ時
					style={{ height: "100%" }}
					popup // イベント詳細をポップアップ表示
					views={["week"]} // 週表示のみ
					defaultView="week" // デフォルトで週表示
				/>
				{/* 新規イベント追加ダイアログ */}
				<AddEventDialog
					show={showAddDialog}
					title={addEventTitle}
					onTitleChange={setAddEventTitle}
					onConfirm={handleAddDialogConfirm}
					onCancel={handleAddDialogCancel}
				/>
				{/* イベントリサイズ後の確認ダイアログ */}
				<ResizeConfirmDialog
					show={showDialog}
					event={resizedEvent}
					onClose={() => setShowDialog(false)}
				/>
			</div>
		</DndProvider>
	);
}
```

:::note info
カスタムフックとカレンダー、Dialogの制御を実施します。
:::

```tsx:src/hooks/useCalendar.ts
import { useReducer } from "react";
import type { EventType, EventDropArgs } from "../types/calendar";

// カレンダーで管理する状態の型定義
type State = {
	events: EventType[];
	showDialog: boolean;
	resizedEvent: EventType | null;
	showAddDialog: boolean;
	addEventTitle: string;
	addEventStart: Date | null;
	addEventEnd: Date | null;
};

// 状態を変更するためのアクション型定義
type Action =
	| { type: "DROP_EVENT"; payload: EventDropArgs }
	| { type: "RESIZE_EVENT"; payload: EventDropArgs }
	| { type: "SHOW_ADD_DIALOG"; payload: { start: Date; end: Date } }
	| { type: "HIDE_ADD_DIALOG" }
	| { type: "SET_ADD_TITLE"; payload: string }
	| { type: "CONFIRM_ADD_EVENT" }
	| { type: "CANCEL_ADD_EVENT" }
	| { type: "CLOSE_RESIZE_DIALOG" };

const initialEvents: EventType[] = [
	{
		id: 0,
		title: "会議A",
		start: new Date(),
		end: new Date(new Date().getTime() + 60 * 60 * 1000), // +1時間
	},
];

const initialState: State = {
	events: initialEvents,
	showDialog: false,
	resizedEvent: null,
	showAddDialog: false,
	addEventTitle: "",
	addEventStart: null,
	addEventEnd: null,
};

// reducer本体：アクションに応じて状態を更新
function reducer(state: State, action: Action): State {
	switch (action.type) {
		case "DROP_EVENT":
			return {
				...state,
				events: state.events.map((e) =>
					e.id === action.payload.event.id
						? { ...e, start: action.payload.start, end: action.payload.end }
						: e,
				),
			};
		case "RESIZE_EVENT":
			return {
				...state,
				events: state.events.map((e) =>
					e.id === action.payload.event.id
						? { ...e, start: action.payload.start, end: action.payload.end }
						: e,
				),
				resizedEvent: {
					...action.payload.event,
					start: action.payload.start,
					end: action.payload.end,
				},
				showDialog: true,
			};
		case "SHOW_ADD_DIALOG":
			return {
				...state,
				showAddDialog: true,
				addEventStart: action.payload.start,
				addEventEnd: action.payload.end,
				addEventTitle: "",
			};
		case "HIDE_ADD_DIALOG":
			return {
				...state,
				showAddDialog: false,
				addEventTitle: "",
				addEventStart: null,
				addEventEnd: null,
			};
		case "SET_ADD_TITLE":
			return { ...state, addEventTitle: action.payload };
		case "CONFIRM_ADD_EVENT":
			if (state.addEventTitle && state.addEventStart && state.addEventEnd) {
				const newEvent: EventType = {
					id:
						state.events.length > 0
							? Math.max(...state.events.map((e) => e.id)) + 1
							: 1,
					title: state.addEventTitle,
					start: state.addEventStart,
					end: state.addEventEnd,
				};
				return {
					...state,
					events: [...state.events, newEvent],
					showAddDialog: false,
					addEventTitle: "",
					addEventStart: null,
					addEventEnd: null,
				};
			}
			return state;
		case "CANCEL_ADD_EVENT":
			return {
				...state,
				showAddDialog: false,
				addEventTitle: "",
				addEventStart: null,
				addEventEnd: null,
			};
		case "CLOSE_RESIZE_DIALOG":
			return { ...state, showDialog: false };
		default:
			return state;
	}
}

// カレンダーの状態管理用カスタムフック
export const useCalendar = () => {
	const [state, dispatch] = useReducer(reducer, initialState);
	return {
		...state,
		onEventDrop: (args: EventDropArgs) =>
			dispatch({ type: "DROP_EVENT", payload: args }),
		handleResizeWithDialog: (args: EventDropArgs) =>
			dispatch({ type: "RESIZE_EVENT", payload: args }),
		handleAddDialogConfirm: () => dispatch({ type: "CONFIRM_ADD_EVENT" }),
		handleAddDialogCancel: () => dispatch({ type: "CANCEL_ADD_EVENT" }),
		handleSelectSlot: ({ start, end }: { start: Date; end: Date }) =>
			dispatch({ type: "SHOW_ADD_DIALOG", payload: { start, end } }),
		setAddEventTitle: (title: string) =>
			dispatch({ type: "SET_ADD_TITLE", payload: title }),
		setShowDialog: (show: boolean) => dispatch({ type: "CLOSE_RESIZE_DIALOG" }),
	};
};
```

:::note info
reducerを定義し実装しています。イベントが多くあるので、状態とアクションをまとめて定義します。
:::

```tsx:src/components/AddEventDialog.tsx
import { useRef, useEffect } from "react";

type AddEventDialogProps = {
	show: boolean;
	title: string;
	onTitleChange: (title: string) => void;
	onConfirm: () => void;
	onCancel: () => void;
};

export const AddEventDialog = ({
	show,
	title,
	onTitleChange,
	onConfirm,
	onCancel,
}: AddEventDialogProps) => {
	const inputRef = useRef<HTMLInputElement>(null);

	useEffect(() => {
		if (show && inputRef.current) {
			inputRef.current.focus();
		}
	}, [show]);

	if (!show) return null;

	return (
		<div
			style={{
				position: "absolute",
				top: "30%",
				left: "30%",
				padding: 20,
				background: "white",
				border: "1px solid #ccc",
				boxShadow: "0 2px 10px rgba(0,0,0,0.3)",
				zIndex: 1000,
			}}
		>
			<p>新しいイベントのタイトルを入力してください</p>
			<input
				ref={inputRef}
				type="text"
				value={title}
				onChange={(e) => onTitleChange(e.target.value)}
			/>
			<div style={{ marginTop: 10, display: "flex", gap: 8 }}>
				<button type="button" onClick={onCancel}>
					キャンセル
				</button>
				<button type="button" onClick={onConfirm}>
					OK
				</button>
			</div>
		</div>
	);
};
```

```tsx:src/components/ResizeConfirmDialog.tsx
import type { EventType } from "../types/calendar";

type ResizeConfirmDialogProps = {
	show: boolean;
	event: EventType | null;
	onClose: () => void;
};

export const ResizeConfirmDialog = ({
	show,
	event,
	onClose,
}: ResizeConfirmDialogProps) => {
	if (!show || !event) return null;

	return (
		<div
			style={{
				position: "absolute",
				top: "30%",
				left: "30%",
				padding: 20,
				background: "white",
				border: "1px solid #ccc",
				boxShadow: "0 2px 10px rgba(0,0,0,0.3)",
				zIndex: 1000,
			}}
		>
			<p>「{event.title}」の時間を変更しました。</p>
			<button type="button" onClick={onClose}>
				閉じる
			</button>
		</div>
	);
};
```

```tsx:src/config/calendar.ts
import { dateFnsLocalizer } from "react-big-calendar";
import { format, parse, startOfWeek, getDay } from "date-fns";
import { ja } from "date-fns/locale";

export const locales = {
	ja: ja,
};

export const localizer = dateFnsLocalizer({
	format,
	parse,
	startOfWeek,
	getDay,
	locales,
	locale: "ja",
});
```

```ts:src/types/calendar.ts
export type EventType = {
	id: number;
	title: string;
	start: Date;
	end: Date;
};

export type EventDropArgs = {
	event: EventType;
	start: Date;
	end: Date;
};
```