JavaScript and React course Β· Module 9: Modern React Hooks
Project: Advanced React 18+ Application
In this lesson4
Every crew eventually goes through a full mission simulation, with all systems running at once. You already know transitions, deferred values, useId, use and useOptimistic, but separately. Now they have to cooperate in one codebase.
In this project, you will create a modern React application using all the React 18+ features you have learned. You will build an Advanced Task Management System - an advanced task management system utilizing Concurrent Features, Server Components, and modern hooks.
Project Specification
Main Features
Dashboard with real-time updates
- Using useTransition and useDeferredValue
- Optimistic updates with useOptimistic
- Content streaming with Suspense
Advanced forms
- useFormStatus and useActionState
- Server Actions
- Real-time validation
Notification system
- useId for accessibility
- Concurrent rendering
- Progressive enhancement
Performance and optimization
- React Compiler (optional)
- Automatic Batching
- Selective Hydration
React Compiler has had a stable 1.0 release since 2025 and adds memoization by itself, so it is worth considering.
Application Structure
The project uses the Next.js App Router, a framework that supports Server Components and Server Actions. Here is the directory map:
1src/
2βββ app/
3β βββ layout.tsx # Root layout (Server Component)
4β βββ page.tsx # Home page
5β βββ dashboard/
6β β βββ page.tsx # Dashboard (Server Component)
7β β βββ loading.tsx # Loading UI
8β βββ tasks/
9β β βββ page.tsx # Tasks list
10β β βββ [id]/page.tsx # Task details
11β β βββ new/page.tsx # Create task
12β βββ api/
13β βββ tasks/route.ts # Tasks API
14β βββ notifications/route.ts
15βββ components/
16β βββ ui/ # Basic UI components
17β βββ forms/ # Form components
18β βββ dashboard/ # Dashboard components
19β βββ tasks/ # Task components
20βββ hooks/ # Custom hooks
21βββ lib/ # Utilities
22βββ types/ # TypeScript typesThe app/ folder holds the routes, components/ the UI building blocks, and lib/ the server logic.
Step-by-step Implementation
Step 1: Basic structure and types
We start with the data. A TypeScript interface is a contract describing the fields of a task, a user and a notification:
1// types/index.ts
2export interface Task {
3 id: string;
4 title: string;
5 description: string;
6 status: 'pending' | 'in_progress' | 'completed';
7 priority: 'low' | 'medium' | 'high';
8 assignee?: string;
9 dueDate?: Date;
10 createdAt: Date;
11 updatedAt: Date;
12}
13
14export interface User {
15 id: string;
16 name: string;
17 email: string;
18 avatar?: string;
19}
20
21export interface Notification {
22 id: string;
23 type: 'task_created' | 'task_updated' | 'task_completed';
24 message: string;
25 timestamp: Date;
26 read: boolean;
27}Fields with a question mark, such as assignee?, are optional, and unions like 'low' | 'medium' | 'high' allow only the listed values.
Step 2: Root Layout with Server Components
The layout is an async Server Component, so it can fetch the user's data while still on the server:
1// app/layout.tsx (Server Component)
2import { Suspense } from 'react';
3import { Navigation } from '@/components/Navigation';
4import { NotificationProvider } from '@/components/NotificationProvider';
5
6async function RootLayout({ children }: { children: React.ReactNode }) {
7 // Fetch user data on the server
8 const user = await getCurrentUser();
9 const notifications = await getUnreadNotifications(user.id);
10
11 return (
12 <html lang="en">
13 <head>
14 <title>Advanced Task Manager</title>
15 <meta name="description" content="Advanced task management system" />
16 </head>
17 <body>
18 <NotificationProvider initialNotifications={notifications}>
19 <div className="app-layout">
20 <Suspense fallback={<NavigationSkeleton />}>
21 <Navigation user={user} />
22 </Suspense>
23
24 <main className="main-content">
25 <Suspense fallback={<PageSkeleton />}>
26 {children}
27 </Suspense>
28 </main>
29 </div>
30 </NotificationProvider>
31 </body>
32 </html>
33 );
34}
35
36export default RootLayout;Two Suspense boundaries let the navigation and the content appear independently. In Next.js, the page title and description are usually defined through a metadata export rather than a hand-written <head>.
Step 3: Dashboard with Concurrent Features
The dashboard page fetches statistics and recent tasks in parallel and streams the slower sections:
1// app/dashboard/page.tsx (Server Component)
2import { Suspense } from 'react';
3import { TasksOverview } from '@/components/dashboard/TasksOverview';
4import { RecentActivity } from '@/components/dashboard/RecentActivity';
5import { PerformanceMetrics } from '@/components/dashboard/PerformanceMetrics';
6
7async function DashboardPage() {
8 // Parallel data fetching
9 const [tasksStats, recentTasks] = await Promise.all([
10 getTasksStatistics(),
11 getRecentTasks(10)
12 ]);
13
14 return (
15 <div className="dashboard">
16 <h1>Dashboard</h1>
17
18 {/* Fast-loading section */}
19 <TasksOverview stats={tasksStats} />
20
21 <div className="dashboard-grid">
22 {/* Streams independently */}
23 <Suspense fallback={<RecentActivitySkeleton />}>
24 <RecentActivity initialTasks={recentTasks} />
25 </Suspense>
26
27 {/* Can load later */}
28 <Suspense fallback={<MetricsSkeleton />}>
29 <PerformanceMetrics />
30 </Suspense>
31 </div>
32 </div>
33 );
34}
35
36export default DashboardPage;TasksOverview appears immediately, while PerformanceMetrics loads later.
Step 4: Interactive Components with new hooks
The 'use client' directive marks the boundary beyond which code is sent to the browser and can use state:
1// components/dashboard/RecentActivity.tsx (Client Component)
2'use client';
3
4import { useState, useTransition, useDeferredValue } from 'react';
5import { Task } from '@/types';
6
7interface RecentActivityProps {
8 initialTasks: Task[];
9}
10
11export function RecentActivity({ initialTasks }: RecentActivityProps) {
12 const [tasks, setTasks] = useState(initialTasks);
13 const [searchQuery, setSearchQuery] = useState('');
14 const [isPending, startTransition] = useTransition();
15
16 // Deferred value for search
17 const deferredSearchQuery = useDeferredValue(searchQuery);
18
19 // Filter tasks using deferredValue
20 const filteredTasks = tasks.filter(task =>
21 task.title.toLowerCase().includes(deferredSearchQuery.toLowerCase()) ||
22 task.description.toLowerCase().includes(deferredSearchQuery.toLowerCase())
23 );
24
25 const handleSearch = (query: string) => {
26 setSearchQuery(query);
27
28 // Use transition for less priority updates
29 startTransition(() => {
30 // Additional operations that can be interrupted
31 updateSearchAnalytics(query);
32 });
33 };
34
35 const refreshTasks = async () => {
36 startTransition(async () => {
37 const newTasks = await fetchRecentTasks();
38 setTasks(newTasks);
39 });
40 };
41
42 return (
43 <div className="recent-activity">
44 <div className="activity-header">
45 <h2>Recent Activity</h2>
46 <button
47 onClick={refreshTasks}
48 disabled={isPending}
49 className={isPending ? 'loading' : ''}
50 >
51 {isPending ? 'Refreshing...' : 'Refresh'}
52 </button>
53 </div>
54
55 <SearchInput
56 value={searchQuery}
57 onChange={handleSearch}
58 isPending={isPending}
59 />
60
61 <div className="tasks-list">
62 {filteredTasks.map(task => (
63 <TaskCard
64 key={task.id}
65 task={task}
66 onUpdate={setTasks}
67 />
68 ))}
69
70 {deferredSearchQuery && filteredTasks.length === 0 && (
71 <div className="no-results">
72 No results for "{deferredSearchQuery}"
73 </div>
74 )}
75 </div>
76 </div>
77 );
78}The list filters by deferredSearchQuery, so the search field does not slow down. Two notes: startTransition in handleSearch does not interrupt the updateSearchAnalytics call itself, because only renders caused by state changes can be interrupted. And setTasks after await should, according to react.dev, go into another startTransition.
Step 5: Advanced forms with Server Actions
The form combines three hooks. useActionState holds the action's result, useOptimistic shows the save immediately, and useFormStatus tells the button about the submission. Important: useFormStatus is imported from react-dom, not from react. First, the logic:
1// components/forms/TaskForm.tsx (Client Component)
2'use client';
3
4import { useActionState, useEffect, useId, useOptimistic } from 'react';
5import { useFormStatus } from 'react-dom';
6import { Task } from '@/types';
7import { createTask, updateTask } from '@/lib/actions';
8
9interface TaskFormProps {
10 task?: Task;
11 onSuccess?: () => void;
12}
13
14export function TaskForm({ task, onSuccess }: TaskFormProps) {
15 const formId = useId();
16 const titleId = useId();
17 const descriptionId = useId();
18
19 // useActionState for action state management
20 const [state, formAction] = useActionState(
21 task ? updateTask.bind(null, task.id) : createTask,
22 {
23 success: false,
24 errors: {},
25 data: task || null
26 }
27 );
28
29 // useOptimistic for instant feedback
30 const [optimisticState, addOptimistic] = useOptimistic(
31 state,
32 (currentState, optimisticUpdate) => ({
33 ...currentState,
34 ...optimisticUpdate,
35 optimistic: true
36 })
37 );
38
39 const handleSubmit = async (formData: FormData) => {
40 // Optimistic update
41 addOptimistic({
42 success: true,
43 data: {
44 id: task?.id || 'temp-' + Date.now(),
45 title: formData.get('title') as string,
46 description: formData.get('description') as string,
47 status: formData.get('status') as Task['status'],
48 priority: formData.get('priority') as Task['priority'],
49 createdAt: new Date(),
50 updatedAt: new Date()
51 }
52 });
53
54 // Execute actual action
55 formAction(formData);
56 };
57
58 // The new state arrives in the next render, so we react to it in an effect
59 useEffect(() => {
60 if (state.success) {
61 onSuccess?.();
62 }
63 }, [state, onSuccess]);
64The formAction function does not return a Promise, and state in this closure is still the old state. That is why we react to success in an effect, once the new state arrives in the next render. Now the form view:
1 return (
2 <form
3 id={formId}
4 action={handleSubmit}
5 className="task-form"
6 >
7 <div className="form-group">
8 <label htmlFor={titleId}>Task title</label>
9 <input
10 id={titleId}
11 name="title"
12 type="text"
13 required
14 defaultValue={optimisticState.data?.title || ''}
15 className={optimisticState.errors?.title ? 'error' : ''}
16 />
17 {optimisticState.errors?.title && (
18 <span className="error-message" role="alert">
19 {optimisticState.errors.title}
20 </span>
21 )}
22 </div>
23
24 <div className="form-group">
25 <label htmlFor={descriptionId}>Description</label>
26 <textarea
27 id={descriptionId}
28 name="description"
29 rows={4}
30 defaultValue={optimisticState.data?.description || ''}
31 />
32 </div>
33
34 <div className="form-group">
35 <label>Status</label>
36 <select name="status" defaultValue={optimisticState.data?.status || 'pending'}>
37 <option value="pending">Pending</option>
38 <option value="in_progress">In Progress</option>
39 <option value="completed">Completed</option>
40 </select>
41 </div>
42
43 <div className="form-group">
44 <label>Priority</label>
45 <select name="priority" defaultValue={optimisticState.data?.priority || 'medium'}>
46 <option value="low">Low</option>
47 <option value="medium">Medium</option>
48 <option value="high">High</option>
49 </select>
50 </div>
51
52 <FormSubmitButton />
53
54 {optimisticState.optimistic && (
55 <div className="optimistic-indicator">
56 Saving...
57 </div>
58 )}
59
60 {optimisticState.success && !optimisticState.optimistic && (
61 <div className="success-message">
62 Task has been {task ? 'updated' : 'created'}!
63 </div>
64 )}
65 </form>
66 );
67}
68
69// Component using useFormStatus
70function FormSubmitButton() {
71 const { pending } = useFormStatus();
72
73 return (
74 <button
75 type="submit"
76 disabled={pending}
77 className={pending ? 'loading' : ''}
78 >
79 {pending ? 'Saving...' : 'Save task'}
80 </button>
81 );
82}FormSubmitButton is a separate component because useFormStatus reads the status of the parent form, so it must be called inside a <form>.
Step 6: Server Actions
The 'use server' directive makes these functions run only on the server. We validate with the Zod library:
1// lib/actions.ts
2'use server';
3
4import { revalidatePath } from 'next/cache';
5import { redirect } from 'next/navigation';
6import { z } from 'zod';
7
8const TaskSchema = z.object({
9 title: z.string().min(1, 'Title is required').max(100, 'Title is too long'),
10 description: z.string().optional(),
11 status: z.enum(['pending', 'in_progress', 'completed']),
12 priority: z.enum(['low', 'medium', 'high']),
13 dueDate: z.string().optional().transform(str => str ? new Date(str) : undefined)
14});
15
16export async function createTask(prevState: any, formData: FormData) {
17 try {
18 const validatedFields = TaskSchema.safeParse({
19 title: formData.get('title'),
20 description: formData.get('description'),
21 status: formData.get('status'),
22 priority: formData.get('priority'),
23 dueDate: formData.get('dueDate')
24 });
25
26 if (!validatedFields.success) {
27 return {
28 success: false,
29 errors: z.flattenError(validatedFields.error).fieldErrors,
30 data: null
31 };
32 }
33
34 const task = await db.tasks.create({
35 data: {
36 ...validatedFields.data,
37 id: generateId(),
38 createdAt: new Date(),
39 updatedAt: new Date()
40 }
41 });
42
43 // Revalidate cache
44 revalidatePath('/dashboard');
45 revalidatePath('/tasks');
46
47 return {
48 success: true,
49 errors: {},
50 data: task
51 };
52
53 } catch (error) {
54 return {
55 success: false,
56 errors: { _form: ['An error occurred while creating the task'] },
57 data: null
58 };
59 }
60}
61safeParse does not throw, it returns an object with a success field. We flatten errors with z.flattenError, because in Zod 4 the error.flatten() method is marked as deprecated. The update action looks similar:
1export async function updateTask(taskId: string, prevState: any, formData: FormData) {
2 try {
3 const validatedFields = TaskSchema.safeParse({
4 title: formData.get('title'),
5 description: formData.get('description'),
6 status: formData.get('status'),
7 priority: formData.get('priority'),
8 dueDate: formData.get('dueDate')
9 });
10
11 if (!validatedFields.success) {
12 return {
13 success: false,
14 errors: z.flattenError(validatedFields.error).fieldErrors,
15 data: null
16 };
17 }
18
19 const task = await db.tasks.update({
20 where: { id: taskId },
21 data: {
22 ...validatedFields.data,
23 updatedAt: new Date()
24 }
25 });
26
27 revalidatePath(`/tasks/${taskId}`);
28 revalidatePath('/dashboard');
29
30 return {
31 success: true,
32 errors: {},
33 data: task
34 };
35
36 } catch (error) {
37 return {
38 success: false,
39 errors: { _form: ['An error occurred while updating the task'] },
40 data: null
41 };
42 }
43}updateTask receives taskId as its first argument thanks to bind in the form, and revalidatePath refreshes the pages' data.
Step 7: Advanced Suspense Patterns
Suspense boundaries can be nested, all the way down to a single card:
1// components/tasks/TasksList.tsx (Server Component)
2import { Suspense } from 'react';
3import { TaskCard } from './TaskCard';
4import { TasksFilter } from './TasksFilter';
5
6async function TasksList({ searchParams }: { searchParams: any }) {
7 return (
8 <div className="tasks-page">
9 <div className="tasks-header">
10 <h1>Tasks</h1>
11
12 {/* Filter doesn't require Suspense - loads quickly */}
13 <TasksFilter />
14 </div>
15
16 <div className="tasks-content">
17 {/* Main task list */}
18 <Suspense fallback={<TasksListSkeleton />}>
19 <TasksGrid searchParams={searchParams} />
20 </Suspense>
21
22 {/* Sidebar with additional info */}
23 <aside className="tasks-sidebar">
24 <Suspense fallback={<StatsSkeleton />}>
25 <TasksStatistics />
26 </Suspense>
27
28 <Suspense fallback={<ActivitySkeleton />}>
29 <RecentActivity />
30 </Suspense>
31 </aside>
32 </div>
33 </div>
34 );
35}
36
37// Component with long load time
38async function TasksGrid({ searchParams }: { searchParams: any }) {
39 // Simulate a long query
40 const tasks = await getFilteredTasks(searchParams);
41
42 return (
43 <div className="tasks-grid">
44 {tasks.map(task => (
45 <Suspense
46 key={task.id}
47 fallback={<TaskCardSkeleton />}
48 >
49 <TaskCard task={task} />
50 </Suspense>
51 ))}
52 </div>
53 );
54}Since Next.js 15, searchParams on a page is a Promise, so you have to read it with await before using it.
Step 8: Accessibility with useId
A universal form field injects identifiers into its child:
1// components/ui/FormField.tsx
2'use client';
3
4import { useId, forwardRef, cloneElement } from 'react';
5
6interface FormFieldProps {
7 label: string;
8 error?: string;
9 help?: string;
10 required?: boolean;
11 children: React.ReactNode;
12}
13
14export const FormField = forwardRef<HTMLDivElement, FormFieldProps>(
15 ({ label, error, help, required, children }, ref) => {
16 const fieldId = useId();
17 const errorId = useId();
18 const helpId = useId();
19
20 return (
21 <div ref={ref} className="form-field">
22 <label
23 htmlFor={fieldId}
24 className={required ? 'required' : ''}
25 >
26 {label}
27 {required && <span aria-hidden="true">*</span>}
28 </label>
29
30 <div className="field-input">
31 {cloneElement(children as React.ReactElement, {
32 id: fieldId,
33 'aria-describedby': [
34 help ? helpId : null,
35 error ? errorId : null
36 ].filter(Boolean).join(' ') || undefined,
37 'aria-invalid': error ? 'true' : undefined,
38 required
39 })}
40 </div>
41
42 {help && (
43 <div id={helpId} className="field-help">
44 {help}
45 </div>
46 )}
47
48 {error && (
49 <div
50 id={errorId}
51 className="field-error"
52 role="alert"
53 aria-live="polite"
54 >
55 {error}
56 </div>
57 )}
58 </div>
59 );
60 }
61);
62
63FormField.displayName = 'FormField';In React 19, ref is a regular prop of a function component, and forwardRef is planned to be deprecated in the future, so in new code you can accept ref directly.
Step 9: Performance Monitoring
Finally, a context that collects performance metrics:
1// components/providers/PerformanceProvider.tsx
2'use client';
3
4import { createContext, useContext, useEffect, useState } from 'react';
5
6interface PerformanceMetrics {
7 renderTime: number;
8 interactionTime: number;
9 suspenseTime: number;
10}
11
12const PerformanceContext = createContext<{
13 metrics: PerformanceMetrics;
14 recordMetric: (type: keyof PerformanceMetrics, time: number) => void;
15}>({
16 metrics: { renderTime: 0, interactionTime: 0, suspenseTime: 0 },
17 recordMetric: () => {}
18});
19
20export function PerformanceProvider({ children }: { children: React.ReactNode }) {
21 const [metrics, setMetrics] = useState<PerformanceMetrics>({
22 renderTime: 0,
23 interactionTime: 0,
24 suspenseTime: 0
25 });
26
27 const recordMetric = (type: keyof PerformanceMetrics, time: number) => {
28 setMetrics(prev => ({
29 ...prev,
30 [type]: time
31 }));
32
33 // Send metrics to analytics system
34 if (time > getThreshold(type)) {
35 analytics.track('performance_issue', {
36 type,
37 time,
38 url: window.location.pathname
39 });
40 }
41 };
42
43 useEffect(() => {
44 // Observe Web Vitals
45 const observer = new PerformanceObserver((list) => {
46 for (const entry of list.getEntries()) {
47 if (entry.entryType === 'measure') {
48 recordMetric('renderTime', entry.duration);
49 }
50 }
51 });
52
53 observer.observe({ entryTypes: ['measure'] });
54
55 return () => observer.disconnect();
56 }, []);
57
58 return (
59 <PerformanceContext.Provider value={{ metrics, recordMetric }}>
60 {children}
61 </PerformanceContext.Provider>
62 );
63}
64
65export const usePerformance = () => useContext(PerformanceContext);
66
67function getThreshold(type: keyof PerformanceMetrics): number {
68 const thresholds = {
69 renderTime: 100, // 100ms
70 interactionTime: 50, // 50ms
71 suspenseTime: 1000 // 1s
72 };
73 return thresholds[type];
74}PerformanceObserver listens here for measure entries, meaning your own measurements, not Web Vitals. For Core Web Vitals use the web-vitals library.
Project Summary
This project demonstrates:
- Concurrent Features: useTransition, useDeferredValue for responsive UI
- Form Handling: useActionState, useFormStatus with Server Actions
- Accessibility: useId for unique identifiers
- Optimistic Updates: useOptimistic for instant feedback
- Server Components: Server-side rendering with parallel data fetching
- Suspense Patterns: Hierarchical loading with streaming
- Performance: Monitoring and performance optimization
- Error Handling: Granular Error Boundaries
- Type Safety: Full TypeScript utilization
Next steps for extension:
- Add real-time synchronization with WebSockets
- Implement offline support with Service Workers
- Add advanced filters and sorting
- Create a user permissions system
- Integrate with push notification system
- Add data export to various formats
- Implement dark mode with CSS-in-JS
- Create a mobile app with React Native
This project is a solid foundation for building modern React applications utilizing the latest framework features.
My advice: build step by step and run the application after every step. Remember: a good mission is one where every system on the ship knows its job, and mission control sees all of them at once.
Code for this lesson: App.jsx
1import React, {
2 useState, useMemo, useCallback, memo,
3 useTransition, useDeferredValue, Suspense, lazy, createContext, use,
4} from 'react';
5
6// ========================
7// APPLICATION CONTEXTS
8// ========================
9const ThemeContext = createContext('dark');
10
11// ========================
12// SIMULATED DATA
13// ========================
14function generateCrewMembers(count) {
15 const roles = ['Pilot', 'Navigator', 'Engineer', 'Medic', 'Scientist', 'Technician'];
16 const statuses = ['Active', 'On mission', 'Resting', 'Training'];
17 const members = [];
18 for (let i = 0; i < count; i++) {
19 members.push({
20 id: i + 1,
21 name: `Astronaut-${(i + 1).toString().padStart(3, '0')}`,
22 role: roles[i % roles.length],
23 status: statuses[i % statuses.length],
24 missions: Math.floor(Math.random() * 50),
25 rating: (3 + Math.random() * 2).toFixed(1),
26 });
27 }
28 return members;
29}
30
31const ALL_CREW = generateCrewMembers(200);
32
33// ========================
34// OPTIMIZED COMPONENTS
35// ========================
36
37// React.memo - prevents unnecessary re-renders
38const StatCard = memo(function StatCard({ label, value, color, icon }) {
39 return (
40 <div style={{
41 padding: '16px', borderRadius: '10px',
42 background: `${color}11`, border: `1px solid ${color}33`,
43 textAlign: 'center', minWidth: '120px',
44 }}>
45 <div style={{ fontSize: '24px', marginBottom: '4px' }}>{icon}</div>
46 <div style={{ fontSize: '22px', fontWeight: 'bold', color }}>{value}</div>
47 <div style={{ fontSize: '12px', color: '#778da9', marginTop: '4px' }}>{label}</div>
48 </div>
49 );
50});
51
52// Memoized table row
53const CrewRow = memo(function CrewRow({ member, onSelect }) {
54 const statusColor = {
55 'Active': '#4ecdc4',
56 'On mission': '#ffd700',
57 'Resting': '#778da9',
58 'Training': '#00d4ff',
59 }[member.status] || '#778da9';
60
61 return (
62 <div
63 onClick={() => onSelect(member)}
64 style={{
65 display: 'grid', gridTemplateColumns: '1fr 100px 90px 60px 50px',
66 padding: '10px 14px', fontSize: '13px',
67 borderBottom: '1px solid #1a2a3c', cursor: 'pointer',
68 transition: 'background 0.15s',
69 }}
70 onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,212,255,0.05)'}
71 onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
72 >
73 <span style={{ color: '#e0e1dd' }}>{member.name}</span>
74 <span style={{ color: '#778da9' }}>{member.role}</span>
75 <span style={{ color: statusColor }}>{member.status}</span>
76 <span style={{ color: '#c0c8d0', textAlign: 'center' }}>{member.missions}</span>
77 <span style={{ color: '#ffd700', textAlign: 'center' }}>{member.rating}</span>
78 </div>
79 );
80});
81
82// TODO 1: Wrap DetailPanel component in React.memo
83// It should render only when selectedMember changes
84function DetailPanel({ selectedMember }) {
85 if (!selectedMember) {
86 return (
87 <div style={{
88 padding: '40px', textAlign: 'center', color: '#556677',
89 background: 'rgba(255,255,255,0.02)', borderRadius: '10px',
90 }}>
91 Select a crew member to see details
92 </div>
93 );
94 }
95 return (
96 <div style={{
97 padding: '20px', background: 'rgba(0, 212, 255, 0.04)',
98 borderRadius: '10px', border: '1px solid #1a3a5c',
99 }}>
100 <h3 style={{ color: '#00d4ff', margin: '0 0 12px' }}>{selectedMember.name}</h3>
101 <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px', fontSize: '13px' }}>
102 <div><span style={{ color: '#778da9' }}>Role:</span> <span style={{ color: '#e0e1dd' }}>{selectedMember.role}</span></div>
103 <div><span style={{ color: '#778da9' }}>Status:</span> <span style={{ color: '#4ecdc4' }}>{selectedMember.status}</span></div>
104 <div><span style={{ color: '#778da9' }}>Missions:</span> <span style={{ color: '#e0e1dd' }}>{selectedMember.missions}</span></div>
105 <div><span style={{ color: '#778da9' }}>Rating:</span> <span style={{ color: '#ffd700' }}>{selectedMember.rating}/5.0</span></div>
106 </div>
107 </div>
108 );
109}
110
111// ========================
112// MAIN DASHBOARD
113// ========================
114export default function App() {
115 const [searchQuery, setSearchQuery] = useState('');
116 const [statusFilter, setStatusFilter] = useState('all');
117 const [selectedMember, setSelectedMember] = useState(null);
118 const [isPending, startTransition] = useTransition();
119
120 // useDeferredValue - deferred search
121 const deferredSearch = useDeferredValue(searchQuery);
122
123 // useMemo - memoizing the filtered list
124 const filteredCrew = useMemo(() => {
125 return ALL_CREW.filter(member => {
126 const matchesSearch = member.name.toLowerCase().includes(deferredSearch.toLowerCase()) ||
127 member.role.toLowerCase().includes(deferredSearch.toLowerCase());
128 const matchesStatus = statusFilter === 'all' || member.status === statusFilter;
129 return matchesSearch && matchesStatus;
130 });
131 }, [deferredSearch, statusFilter]);
132
133 // useMemo - calculating statistics
134 const stats = useMemo(() => ({
135 total: ALL_CREW.length,
136 active: ALL_CREW.filter(m => m.status === 'Active').length,
137 onMission: ALL_CREW.filter(m => m.status === 'On mission').length,
138 avgRating: (ALL_CREW.reduce((sum, m) => sum + parseFloat(m.rating), 0) / ALL_CREW.length).toFixed(1),
139 }), []);
140
141 // useCallback - stable function reference for memoized components
142 const handleSelect = useCallback((member) => {
143 setSelectedMember(member);
144 }, []);
145
146 // TODO 2: Use useCallback for handleStatusChange
147 // to prevent re-renders of the filtering components
148 const handleStatusChange = (status) => {
149 startTransition(() => {
150 setStatusFilter(status);
151 });
152 };
153
154 // TODO 3: Use useMemo to compute topPerformers
155 // (top 5 members by rating from the filtered list)
156 // sort changes the array in place, so we sort a copy: otherwise the table would also be ordered by rating
157 const topPerformers = [...filteredCrew]
158 .sort((a, b) => parseFloat(b.rating) - parseFloat(a.rating))
159 .slice(0, 5);
160
161 const STATUSES = ['all', 'Active', 'On mission', 'Resting', 'Training'];
162
163 return (
164 <div style={{
165 minHeight: '100vh',
166 background: 'linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%)',
167 padding: '20px', fontFamily: 'monospace', color: '#e0e1dd',
168 }}>
169 <h1 style={{ color: '#00d4ff', textAlign: 'center', margin: '0 0 4px' }}>
170 Fleet Command Center
171 </h1>
172 <p style={{ color: '#778da9', textAlign: 'center', marginBottom: '20px', fontSize: '13px' }}>
173 Dashboard with React.memo | useMemo | useCallback | useTransition | useDeferredValue
174 </p>
175
176 {/* Statistics */}
177 <div style={{
178 display: 'flex', gap: '12px', justifyContent: 'center',
179 marginBottom: '20px', flexWrap: 'wrap',
180 }}>
181 <StatCard icon="β" label="Total crew" value={stats.total} color="#00d4ff" />
182 <StatCard icon="OK" label="Active" value={stats.active} color="#4ecdc4" />
183 <StatCard icon="β²" label="On mission" value={stats.onMission} color="#ffd700" />
184 <StatCard icon="β" label="Average rating" value={stats.avgRating} color="#ff6b4a" />
185 </div>
186
187 <div style={{ maxWidth: '800px', margin: '0 auto' }}>
188 {/* Search and filters */}
189 <div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
190 <input
191 value={searchQuery}
192 onChange={e => setSearchQuery(e.target.value)}
193 placeholder="Search crew member..."
194 style={{
195 flex: 1, minWidth: '200px', padding: '10px 14px',
196 background: '#0d1b2a', border: '1px solid #1a3a5c',
197 borderRadius: '8px', color: '#e0e1dd', fontSize: '14px',
198 }}
199 />
200 {STATUSES.map(status => (
201 <button
202 key={status}
203 onClick={() => handleStatusChange(status)}
204 style={{
205 padding: '8px 14px',
206 background: statusFilter === status ? '#1a3a5c' : 'transparent',
207 border: `1px solid ${statusFilter === status ? '#00d4ff' : '#1a3a5c'}`,
208 borderRadius: '6px', fontSize: '12px',
209 color: statusFilter === status ? '#00d4ff' : '#778da9',
210 cursor: 'pointer',
211 }}
212 >
213 {status === 'all' ? 'All' : status}
214 </button>
215 ))}
216 </div>
217
218 {isPending && (
219 <div style={{ color: '#00d4ff', fontSize: '12px', marginBottom: '8px' }}>
220 Filtering...
221 </div>
222 )}
223
224 <div style={{ display: 'grid', gridTemplateColumns: '1fr 280px', gap: '16px' }}>
225 {/* Crew table */}
226 <div>
227 <div style={{
228 display: 'grid', gridTemplateColumns: '1fr 100px 90px 60px 50px',
229 padding: '8px 14px', fontSize: '11px', color: '#556677',
230 borderBottom: '2px solid #1a3a5c', textTransform: 'uppercase',
231 }}>
232 <span>Name</span><span>Role</span><span>Status</span>
233 <span style={{ textAlign: 'center' }}>Missions</span>
234 <span style={{ textAlign: 'center' }}>Rating</span>
235 </div>
236 <div style={{ maxHeight: '350px', overflow: 'auto' }}>
237 {filteredCrew.slice(0, 50).map(member => (
238 <CrewRow
239 key={member.id}
240 member={member}
241 onSelect={handleSelect}
242 />
243 ))}
244 </div>
245 <div style={{ padding: '8px 14px', fontSize: '12px', color: '#556677' }}>
246 Showing {Math.min(50, filteredCrew.length)} of {filteredCrew.length} results
247 </div>
248 </div>
249
250 {/* Side panel */}
251 <div>
252 <DetailPanel selectedMember={selectedMember} />
253
254 <div style={{ marginTop: '16px' }}>
255 <h4 style={{ color: '#ffd700', margin: '0 0 8px', fontSize: '13px' }}>
256 Top 5 - highest rating
257 </h4>
258 {topPerformers.map((m, i) => (
259 <div key={m.id} style={{
260 display: 'flex', justifyContent: 'space-between',
261 padding: '6px 10px', fontSize: '12px',
262 background: i === 0 ? 'rgba(255, 215, 0, 0.06)' : 'transparent',
263 borderRadius: '4px',
264 }}>
265 <span style={{ color: '#c0c8d0' }}>
266 {i + 1}. {m.name}
267 </span>
268 <span style={{ color: '#ffd700' }}>{m.rating}</span>
269 </div>
270 ))}
271 </div>
272 </div>
273 </div>
274 </div>
275 </div>
276 );
277}Spotted a mistake in this lesson?