From f7d9950bbc3a1e2aff4f88d9bbfedd4e3f7b7247 Mon Sep 17 00:00:00 2001 From: KentaroKumode <23e1273@andrew.ac.jp> Date: Mon, 29 Sep 2025 09:06:45 +0900 Subject: [PATCH] =?UTF-8?q?step5-3=20=E6=9C=80=E9=81=A9=E5=8C=96=E3=81=BE?= =?UTF-8?q?=E3=81=A7=E7=B5=82=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/page.tsx | 186 +++++++++++++++---------------- src/components/FilterButtons.tsx | 6 +- src/components/TodoItem.tsx | 18 ++- src/components/TodoList.tsx | 17 +-- 4 files changed, 116 insertions(+), 111 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 60c5120..f0c3033 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import TodoList from '@/components/TodoList'; import FilterButtons from '@/components/FilterButtons'; @@ -37,25 +37,27 @@ export default function Home() { const [error, setError] = useState(null); const [showErrorDialog, setShowErrorDialog] = useState(false); - // フィルターに応じたTodoの数をカウント - const activeCount = todos.filter(todo => !todo.completed).length; - const completedCount = todos.filter(todo => todo.completed).length; + // フィルターに応じたTodoの数をメモ化 + const { activeCount, completedCount } = useMemo(() => ({ + activeCount: todos.filter(todo => !todo.completed).length, + completedCount: todos.filter(todo => todo.completed).length, + }), [todos]); - // エラー処理用のヘルパー関数 - const handleError = (error: AppError) => { + // エラー処理用のヘルパー関数をメモ化 + const handleError = useCallback((error: AppError) => { console.error('アプリケーションエラー:', error); setError(error); setShowErrorDialog(true); - }; + }, []); // エラーダイアログを閉じる - const closeErrorDialog = () => { + const closeErrorDialog = useCallback(() => { setShowErrorDialog(false); setError(null); - }; + }, []); - // バリデーション関数 - const validateTodoText = (text: string): boolean => { + // バリデーション関数をメモ化 + const validateTodoText = useCallback((text: string): boolean => { if (!text || text.trim().length === 0) { handleError({ type: 'VALIDATION_ERROR', @@ -73,10 +75,10 @@ export default function Home() { } return true; - }; + }, [handleError]); - // Local Storage操作をtry-catchで包む - const safeLocalStorageGet = (key: string): string | null => { + // Local Storage操作をメモ化 + const safeLocalStorageGet = useCallback((key: string): string | null => { try { return localStorage.getItem(key); } catch (error) { @@ -86,9 +88,9 @@ export default function Home() { }); return null; } - }; + }, [handleError]); - const safeLocalStorageSet = (key: string, value: string): boolean => { + const safeLocalStorageSet = useCallback((key: string, value: string): boolean => { try { localStorage.setItem(key, value); return true; @@ -99,14 +101,15 @@ export default function Home() { }); return false; } - }; + }, [handleError]); - const startEdit = (todo: Todo) => { + // 編集関連の関数をメモ化 + const startEdit = useCallback((todo: Todo) => { setEditingId(todo.id); setEditText(todo.text); - }; + }, []); - const saveEdit = () => { + const saveEdit = useCallback(() => { if (!validateTodoText(editText)) { return; } @@ -128,10 +131,15 @@ export default function Home() { message: 'TODOの更新に失敗しました' }); } - }; + }, [editText, editingId, todos, validateTodoText, handleError]); - // 新しいTODOを追加する関数(エラーハンドリング追加) - const addTodo = () => { + const cancelEdit = useCallback(() => { + setEditingId(null); + setEditText(''); + }, []); + + // TODO操作の関数をメモ化 + const addTodo = useCallback(() => { if (!validateTodoText(inputValue)) { return; } @@ -142,7 +150,7 @@ export default function Home() { text: inputValue.trim(), completed: false }; - setTodos([...todos, newTodo]); + setTodos(prev => [...prev, newTodo]); setInputValue(''); } catch (error) { handleError({ @@ -150,48 +158,38 @@ export default function Home() { message: 'TODOの追加に失敗しました' }); } - }; + }, [inputValue, validateTodoText, handleError]); - const cancelEdit = () => { - setEditingId(null); - setEditText(''); - }; - - // フォーム送信時の処理 - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = useCallback((e: React.FormEvent) => { e.preventDefault(); addTodo(); - }; + }, [addTodo]); - // Todoの完了状態をトグルする関数 - const toggleTodo = (id: number) => { + const toggleTodo = useCallback((id: number) => { try { - const updatedTodos = todos.map(todo => { + setTodos(prev => prev.map(todo => { if (todo.id === id) { return { ...todo, completed: !todo.completed }; } return todo; - }); - setTodos(updatedTodos); + })); } catch (error) { handleError({ type: 'VALIDATION_ERROR', message: 'TODO状態の更新に失敗しました' }); } - }; + }, [handleError]); - // 削除ボタンが押されたときの処理(確認ダイアログ表示) - const handleDeleteClick = (id: number) => { + const handleDeleteClick = useCallback((id: number) => { setShowConfirm(true); setTargetId(id); - }; + }, []); - // ダイアログで「はい」が押されたときの本削除処理 - const confirmDelete = () => { + const confirmDelete = useCallback(() => { if (targetId !== null) { try { - setTodos(todos.filter(todo => todo.id !== targetId)); + setTodos(prev => prev.filter(todo => todo.id !== targetId)); setShowConfirm(false); setTargetId(null); } catch (error) { @@ -201,64 +199,30 @@ export default function Home() { }); } } - }; + }, [targetId, handleError]); - // ダイアログで「いいえ」が押されたときの処理 - const cancelDelete = () => { + const cancelDelete = useCallback(() => { setShowConfirm(false); setTargetId(null); - }; + }, []); - // 空欄入力時ダイアログを閉じる関数 - const closeInputAlert = () => { + const closeInputAlert = useCallback(() => { setShowInputAlert(false); - }; + }, []); - // 完了済みTODOをすべて削除 - const clearCompleted = () => { + const clearCompleted = useCallback(() => { try { - setTodos(todos.filter(todo => !todo.completed)); + setTodos(prev => prev.filter(todo => !todo.completed)); } catch (error) { handleError({ type: 'VALIDATION_ERROR', message: '完了済みTODOの削除に失敗しました' }); } - }; + }, [handleError]); - // アプリ起動時にデータを読み込み(エラーハンドリング強化) - useEffect(() => { - const savedTodos = safeLocalStorageGet(STORAGE_KEY); - if (savedTodos) { - try { - const parsedTodos = JSON.parse(savedTodos); - - // データの妥当性をチェック - if (Array.isArray(parsedTodos)) { - const validTodos = parsedTodos.filter(todo => - todo && - typeof todo.id === 'number' && - typeof todo.text === 'string' && - typeof todo.completed === 'boolean' - ); - setTodos(validTodos); - } - } catch (error) { - handleError({ - type: 'STORAGE_ERROR', - message: '保存されたデータの形式が正しくありません' - }); - } - } - }, []); - - // TODOが変更されるたびに保存(エラーハンドリング追加) - useEffect(() => { - safeLocalStorageSet(STORAGE_KEY, JSON.stringify(todos)); - }, [todos]); - - // データのエクスポート機能(エラーハンドリング追加) - const exportData = () => { + // エクスポート・インポート機能をメモ化 + const exportData = useCallback(() => { try { const dataStr = JSON.stringify(todos, null, 2); const dataBlob = new Blob([dataStr], { type: 'application/json' }); @@ -274,10 +238,9 @@ export default function Home() { message: 'データのエクスポートに失敗しました' }); } - }; + }, [todos, handleError]); - // データのインポート機能(エラーハンドリング強化) - const importData = (event: React.ChangeEvent) => { + const importData = useCallback((event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (file) { const reader = new FileReader(); @@ -285,7 +248,6 @@ export default function Home() { try { const importedData = JSON.parse(e.target?.result as string); - // インポートデータの妥当性をチェック if (!Array.isArray(importedData)) { throw new Error('無効なデータ形式です'); } @@ -302,8 +264,6 @@ export default function Home() { } setTodos(validTodos); - - // ファイル入力をリセット event.target.value = ''; } catch (error) { handleError({ @@ -324,7 +284,41 @@ export default function Home() { reader.readAsText(file); } - }; + }, [handleError]); + + // アプリ起動時にデータを読み込み + useEffect(() => { + const savedTodos = safeLocalStorageGet(STORAGE_KEY); + if (savedTodos) { + try { + const parsedTodos = JSON.parse(savedTodos); + + if (Array.isArray(parsedTodos)) { + const validTodos = parsedTodos.filter(todo => + todo && + typeof todo.id === 'number' && + typeof todo.text === 'string' && + typeof todo.completed === 'boolean' + ); + setTodos(validTodos); + } + } catch (error) { + handleError({ + type: 'STORAGE_ERROR', + message: '保存されたデータの形式が正しくありません' + }); + } + } + }, [safeLocalStorageGet, handleError]); + + // TODOが変更されるたびに保存(デバウンス処理付き) + useEffect(() => { + const timeoutId = setTimeout(() => { + safeLocalStorageSet(STORAGE_KEY, JSON.stringify(todos)); + }, 300); // 300ms後に保存 + + return () => clearTimeout(timeoutId); + }, [todos, safeLocalStorageSet]); return (
diff --git a/src/components/FilterButtons.tsx b/src/components/FilterButtons.tsx index f6e44e0..d977121 100644 --- a/src/components/FilterButtons.tsx +++ b/src/components/FilterButtons.tsx @@ -11,7 +11,7 @@ type FilterButtonsProps = { onClearCompleted: () => void; }; -export default function FilterButtons({ +const FilterButtons = React.memo(function FilterButtons({ filter, totalCount, activeCount, @@ -64,4 +64,6 @@ export default function FilterButtons({ )} ); -} \ No newline at end of file +}); + +export default FilterButtons; \ No newline at end of file diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx index 10244ee..f1af893 100644 --- a/src/components/TodoItem.tsx +++ b/src/components/TodoItem.tsx @@ -18,7 +18,7 @@ type TodoItemProps = { onEditTextChange: (text: string) => void; }; -export default function TodoItem({ +const TodoItem = React.memo(function TodoItem({ todo, isEditing, editText, @@ -29,6 +29,11 @@ export default function TodoItem({ onDelete, onEditTextChange, }: TodoItemProps) { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') onSaveEdit(); + if (e.key === 'Escape') onCancelEdit(); + }; + return (
+ {isEditing ? ( // 編集モード onEditTextChange(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') onSaveEdit(); - if (e.key === 'Escape') onCancelEdit(); - }} + onKeyDown={handleKeyDown} className="flex-1 border border-blue-500 p-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" autoFocus /> @@ -61,6 +64,7 @@ export default function TodoItem({ {todo.text} )} + {isEditing ? ( // 編集モードのボタン
@@ -96,4 +100,6 @@ export default function TodoItem({ )}
); -} \ No newline at end of file +}); + +export default TodoItem; \ No newline at end of file diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx index a93cb4b..a297902 100644 --- a/src/components/TodoList.tsx +++ b/src/components/TodoList.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import TodoItem from './TodoItem'; type Todo = { @@ -22,7 +22,7 @@ type TodoListProps = { onEditTextChange: (text: string) => void; }; -export default function TodoList({ +const TodoList = React.memo(function TodoList({ todos, filter, editingId, @@ -34,7 +34,8 @@ export default function TodoList({ onDeleteClick, onEditTextChange, }: TodoListProps) { - const getFilteredTodos = () => { + // フィルタリング処理をメモ化 + const filteredTodos = useMemo(() => { switch (filter) { case 'active': return todos.filter(todo => !todo.completed); @@ -43,18 +44,18 @@ export default function TodoList({ default: return todos; } - }; + }, [todos, filter]); return (
- {getFilteredTodos().length === 0 ? ( + {filteredTodos.length === 0 ? (

{filter === 'active' && 'すべて完了しました!'} {filter === 'completed' && '完了したTODOがありません'} {filter === 'all' && 'TODOがありません'}

) : ( - getFilteredTodos().map((todo) => ( + filteredTodos.map((todo) => ( ); -} \ No newline at end of file +}); + +export default TodoList; \ No newline at end of file