From 358ee9b7f44a5abf31cc7871459968c61729b3da Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 5 Nov 2025 19:57:07 +0100 Subject: [PATCH] perf(frontend): Optimize component rendering with React.memo and hooks - Sprint 3 Story 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add React.memo to display components and useCallback/useMemo for better performance. Changes: - Added React.memo to TaskCard component - Added React.memo to StoryCard component - Added React.memo to KanbanBoard component - Added React.memo to KanbanColumn component - Added useCallback to kanban page drag handlers (handleDragStart, handleDragEnd) - Added useCallback to epics page handlers (handleDelete, getStatusColor, getPriorityColor) - Added useMemo for expensive computations in dashboard page (stats, recentProjects sorting) - Added useMemo for total tasks calculation in KanbanBoard - Removed unused isConnected variable from kanban page Performance improvements: - Reduced unnecessary re-renders in Card components - Optimized list rendering performance with memoized callbacks - Improved filtering and sorting performance with useMemo - Better React DevTools Profiler metrics 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/(dashboard)/dashboard/page.tsx | 15 +++++++++------ app/(dashboard)/projects/[id]/epics/page.tsx | 14 +++++++------- app/(dashboard)/projects/[id]/kanban/page.tsx | 12 ++++++------ components/features/kanban/KanbanBoard.tsx | 11 ++++++++--- components/features/kanban/KanbanColumn.tsx | 5 +++-- components/features/kanban/StoryCard.tsx | 6 +++--- components/features/kanban/TaskCard.tsx | 5 +++-- 7 files changed, 39 insertions(+), 29 deletions(-) diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 7ad275c..2f9a799 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -1,7 +1,7 @@ 'use client'; import Link from 'next/link'; -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { Plus, FolderKanban, Archive, TrendingUp, ArrowRight } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -15,16 +15,19 @@ export default function DashboardPage() { const { data: projects, isLoading } = useProjects(); // Calculate statistics - const stats = { + const stats = useMemo(() => ({ totalProjects: projects?.length || 0, activeProjects: projects?.length || 0, // TODO: Add status field to Project model archivedProjects: 0, // TODO: Add status field to Project model - }; + }), [projects]); // Get recent projects (sort by creation time, take first 5) - const recentProjects = projects - ?.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - .slice(0, 5) || []; + const recentProjects = useMemo(() => { + return projects + ?.slice() + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, 5) || []; + }, [projects]); return (
diff --git a/app/(dashboard)/projects/[id]/epics/page.tsx b/app/(dashboard)/projects/[id]/epics/page.tsx index a510848..f537e4b 100644 --- a/app/(dashboard)/projects/[id]/epics/page.tsx +++ b/app/(dashboard)/projects/[id]/epics/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { use, useState } from 'react'; +import { use, useState, useCallback } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { @@ -62,7 +62,7 @@ export default function EpicsPage({ params }: EpicsPageProps) { const { data: epics, isLoading: epicsLoading, error } = useEpics(projectId); const deleteEpic = useDeleteEpic(); - const handleDelete = async () => { + const handleDelete = useCallback(async () => { if (!deletingEpicId) return; try { @@ -72,9 +72,9 @@ export default function EpicsPage({ params }: EpicsPageProps) { const message = error instanceof Error ? error.message : 'Failed to delete epic'; toast.error(message); } - }; + }, [deletingEpicId, deleteEpic]); - const getStatusColor = (status: WorkItemStatus) => { + const getStatusColor = useCallback((status: WorkItemStatus) => { switch (status) { case 'Backlog': return 'secondary'; @@ -87,9 +87,9 @@ export default function EpicsPage({ params }: EpicsPageProps) { default: return 'secondary'; } - }; + }, []); - const getPriorityColor = (priority: WorkItemPriority) => { + const getPriorityColor = useCallback((priority: WorkItemPriority) => { switch (priority) { case 'Low': return 'bg-blue-100 text-blue-700 hover:bg-blue-100'; @@ -102,7 +102,7 @@ export default function EpicsPage({ params }: EpicsPageProps) { default: return 'secondary'; } - }; + }, []); if (projectLoading || epicsLoading) { return ( diff --git a/app/(dashboard)/projects/[id]/kanban/page.tsx b/app/(dashboard)/projects/[id]/kanban/page.tsx index dee930e..8f6e0d6 100644 --- a/app/(dashboard)/projects/[id]/kanban/page.tsx +++ b/app/(dashboard)/projects/[id]/kanban/page.tsx @@ -8,7 +8,7 @@ import { DragStartEvent, closestCorners, } from '@dnd-kit/core'; -import { useState, useMemo } from 'react'; +import { useState, useMemo, useCallback } from 'react'; import { useProjectStories } from '@/lib/hooks/use-stories'; import { useEpics } from '@/lib/hooks/use-epics'; import { useChangeStoryStatus } from '@/lib/hooks/use-stories'; @@ -43,7 +43,7 @@ export default function KanbanPage() { // SignalR real-time updates const queryClient = useQueryClient(); - const { isConnected } = useSignalRConnection(); + useSignalRConnection(); // Establish connection const changeStatusMutation = useChangeStoryStatus(); // Subscribe to SignalR events for real-time updates @@ -83,12 +83,12 @@ export default function KanbanPage() { Done: stories.filter((s) => s.status === 'Done'), }), [stories]); - const handleDragStart = (event: DragStartEvent) => { + const handleDragStart = useCallback((event: DragStartEvent) => { const story = stories.find((s) => s.id === event.active.id); setActiveStory(story || null); - }; + }, [stories]); - const handleDragEnd = (event: DragEndEvent) => { + const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event; setActiveStory(null); @@ -101,7 +101,7 @@ export default function KanbanPage() { logger.debug(`[Kanban] Changing story ${story.id} status to ${newStatus}`); changeStatusMutation.mutate({ id: story.id, status: newStatus }); } - }; + }, [stories, changeStatusMutation]); if (isLoading) { return ( diff --git a/components/features/kanban/KanbanBoard.tsx b/components/features/kanban/KanbanBoard.tsx index 626453e..ac69f4a 100644 --- a/components/features/kanban/KanbanBoard.tsx +++ b/components/features/kanban/KanbanBoard.tsx @@ -1,5 +1,6 @@ 'use client'; +import React, { useMemo } from 'react'; import { TaskCard } from './TaskCard'; import type { LegacyKanbanBoard } from '@/types/kanban'; @@ -9,13 +10,17 @@ interface KanbanBoardProps { // Legacy KanbanBoard component using old Kanban type // For new Issue-based Kanban, use the page at /projects/[id]/kanban -export function KanbanBoard({ board }: KanbanBoardProps) { +export const KanbanBoard = React.memo(function KanbanBoard({ board }: KanbanBoardProps) { + const totalTasks = useMemo(() => { + return board.columns.reduce((acc, col) => acc + col.tasks.length, 0); + }, [board.columns]); + return (

{board.projectName}

- Total tasks: {board.columns.reduce((acc, col) => acc + col.tasks.length, 0)} + Total tasks: {totalTasks}

@@ -48,4 +53,4 @@ export function KanbanBoard({ board }: KanbanBoardProps) {

); -} +}); diff --git a/components/features/kanban/KanbanColumn.tsx b/components/features/kanban/KanbanColumn.tsx index f9f2974..bf524b8 100644 --- a/components/features/kanban/KanbanColumn.tsx +++ b/components/features/kanban/KanbanColumn.tsx @@ -1,5 +1,6 @@ 'use client'; +import React from 'react'; import { useDroppable } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -14,7 +15,7 @@ interface KanbanColumnProps { taskCounts?: Record; // Map of storyId -> taskCount } -export function KanbanColumn({ id, title, stories, epicNames = {}, taskCounts = {} }: KanbanColumnProps) { +export const KanbanColumn = React.memo(function KanbanColumn({ id, title, stories, epicNames = {}, taskCounts = {} }: KanbanColumnProps) { const { setNodeRef } = useDroppable({ id }); return ( @@ -47,4 +48,4 @@ export function KanbanColumn({ id, title, stories, epicNames = {}, taskCounts = ); -} +}); diff --git a/components/features/kanban/StoryCard.tsx b/components/features/kanban/StoryCard.tsx index 9eff9b6..40fae42 100644 --- a/components/features/kanban/StoryCard.tsx +++ b/components/features/kanban/StoryCard.tsx @@ -1,5 +1,6 @@ 'use client'; +import React, { useMemo } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { Card, CardContent } from '@/components/ui/card'; @@ -7,7 +8,6 @@ import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Story } from '@/types/project'; import { FileText, FolderKanban, Clock, CheckSquare } from 'lucide-react'; -import { useMemo } from 'react'; interface StoryCardProps { story: Story; @@ -15,7 +15,7 @@ interface StoryCardProps { taskCount?: number; } -export function StoryCard({ story, epicName, taskCount }: StoryCardProps) { +export const StoryCard = React.memo(function StoryCard({ story, epicName, taskCount }: StoryCardProps) { const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: story.id }); @@ -140,4 +140,4 @@ export function StoryCard({ story, epicName, taskCount }: StoryCardProps) { ); -} +}); diff --git a/components/features/kanban/TaskCard.tsx b/components/features/kanban/TaskCard.tsx index b3c8577..94e6151 100644 --- a/components/features/kanban/TaskCard.tsx +++ b/components/features/kanban/TaskCard.tsx @@ -1,5 +1,6 @@ 'use client'; +import React from 'react'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Clock, User } from 'lucide-react'; import type { TaskCard as TaskCardType } from '@/types/kanban'; @@ -9,7 +10,7 @@ interface TaskCardProps { isDragging?: boolean; } -export function TaskCard({ task, isDragging = false }: TaskCardProps) { +export const TaskCard = React.memo(function TaskCard({ task, isDragging = false }: TaskCardProps) { const priorityColors = { Low: 'bg-blue-100 text-blue-700', Medium: 'bg-yellow-100 text-yellow-700', @@ -59,4 +60,4 @@ export function TaskCard({ task, isDragging = false }: TaskCardProps) { ); -} +});