React.js Fundamentals
Building a Complete React Project
Project Overview: Task Manager Application
In this lesson, we'll build a complete, production-ready Task Manager application that demonstrates all the concepts we've learned. This app will include CRUD operations, routing, state management, API integration, authentication, and modern React patterns.
Features We'll Build
Application Features:
- User authentication (login/register/logout)
- Create, read, update, delete tasks
- Filter tasks by status (all, active, completed)
- Search tasks by title
- Mark tasks as complete/incomplete
- Persistent data with API
- Protected routes
- Loading states and error handling
- Responsive design
Project Structure
task-manager/
├── public/
├── src/
│ ├── components/
│ │ ├── common/
│ │ │ ├── Button.jsx
│ │ │ ├── Input.jsx
│ │ │ └── Spinner.jsx
│ │ ├── layout/
│ │ │ ├── Header.jsx
│ │ │ └── Layout.jsx
│ │ └── tasks/
│ │ ├── TaskList.jsx
│ │ ├── TaskItem.jsx
│ │ ├── TaskForm.jsx
│ │ └── TaskFilters.jsx
│ ├── features/
│ │ └── auth/
│ │ ├── Login.jsx
│ │ ├── Register.jsx
│ │ └── useAuth.js
│ ├── hooks/
│ │ ├── useLocalStorage.js
│ │ └── useTasks.js
│ ├── services/
│ │ ├── api.js
│ │ └── taskService.js
│ ├── context/
│ │ ├── AuthContext.jsx
│ │ └── TaskContext.jsx
│ ├── routes/
│ │ ├── ProtectedRoute.jsx
│ │ └── index.jsx
│ ├── App.jsx
│ └── index.jsx
└── package.json
Step 1: Setting Up the Project
# Create project with Vite
npm create vite@latest task-manager -- --template react
cd task-manager
npm install
# Install dependencies
npm install react-router-dom axios
npm install -D tailwindcss postcss autoprefixer
# Initialize Tailwind
npx tailwindcss init -p
Step 2: API Service Setup
// src/services/api.js
import axios from 'axios';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add auth token to requests
api.interceptors.request.use((config) => {
const token = localStorage.getItem('authToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle token expiration
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('authToken');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default api;
// src/services/taskService.js
import api from './api';
export const taskService = {
// Get all tasks
getAllTasks: async () => {
const response = await api.get('/tasks');
return response.data;
},
// Get task by ID
getTaskById: async (id) => {
const response = await api.get(`/tasks/${id}`);
return response.data;
},
// Create new task
createTask: async (taskData) => {
const response = await api.post('/tasks', taskData);
return response.data;
},
// Update task
updateTask: async (id, taskData) => {
const response = await api.put(`/tasks/${id}`, taskData);
return response.data;
},
// Delete task
deleteTask: async (id) => {
const response = await api.delete(`/tasks/${id}`);
return response.data;
},
// Toggle task completion
toggleTask: async (id) => {
const response = await api.patch(`/tasks/${id}/toggle`);
return response.data;
},
};
Step 3: Authentication Context
// src/context/AuthContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';
import api from '../services/api';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check if user is logged in
const token = localStorage.getItem('authToken');
if (token) {
fetchUser();
} else {
setLoading(false);
}
}, []);
const fetchUser = async () => {
try {
const response = await api.get('/auth/me');
setUser(response.data);
} catch (error) {
localStorage.removeItem('authToken');
} finally {
setLoading(false);
}
};
const login = async (email, password) => {
const response = await api.post('/auth/login', { email, password });
const { token, user } = response.data;
localStorage.setItem('authToken', token);
setUser(user);
return user;
};
const register = async (name, email, password) => {
const response = await api.post('/auth/register', {
name,
email,
password,
});
const { token, user } = response.data;
localStorage.setItem('authToken', token);
setUser(user);
return user;
};
const logout = () => {
localStorage.removeItem('authToken');
setUser(null);
};
const value = {
user,
loading,
login,
register,
logout,
isAuthenticated: !!user,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};
Step 4: Task Context with CRUD Operations
// src/context/TaskContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';
import { taskService } from '../services/taskService';
import { useAuth } from './AuthContext';
const TaskContext = createContext(null);
export function TaskProvider({ children }) {
const { isAuthenticated } = useAuth();
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [filter, setFilter] = useState('all'); // all, active, completed
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
if (isAuthenticated) {
loadTasks();
}
}, [isAuthenticated]);
const loadTasks = async () => {
setLoading(true);
setError(null);
try {
const data = await taskService.getAllTasks();
setTasks(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const addTask = async (taskData) => {
try {
const newTask = await taskService.createTask(taskData);
setTasks((prev) => [newTask, ...prev]);
return newTask;
} catch (err) {
setError(err.message);
throw err;
}
};
const updateTask = async (id, taskData) => {
try {
const updated = await taskService.updateTask(id, taskData);
setTasks((prev) => prev.map((task) => (task.id === id ? updated : task)));
return updated;
} catch (err) {
setError(err.message);
throw err;
}
};
const deleteTask = async (id) => {
try {
await taskService.deleteTask(id);
setTasks((prev) => prev.filter((task) => task.id !== id));
} catch (err) {
setError(err.message);
throw err;
}
};
const toggleTask = async (id) => {
try {
const updated = await taskService.toggleTask(id);
setTasks((prev) => prev.map((task) => (task.id === id ? updated : task)));
} catch (err) {
setError(err.message);
throw err;
}
};
// Filtered tasks based on filter and search
const filteredTasks = tasks
.filter((task) => {
if (filter === 'active') return !task.completed;
if (filter === 'completed') return task.completed;
return true;
})
.filter((task) =>
task.title.toLowerCase().includes(searchQuery.toLowerCase())
);
const value = {
tasks: filteredTasks,
loading,
error,
filter,
setFilter,
searchQuery,
setSearchQuery,
addTask,
updateTask,
deleteTask,
toggleTask,
refreshTasks: loadTasks,
};
return <TaskContext.Provider value={value}>{children}</TaskContext.Provider>;
}
export const useTasks = () => {
const context = useContext(TaskContext);
if (!context) {
throw new Error('useTasks must be used within TaskProvider');
}
return context;
};
Step 5: Protected Route Component
// src/routes/ProtectedRoute.jsx
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import Spinner from '../components/common/Spinner';
export default function ProtectedRoute({ children }) {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<Spinner />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
}
Step 6: Task Components
// src/components/tasks/TaskForm.jsx
import { useState } from 'react';
import { useTasks } from '../../context/TaskContext';
import Button from '../common/Button';
import Input from '../common/Input';
export default function TaskForm() {
const { addTask } = useTasks();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (!title.trim()) return;
setLoading(true);
try {
await addTask({
title: title.trim(),
description: description.trim(),
completed: false,
});
setTitle('');
setDescription('');
} catch (error) {
console.error('Failed to add task:', error);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4 mb-6">
<Input
type="text"
placeholder="Task title..."
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={loading}
/>
<textarea
className="w-full px-4 py-2 border rounded-lg"
placeholder="Description (optional)..."
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={loading}
rows={3}
/>
<Button type="submit" disabled={loading || !title.trim()}>
{loading ? 'Adding...' : 'Add Task'}
</Button>
</form>
);
}
// src/components/tasks/TaskItem.jsx
import { useState } from 'react';
import { useTasks } from '../../context/TaskContext';
import Button from '../common/Button';
export default function TaskItem({ task }) {
const { updateTask, deleteTask, toggleTask } = useTasks();
const [isEditing, setIsEditing] = useState(false);
const [title, setTitle] = useState(task.title);
const handleUpdate = async () => {
if (title.trim() && title !== task.title) {
await updateTask(task.id, { ...task, title: title.trim() });
}
setIsEditing(false);
};
const handleDelete = async () => {
if (confirm('Are you sure you want to delete this task?')) {
await deleteTask(task.id);
}
};
return (
<div className="flex items-center gap-3 p-4 bg-white rounded-lg shadow">
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleTask(task.id)}
className="w-5 h-5"
/>
{isEditing ? (
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={handleUpdate}
onKeyPress={(e) => e.key === 'Enter' && handleUpdate()}
className="flex-1 px-2 py-1 border rounded"
autoFocus
/>
) : (
<div className="flex-1">
<h3
className={`font-medium ${
task.completed ? 'line-through text-gray-500' : ''
}`}
>
{task.title}
</h3>
{task.description && (
<p className="text-sm text-gray-600">{task.description}</p>
)}
</div>
)}
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setIsEditing(!isEditing)}>
{isEditing ? 'Cancel' : 'Edit'}
</Button>
<Button variant="danger" onClick={handleDelete}>
Delete
</Button>
</div>
</div>
);
}
Step 7: Routing Configuration
// src/App.jsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import { TaskProvider } from './context/TaskContext';
import ProtectedRoute from './routes/ProtectedRoute';
import Layout from './components/layout/Layout';
import Login from './features/auth/Login';
import Register from './features/auth/Register';
import Dashboard from './pages/Dashboard';
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<TaskProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Layout>
<Dashboard />
</Layout>
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</TaskProvider>
</AuthProvider>
</BrowserRouter>
);
}
Architecture Benefits:
- Separation of Concerns: Context for state, services for API, components for UI
- Reusability: Common components used throughout
- Scalability: Easy to add new features
- Maintainability: Clear structure, easy to find code
- Testability: Each layer can be tested independently
Exercise 1: Extend the task manager with additional features:
- Add due dates to tasks
- Implement task priorities (low, medium, high)
- Add task categories/tags
- Implement sorting (by date, priority, alphabetical)
- Add pagination for large task lists
Exercise 2: Improve user experience:
- Add optimistic updates (update UI before API response)
- Implement undo functionality for deletions
- Add toast notifications for success/error messages
- Implement drag-and-drop for task reordering
- Add keyboard shortcuts (e.g., Ctrl+N for new task)
Exercise 3: Add testing and deployment:
- Write unit tests for context providers
- Write integration tests for task CRUD operations
- Set up error boundary for graceful error handling
- Configure environment variables for dev/staging/prod
- Deploy to Vercel or Netlify