step5-3 最適化まで終了

This commit is contained in:
2025-09-29 09:06:45 +09:00
parent d387a00dff
commit f7d9950bbc
4 changed files with 116 additions and 111 deletions

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import TodoList from '@/components/TodoList'; import TodoList from '@/components/TodoList';
import FilterButtons from '@/components/FilterButtons'; import FilterButtons from '@/components/FilterButtons';
@@ -37,25 +37,27 @@ export default function Home() {
const [error, setError] = useState<AppError | null>(null); const [error, setError] = useState<AppError | null>(null);
const [showErrorDialog, setShowErrorDialog] = useState(false); const [showErrorDialog, setShowErrorDialog] = useState(false);
// フィルターに応じたTodoの数をカウント // フィルターに応じたTodoの数をメモ化
const activeCount = todos.filter(todo => !todo.completed).length; const { activeCount, completedCount } = useMemo(() => ({
const completedCount = todos.filter(todo => todo.completed).length; 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); console.error('アプリケーションエラー:', error);
setError(error); setError(error);
setShowErrorDialog(true); setShowErrorDialog(true);
}; }, []);
// エラーダイアログを閉じる // エラーダイアログを閉じる
const closeErrorDialog = () => { const closeErrorDialog = useCallback(() => {
setShowErrorDialog(false); setShowErrorDialog(false);
setError(null); setError(null);
}; }, []);
// バリデーション関数 // バリデーション関数をメモ化
const validateTodoText = (text: string): boolean => { const validateTodoText = useCallback((text: string): boolean => {
if (!text || text.trim().length === 0) { if (!text || text.trim().length === 0) {
handleError({ handleError({
type: 'VALIDATION_ERROR', type: 'VALIDATION_ERROR',
@@ -73,10 +75,10 @@ export default function Home() {
} }
return true; return true;
}; }, [handleError]);
// Local Storage操作をtry-catchで包む // Local Storage操作をメモ化
const safeLocalStorageGet = (key: string): string | null => { const safeLocalStorageGet = useCallback((key: string): string | null => {
try { try {
return localStorage.getItem(key); return localStorage.getItem(key);
} catch (error) { } catch (error) {
@@ -86,9 +88,9 @@ export default function Home() {
}); });
return null; return null;
} }
}; }, [handleError]);
const safeLocalStorageSet = (key: string, value: string): boolean => { const safeLocalStorageSet = useCallback((key: string, value: string): boolean => {
try { try {
localStorage.setItem(key, value); localStorage.setItem(key, value);
return true; return true;
@@ -99,14 +101,15 @@ export default function Home() {
}); });
return false; return false;
} }
}; }, [handleError]);
const startEdit = (todo: Todo) => { // 編集関連の関数をメモ化
const startEdit = useCallback((todo: Todo) => {
setEditingId(todo.id); setEditingId(todo.id);
setEditText(todo.text); setEditText(todo.text);
}; }, []);
const saveEdit = () => { const saveEdit = useCallback(() => {
if (!validateTodoText(editText)) { if (!validateTodoText(editText)) {
return; return;
} }
@@ -128,10 +131,15 @@ export default function Home() {
message: 'TODOの更新に失敗しました' message: 'TODOの更新に失敗しました'
}); });
} }
}; }, [editText, editingId, todos, validateTodoText, handleError]);
// 新しいTODOを追加する関数エラーハンドリング追加 const cancelEdit = useCallback(() => {
const addTodo = () => { setEditingId(null);
setEditText('');
}, []);
// TODO操作の関数をメモ化
const addTodo = useCallback(() => {
if (!validateTodoText(inputValue)) { if (!validateTodoText(inputValue)) {
return; return;
} }
@@ -142,7 +150,7 @@ export default function Home() {
text: inputValue.trim(), text: inputValue.trim(),
completed: false completed: false
}; };
setTodos([...todos, newTodo]); setTodos(prev => [...prev, newTodo]);
setInputValue(''); setInputValue('');
} catch (error) { } catch (error) {
handleError({ handleError({
@@ -150,48 +158,38 @@ export default function Home() {
message: 'TODOの追加に失敗しました' message: 'TODOの追加に失敗しました'
}); });
} }
}; }, [inputValue, validateTodoText, handleError]);
const cancelEdit = () => { const handleSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
setEditingId(null);
setEditText('');
};
// フォーム送信時の処理
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
addTodo(); addTodo();
}; }, [addTodo]);
// Todoの完了状態をトグルする関数 const toggleTodo = useCallback((id: number) => {
const toggleTodo = (id: number) => {
try { try {
const updatedTodos = todos.map(todo => { setTodos(prev => prev.map(todo => {
if (todo.id === id) { if (todo.id === id) {
return { ...todo, completed: !todo.completed }; return { ...todo, completed: !todo.completed };
} }
return todo; return todo;
}); }));
setTodos(updatedTodos);
} catch (error) { } catch (error) {
handleError({ handleError({
type: 'VALIDATION_ERROR', type: 'VALIDATION_ERROR',
message: 'TODO状態の更新に失敗しました' message: 'TODO状態の更新に失敗しました'
}); });
} }
}; }, [handleError]);
// 削除ボタンが押されたときの処理(確認ダイアログ表示) const handleDeleteClick = useCallback((id: number) => {
const handleDeleteClick = (id: number) => {
setShowConfirm(true); setShowConfirm(true);
setTargetId(id); setTargetId(id);
}; }, []);
// ダイアログで「はい」が押されたときの本削除処理 const confirmDelete = useCallback(() => {
const confirmDelete = () => {
if (targetId !== null) { if (targetId !== null) {
try { try {
setTodos(todos.filter(todo => todo.id !== targetId)); setTodos(prev => prev.filter(todo => todo.id !== targetId));
setShowConfirm(false); setShowConfirm(false);
setTargetId(null); setTargetId(null);
} catch (error) { } catch (error) {
@@ -201,64 +199,30 @@ export default function Home() {
}); });
} }
} }
}; }, [targetId, handleError]);
// ダイアログで「いいえ」が押されたときの処理 const cancelDelete = useCallback(() => {
const cancelDelete = () => {
setShowConfirm(false); setShowConfirm(false);
setTargetId(null); setTargetId(null);
}; }, []);
// 空欄入力時ダイアログを閉じる関数 const closeInputAlert = useCallback(() => {
const closeInputAlert = () => {
setShowInputAlert(false); setShowInputAlert(false);
}; }, []);
// 完了済みTODOをすべて削除 const clearCompleted = useCallback(() => {
const clearCompleted = () => {
try { try {
setTodos(todos.filter(todo => !todo.completed)); setTodos(prev => prev.filter(todo => !todo.completed));
} catch (error) { } catch (error) {
handleError({ handleError({
type: 'VALIDATION_ERROR', type: 'VALIDATION_ERROR',
message: '完了済みTODOの削除に失敗しました' message: '完了済みTODOの削除に失敗しました'
}); });
} }
}; }, [handleError]);
// アプリ起動時にデータを読み込み(エラーハンドリング強化) // エクスポート・インポート機能をメモ化
useEffect(() => { const exportData = useCallback(() => {
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 = () => {
try { try {
const dataStr = JSON.stringify(todos, null, 2); const dataStr = JSON.stringify(todos, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' }); const dataBlob = new Blob([dataStr], { type: 'application/json' });
@@ -274,10 +238,9 @@ export default function Home() {
message: 'データのエクスポートに失敗しました' message: 'データのエクスポートに失敗しました'
}); });
} }
}; }, [todos, handleError]);
// データのインポート機能(エラーハンドリング強化) const importData = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const importData = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { if (file) {
const reader = new FileReader(); const reader = new FileReader();
@@ -285,7 +248,6 @@ export default function Home() {
try { try {
const importedData = JSON.parse(e.target?.result as string); const importedData = JSON.parse(e.target?.result as string);
// インポートデータの妥当性をチェック
if (!Array.isArray(importedData)) { if (!Array.isArray(importedData)) {
throw new Error('無効なデータ形式です'); throw new Error('無効なデータ形式です');
} }
@@ -302,8 +264,6 @@ export default function Home() {
} }
setTodos(validTodos); setTodos(validTodos);
// ファイル入力をリセット
event.target.value = ''; event.target.value = '';
} catch (error) { } catch (error) {
handleError({ handleError({
@@ -324,7 +284,41 @@ export default function Home() {
reader.readAsText(file); 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 ( return (
<main className="min-h-screen p-8 bg-gray-50"> <main className="min-h-screen p-8 bg-gray-50">

View File

@@ -11,7 +11,7 @@ type FilterButtonsProps = {
onClearCompleted: () => void; onClearCompleted: () => void;
}; };
export default function FilterButtons({ const FilterButtons = React.memo(function FilterButtons({
filter, filter,
totalCount, totalCount,
activeCount, activeCount,
@@ -64,4 +64,6 @@ export default function FilterButtons({
)} )}
</div> </div>
); );
} });
export default FilterButtons;

View File

@@ -18,7 +18,7 @@ type TodoItemProps = {
onEditTextChange: (text: string) => void; onEditTextChange: (text: string) => void;
}; };
export default function TodoItem({ const TodoItem = React.memo(function TodoItem({
todo, todo,
isEditing, isEditing,
editText, editText,
@@ -29,6 +29,11 @@ export default function TodoItem({
onDelete, onDelete,
onEditTextChange, onEditTextChange,
}: TodoItemProps) { }: TodoItemProps) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') onSaveEdit();
if (e.key === 'Escape') onCancelEdit();
};
return ( return (
<div className="flex items-center p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"> <div className="flex items-center p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<input <input
@@ -37,16 +42,14 @@ export default function TodoItem({
onChange={onToggle} onChange={onToggle}
className="mr-3 h-5 w-5 cursor-pointer" className="mr-3 h-5 w-5 cursor-pointer"
/> />
{isEditing ? ( {isEditing ? (
// 編集モード // 編集モード
<input <input
type="text" type="text"
value={editText} value={editText}
onChange={(e) => onEditTextChange(e.target.value)} onChange={(e) => onEditTextChange(e.target.value)}
onKeyDown={(e) => { onKeyDown={handleKeyDown}
if (e.key === 'Enter') onSaveEdit();
if (e.key === 'Escape') onCancelEdit();
}}
className="flex-1 border border-blue-500 p-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" className="flex-1 border border-blue-500 p-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
autoFocus autoFocus
/> />
@@ -61,6 +64,7 @@ export default function TodoItem({
{todo.text} {todo.text}
</span> </span>
)} )}
{isEditing ? ( {isEditing ? (
// 編集モードのボタン // 編集モードのボタン
<div className="flex gap-2 ml-2"> <div className="flex gap-2 ml-2">
@@ -96,4 +100,6 @@ export default function TodoItem({
)} )}
</div> </div>
); );
} });
export default TodoItem;

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useMemo } from 'react';
import TodoItem from './TodoItem'; import TodoItem from './TodoItem';
type Todo = { type Todo = {
@@ -22,7 +22,7 @@ type TodoListProps = {
onEditTextChange: (text: string) => void; onEditTextChange: (text: string) => void;
}; };
export default function TodoList({ const TodoList = React.memo(function TodoList({
todos, todos,
filter, filter,
editingId, editingId,
@@ -34,7 +34,8 @@ export default function TodoList({
onDeleteClick, onDeleteClick,
onEditTextChange, onEditTextChange,
}: TodoListProps) { }: TodoListProps) {
const getFilteredTodos = () => { // フィルタリング処理をメモ化
const filteredTodos = useMemo(() => {
switch (filter) { switch (filter) {
case 'active': case 'active':
return todos.filter(todo => !todo.completed); return todos.filter(todo => !todo.completed);
@@ -43,18 +44,18 @@ export default function TodoList({
default: default:
return todos; return todos;
} }
}; }, [todos, filter]);
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{getFilteredTodos().length === 0 ? ( {filteredTodos.length === 0 ? (
<p className="text-gray-500 text-center py-8"> <p className="text-gray-500 text-center py-8">
{filter === 'active' && 'すべて完了しました!'} {filter === 'active' && 'すべて完了しました!'}
{filter === 'completed' && '完了したTODOがありません'} {filter === 'completed' && '完了したTODOがありません'}
{filter === 'all' && 'TODOがありません'} {filter === 'all' && 'TODOがありません'}
</p> </p>
) : ( ) : (
getFilteredTodos().map((todo) => ( filteredTodos.map((todo) => (
<TodoItem <TodoItem
key={todo.id} key={todo.id}
todo={todo} todo={todo}
@@ -71,4 +72,6 @@ export default function TodoList({
)} )}
</div> </div>
); );
} });
export default TodoList;