-
Notifications
You must be signed in to change notification settings - Fork 7
Lab 4: Task Tracker
By the end of this lab, students will be able to:
- Create and structure React components using JSX
- Understand the difference between functional and class components
- Implement proper component file structure and naming conventions
- Style components using CSS classes and inline styles
- Initialize and update component state using the useState hook
- Create and manage state in class components using setState
- Implement state lifting to share data between components
- Distinguish between and implement controlled vs. uncontrolled components
- Apply proper state management patterns for form handling
- Attach and handle user events in React components
- Work with React's synthetic event system
- Properly bind event handlers in class components
- Implement event delegation patterns
- Pass event handlers as props between components
In this lab, we'll build an interactive task tracking application with React. This project will teach you essential React concepts while creating a practical tool that could be used in real-world scenarios. You'll learn how to manage state, handle user events, and create reusable components.
If some concepts seem challenging at first, don't worry! Learning React is a journey, and this lab is designed to help you understand core concepts through practical application.
- Setup and Overview: Creating the project structure and understanding components
- Building the Basic Interface: Creating and styling components
- Adding Interactivity: Implementing state and event handling
- Advanced Features: Adding persistence and filtering capabilities
- Basic understanding of HTML and JavaScript
- Familiarity with functions, arrays, and objects
- Node.js installed on your computer
- Create a new React project:
npx create-react-app task-tracker
cd task-tracker
- Clean up the initial files:
cd src
# For Mac/Linux:
rm -f *
# For Windows:
del *
- Create the following files in your src directory:
index.js
App.js
styles.css
- Copy this starter code for each file:
index.js:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';
import App from './App';
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<App />
</StrictMode>
);
App.js:
export default function TaskItem() {
return (
<div className="task-item">
<h3>Complete React Lab</h3>
<p>Priority: High</p>
</div>
);
}
styles.css:
.task-item {
background: #f4f4f4;
padding: 1rem;
margin: 0.5rem 0;
border-radius: 4px;
cursor: pointer;
}
.task-item h3 {
margin: 0;
color: #333;
}
.task-item p {
margin: 5px 0;
color: #666;
}
Let's examine the App.js
code in detail:
export default function TaskItem() { // 1
return ( // 2
<div className="task-item"> // 3
<h3>Complete React Lab</h3> // 4
<p>Priority: High</p> // 5
</div>
);
}
Line-by-line explanation:
- We define a functional component named
TaskItem
. Theexport default
makes this component available for import in other files. - Every React component must return JSX (or null).
- The outer
div
has aclassName
attribute - this is React's way of setting HTML classes. - The
h3
element contains our task title. - The
p
element contains our priority level.
Let's break down the CSS in styles.css
:
.task-item {
background: #f4f4f4; /* Light grey background */
padding: 1rem; /* 16px spacing inside the box */
margin: 0.5rem 0; /* 8px spacing above and below */
border-radius: 4px; /* Rounded corners */
cursor: pointer; /* Hand cursor on hover */
}
.task-item h3 {
margin: 0; /* Remove default heading margin */
color: #333; /* Dark grey text */
}
.task-item p {
margin: 5px 0; /* Small spacing above/below paragraph */
color: #666; /* Medium grey text */
}
This CSS creates:
- A card-like container for each task with a light grey background
- Consistent spacing using rem units (relative to root font size)
- A visual hierarchy with different text colors
- Interactive feel with the pointer cursor
- Clean typography with controlled margins
When creating React components:
- Use PascalCase for component names (e.g.,
TaskItem
) - Keep components focused on a single responsibility
- Use semantic HTML elements inside your JSX
- Maintain consistent spacing and indentation
- Use className instead of class for CSS classes
Now that you understand the component structure and styling, your first challenge is to create a TaskList
component that displays multiple tasks. You'll need to:
- Create a new component named
TaskList
- Create multiple task items within the component
- Use proper JSX structure to display them in a list
Here's how to get started:
- First, rename your current component:
export default function TaskList() {
// Your code will go here
}
- Think about how you'll structure multiple tasks. Remember:
- JSX requires a parent element
- You can use fragments (
<>...</>
) to avoid extra divs - Each task should maintain the same structure
- Forgetting to wrap multiple elements in a parent container
- Inconsistent use of className
- Missing the export default statement
- Incorrect component naming (must start with capital letter)
Try completing this challenge before moving on to Challenge 2!
In React, it's best practice to break down complex UIs into smaller, reusable components. This follows the Single Responsibility Principle - each component should do one thing well.
Currently, our TaskList
component is handling both:
- The overall list structure
- Individual task display
Let's separate these concerns by creating a dedicated TaskItem
component.
- First, create a new
TaskItem
component above yourTaskList
:
function TaskItem({ title, priority }) {
return (
<div className="task-item">
<h3>{title}</h3>
<p>Priority: {priority}</p>
</div>
);
}
Note the destructured props in the parameters. This is equivalent to:
function TaskItem(props) {
return (
<div className="task-item">
<h3>{props.title}</h3>
<p>Priority: {props.priority}</p>
</div>
);
}
- Update your
TaskList
component to useTaskItem
:
export default function TaskList() {
return (
<div className="task-list">
<TaskItem
title="Complete React Lab"
priority="High"
/>
<TaskItem
title="Study Components"
priority="Medium"
/>
<TaskItem
title="Practice Coding"
priority="High"
/>
</div>
);
}
Props (short for properties) are React's way of passing data from parent to child components. They:
- Are read-only
- Can be any JavaScript value (strings, numbers, objects, functions)
- Flow one way (parent to child)
- Trying to modify props directly (they're read-only)
- Forgetting curly braces for non-string props:
// Incorrect:
<TaskItem number=1 />
// Correct:
<TaskItem number={1} />
- Inconsistent prop naming conventions
Now that we have our component structure, let's make it dynamic with state management. We'll start with the useState
hook.
- First, import useState:
import { useState } from 'react';
(And yes, you really need to import it! If you hear your components screaming "useState is not defined" in the console, this is probably why. We've all spent at least 10 minutes debugging only to realize we forgot this line. Don't ask me how I know... 😅)
- Update
TaskList
to manage tasks as state:
export default function TaskList() {
const [tasks, setTasks] = useState([
{ id: 1, title: "Complete React Lab", priority: "High" },
{ id: 2, title: "Study Components", priority: "Medium" },
{ id: 3, title: "Practice Coding", priority: "High" }
]);
return (
<div className="task-list">
{tasks.map(task => (
<TaskItem
key={task.id}
title={task.title}
priority={task.priority}
/>
))}
</div>
);
}
-
useState
returns an array with two elements:- The current state value
- A function to update the state
- We use array destructuring to assign these to variables
- React re-renders the component when state changes
Now it's your turn! Create a form to add new tasks:
- Create a new component called
AddTask
- Use state to manage the form inputs
- Add a function in
TaskList
to add new tasks - Pass this function as a prop to
AddTask
Here's a starter template:
function AddTask({ onAdd }) {
const [title, setTitle] = useState('');
const [priority, setPriority] = useState('Medium');
// Your code here
}
For comparison, here's how we'd manage state in a class component:
import React, { Component } from 'react';
class TaskList extends Component {
state = {
tasks: [
{ id: 1, title: "Complete React Lab", priority: "High" },
{ id: 2, title: "Study Components", priority: "Medium" },
{ id: 3, title: "Practice Coding", priority: "High" }
]
};
addTask = (task) => {
this.setState(prevState => ({
tasks: [...prevState.tasks, { ...task, id: prevState.tasks.length + 1 }]
}));
};
render() {
return (
<div className="task-list">
{this.state.tasks.map(task => (
<TaskItem
key={task.id}
title={task.title}
priority={task.priority}
/>
))}
</div>
);
}
}
- Never modify state directly
- State updates may be asynchronous
- State updates are merged in class components
- Lift state up to the lowest common ancestor
- Use controlled components for forms
Add the ability to delete tasks:
- Add a delete button to
TaskItem
- Create a delete function in
TaskList
- Pass the delete function down to
TaskItem
- Update state when a task is deleted
Hint: Use filter() to create a new array without the deleted task.
React events are similar to HTML events (like clicking a button or typing in a form), but React improves them in several ways:
- Uses camelCase:
onClick
instead ofonclick
(capital C!) - Takes functions as handlers:
onClick={handleClick}
instead ofonclick="handleClick()"
- Provides consistent behavior across browsers through "synthetic events"
Think of it like this: if HTML events are like a basic TV remote, React events are like a universal remote that works the same way no matter what TV you have.
Let's break this down into small, understandable pieces:
- First, we update our task data structure to include completion status:
// In TaskList component
const [tasks, setTasks] = useState([
{
id: 1,
title: "Complete React Lab",
priority: "High",
completed: false // Added this new property
},
// ... other tasks
]);
- Add the toggle function with detailed comments:
// This function updates a task's completion status
function toggleTask(taskId) {
// setTasks is our state updater from useState
setTasks(tasks.map(task => {
// For each task, check if it's the one we want to toggle
if (task.id === taskId) {
// If it is, create a new object with all existing properties (...)
// but flip the completed status
return {
...task,
completed: !task.completed
};
}
// If it's not the task we're looking for, return it unchanged
return task;
}));
}
// Alternative shorter version using ternary operator
// (for more experienced developers)
function toggleTask(taskId) {
setTasks(tasks.map(task =>
task.id === taskId
? { ...task, completed: !task.completed }
: task
));
}
- Update TaskItem with detailed explanations:
function TaskItem({ title, priority, completed, onToggle }) {
// The className uses a template literal (backticks) to conditionally add classes
// If completed is true, the className will be "task-item completed"
// If completed is false, it will just be "task-item"
return (
<div
className={`task-item ${completed ? 'completed' : ''}`}
onClick={onToggle} // When clicked, run the onToggle function
>
<h3>{title}</h3>
<p>Priority: {priority}</p>
</div>
);
}
React wraps browser events in something called a "synthetic event". Think of it like a translator that makes sure events work the same way across all browsers.
Let's explore this with a more detailed example:
function TaskItem({ title, priority, completed, onToggle }) {
// This function will run when the task is clicked
const handleClick = (e) => {
// 'e' is the synthetic event object
// It's similar to the native browser event but works consistently
// across all browsers
// stopPropagation() prevents the click from affecting parent elements
// Think of it like putting up an umbrella to stop the rain from hitting
// things above you
e.stopPropagation();
// Let's look at what information the event gives us
console.log('Event type:', e.type); // Will show 'click'
// e.target is the element that triggered the event
// (might be a child element of our div)
console.log('Target:', e.target);
// e.currentTarget is always the element the event handler
// is attached to (our div)
console.log('Current target:', e.currentTarget);
// Finally, call the onToggle function passed from the parent
onToggle();
};
return (
<div
className={`task-item ${completed ? 'completed' : ''}`}
onClick={handleClick} // Attach our event handler
>
<h3>{title}</h3>
<p>Priority: {priority}</p>
</div>
);
}
Class components handle events a bit differently due to how JavaScript handles this
. Here's a detailed explanation:
class TaskItem extends Component {
constructor(props) {
super(props);
// Method 1: Binding in constructor
// This tells JavaScript to always run handleClick with the correct 'this'
// Think of it like writing your name on your lunch bag so it doesn't
// get mixed up with someone else's
this.handleClick = this.handleClick.bind(this);
}
// Method 1: Regular method (needs binding)
handleClick(e) {
e.stopPropagation();
this.props.onToggle();
}
// Method 2: Class field with arrow function
// This automatically binds 'this' - newer and often preferred
// No constructor binding needed!
handleDelete = (e) => {
e.stopPropagation();
this.props.onDelete();
};
render() {
// Destructuring props makes our code cleaner
const { title, priority, completed } = this.props;
return (
<div
className={`task-item ${completed ? 'completed' : ''}`}
onClick={this.handleClick} // Note: we use this.handleClick
>
<h3>{title}</h3>
<p>Priority: {priority}</p>
<button onClick={this.handleDelete}>Delete</button>
</div>
);
}
}
- The Event Handler is Undefined
// ❌ Wrong - function runs immediately
<button onClick={handleClick()}>Click me</button>
// ✅ Correct - function is passed as a reference
<button onClick={handleClick}>Click me</button>
- This is Undefined in Class Components
// ❌ Wrong - 'this' will be undefined
class MyComponent extends Component {
handleClick() {
this.setState(/*...*/); // Error!
}
}
// ✅ Correct - use arrow function or bind
class MyComponent extends Component {
handleClick = () => {
this.setState(/*...*/); // Works!
}
}
- Event Pooling
// ❌ Wrong - event might not be available later
handleClick = (e) => {
setTimeout(() => {
console.log(e.target.value); // Might not work
}, 100);
};
// ✅ Correct - save values you need
handleClick = (e) => {
const value = e.target.value;
setTimeout(() => {
console.log(value); // Works!
}, 100);
};
In React, there are two ways to handle form inputs. Think of it like the difference between:
- A teacher watching students write (controlled - React monitors every change)
- Collecting homework after it's done (uncontrolled - React only sees the final result)
Let's look at both approaches:
// Uncontrolled Component Example (Using a ref)
function UncontrolledTaskForm({ onSubmit }) {
// useRef creates a "box" to hold a value that persists between renders
// Think of it like a sticky note that doesn't trigger re-renders
const titleInputRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault(); // Stop the form from causing a page reload
// Get the current value from our "sticky note" (ref)
const title = titleInputRef.current.value;
onSubmit(title);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
ref={titleInputRef} // Attach our ref to the input
defaultValue="" // Initial value (optional)
/>
<button type="submit">Add Task</button>
</form>
);
}
// Controlled Component Example (Using state)
function ControlledTaskForm({ onSubmit }) {
// useState creates a "watched" value that triggers re-renders
const [title, setTitle] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(title);
setTitle(''); // Clear the input after submission
};
// Every keystroke updates our state
const handleChange = (e) => {
setTitle(e.target.value);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={title} // Control the input's value with state
onChange={handleChange} // Update state on every change
/>
<button type="submit">Add Task</button>
</form>
);
}
Let's create a form to add new tasks with multiple fields:
function AddTaskForm({ onAdd }) {
// Create state for each form field
const [formData, setFormData] = useState({
title: '',
priority: 'Medium', // Default value
dueDate: '', // Optional: for stretch goal
});
// Handle changes for all inputs
const handleChange = (e) => {
// Get the name and value from the input that changed
const { name, value } = e.target;
// Update our state, spreading the existing formData
// and updating just the field that changed
setFormData(prevData => ({
...prevData,
[name]: value // Use computed property name
}));
};
const handleSubmit = (e) => {
e.preventDefault(); // Prevent form submission
// Basic validation
if (!formData.title.trim()) { // Check if title is empty
alert('Please enter a task title');
return;
}
// Call the onAdd function passed from parent
onAdd(formData);
// Reset form
setFormData({
title: '',
priority: 'Medium',
dueDate: '',
});
};
return (
<form onSubmit={handleSubmit} className="add-task-form">
{/* Title Input */}
<div className="form-group">
<label htmlFor="title">
Task Title:
<span className="required">*</span>
</label>
<input
id="title"
name="title"
type="text"
value={formData.title}
onChange={handleChange}
placeholder="Enter task title"
/>
</div>
{/* Priority Select */}
<div className="form-group">
<label htmlFor="priority">Priority Level:</label>
<select
id="priority"
name="priority"
value={formData.priority}
onChange={handleChange}
>
<option value="Low">Low</option>
<option value="Medium">Medium</option>
<option value="High">High</option>
</select>
</div>
{/* Optional: Due Date Input */}
<div className="form-group">
<label htmlFor="dueDate">Due Date:</label>
<input
id="dueDate"
name="dueDate"
type="date"
value={formData.dueDate}
onChange={handleChange}
/>
</div>
<button type="submit">Add Task</button>
</form>
);
}
Add this CSS to style the form:
.add-task-form {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 400px;
margin: 1rem 0;
padding: 1rem;
background: #f8f8f8;
border-radius: 4px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: bold;
}
.required {
color: red;
margin-left: 4px;
}
input, select {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
button[type="submit"] {
padding: 0.5rem 1rem;
background: #0066cc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
button[type="submit"]:hover {
background: #0052a3;
}
- Form Validation
// Basic validation function
const validateForm = (formData) => {
const errors = {};
if (!formData.title.trim()) {
errors.title = 'Title is required';
}
if (formData.title.length > 50) {
errors.title = 'Title must be less than 50 characters';
}
return errors;
};
// Usage in form
const handleSubmit = (e) => {
e.preventDefault();
const [errors, setErrors] = validateForm(formData);
if (Object.keys(errors).length > 0) {
// Handle errors (set error state, show messages, etc.)
setErrors(errors);
return;
}
// Proceed with submission
onAdd(formData);
};
- Handling Multiple Input Types
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
// Handle different input types appropriately
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
Let's update our form to be a more comprehensive form for adding new tasks:
function AddTaskForm({ onAdd }) {
// Initialize all form fields in one state object
// This is more maintainable than separate useState for each field
const [formData, setFormData] = useState({
title: '', // Store the task title
priority: 'Medium', // Default priority value
dueDate: '', // Optional due date field
});
// Universal change handler for all form inputs
// This saves us from writing separate handlers for each input
const handleChange = (e) => {
// Destructure the event target properties we need
const { name, value } = e.target;
// Update state using the functional update pattern
// This ensures we're always working with the latest state
setFormData(prevData => ({
...prevData, // Spread all existing form data
[name]: value // Update only the field that changed
// [name] is a computed property name - it uses the actual
// value of 'name' as the key
}));
};
const handleSubmit = (e) => {
// Prevent the default form submission
// This stops the page from reloading
e.preventDefault();
// Form validation
// trim() removes whitespace from both ends of the string
if (!formData.title.trim()) {
alert('Please enter a task title');
return; // Exit early if validation fails
}
// If we get here, validation passed
// Call the function passed from parent with our form data
onAdd(formData);
// Reset form to initial state
// This gives users immediate feedback that their submission worked
setFormData({
title: '',
priority: 'Medium',
dueDate: '',
});
};
return (
// The className helps with styling and identifying the form
<form onSubmit={handleSubmit} className="add-task-form">
{/* Each form group is wrapped in a div for styling/structure */}
<div className="form-group">
<label htmlFor="title">
Task Title: {/* htmlFor matches input id for accessibility */}
<span className="required">*</span> {/* Visual required indicator */}
</label>
<input
id="title" // Matches the htmlFor in label
name="title" // Matches the property name in formData
type="text"
value={formData.title} // Controlled input - React controls the value
onChange={handleChange} // Called on every keystroke
placeholder="Enter task title" // Helper text for users
/>
</div>
<div className="form-group">
<label htmlFor="priority">Priority Level:</label>
<select
id="priority"
name="priority"
value={formData.priority}
onChange={handleChange}
>
{/* Each option represents a priority level */}
<option value="Low">Low</option>
<option value="Medium">Medium</option>
<option value="High">High</option>
</select>
</div>
<div className="form-group">
<label htmlFor="dueDate">Due Date:</label>
<input
id="dueDate"
name="dueDate"
type="date" // HTML5 date picker
value={formData.dueDate}
onChange={handleChange}
// min={new Date().toISOString().split('T')[0]} // Optional: Prevent past dates
/>
</div>
<button type="submit">Add Task</button>
</form>
);
}
The CSS remains the same, but let's add comments to explain the styling choices:
/* Container for the entire form */
.add-task-form {
display: flex; /* Use flexbox for layout */
flex-direction: column; /* Stack children vertically */
gap: 1rem; /* Consistent spacing between elements */
max-width: 400px; /* Limit form width for readability */
margin: 1rem 0; /* Vertical spacing around form */
padding: 1rem; /* Inner spacing */
background: #f8f8f8; /* Light background to stand out */
border-radius: 4px; /* Rounded corners */
}
/* Container for each form field group */
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem; /* Space between label and input */
}
/* Style for all labels */
.form-group label {
font-weight: bold; /* Make labels stand out */
}
/* Required field indicator */
.required {
color: red; /* Red asterisk for required fields */
margin-left: 4px; /* Space between label and asterisk */
}
/* Style for all inputs and selects */
input, select {
padding: 0.5rem; /* Internal spacing */
border: 1px solid #ddd; /* Light border */
border-radius: 4px; /* Rounded corners */
font-size: 1rem; /* Readable text size */
}
/* Submit button styling */
button[type="submit"] {
padding: 0.5rem 1rem; /* Comfortable click target */
background: #0066cc; /* Blue background */
color: white; /* White text */
border: none; /* Remove default border */
border-radius: 4px; /* Match other border radii */
cursor: pointer; /* Hand cursor on hover */
font-size: 1rem; /* Match input text size */
}
/* Hover state for submit button */
button[type="submit"]:hover {
background: #0052a3; /* Darker blue on hover */
}
Let's also add more detailed comments to our form validation pattern:
// Validation function that returns an object of errors
const validateForm = (formData) => {
// Initialize empty errors object
// We'll add error messages to this if we find problems
const [errors, setErrors] = {};
// Check for empty title after trimming whitespace
if (!formData.title.trim()) {
errors.title = 'Title is required';
}
// Check title length
// This prevents users from entering extremely long titles
if (formData.title.length > 50) {
errors.title = 'Title must be less than 50 characters';
}
// If priority isn't one of our valid options
// This prevents manipulation of select values
if (!['Low', 'Medium', 'High'].includes(formData.priority)) {
errors.priority = 'Invalid priority level';
}
// If dueDate is provided, ensure it's not in the past
if (formData.dueDate) {
const today = new Date();
const dueDate = new Date(formData.dueDate);
if (dueDate < today) {
errors.dueDate = 'Due date cannot be in the past';
}
}
// Return the errors object
// Will be empty if no validation errors found
return errors;
};
// Usage in form submission handler
const handleSubmit = (e) => {
e.preventDefault();
// Run validation
const errors = validateForm(formData);
// Check if we have any errors
if (Object.keys(errors).length > 0) {
// Update error state to display messages
setErrors(errors);
// Exit early - don't submit the form
return;
}
// If we get here, validation passed
// Proceed with form submission
onAdd(formData);
// Clear the form
setFormData({
title: '',
priority: 'Medium',
dueDate: '',
});
// Clear any previous errors
setErrors({});
};
When working with real applications, forms usually submit data to an API. Let's enhance our form to handle this:
function AddTaskForm({ onAdd }) {
// Add loading and error states to manage API interaction
const [formData, setFormData] = useState({
title: '',
priority: 'Medium',
dueDate: '',
});
const [isSubmitting, setIsSubmitting] = useState(false); // Track submission state
const [apiError, setApiError] = useState(null); // Store API errors
const [fieldErrors, setFieldErrors] = useState({}); // Store validation errors
// Simulate an API call (replace with your actual API call)
const submitToApi = async (taskData) => {
// This simulates an API call that might fail
const response = await fetch('/api/tasks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(taskData)
});
if (!response.ok) {
// Parse error response
const error = await response.json();
throw new Error(error.message || 'Failed to create task');
}
return response.json();
};
const handleSubmit = async (e) => {
e.preventDefault();
// Clear any previous errors
setApiError(null);
setFieldErrors({});
// Validate form data
const validationErrors = validateForm(formData);
if (Object.keys(validationErrors).length > 0) {
setFieldErrors(validationErrors);
return;
}
try {
// Show loading state
setIsSubmitting(true);
// Attempt submission
await submitToApi(formData);
// If successful, clear form and notify parent
setFormData({
title: '',
priority: 'Medium',
dueDate: '',
});
onAdd(formData);
} catch (error) {
// Handle different types of errors appropriately
if (error.name === 'ValidationError') {
// Backend validation errors (field-specific)
setFieldErrors(error.fields);
} else if (error.name === 'NetworkError') {
// Network-related errors
setApiError('Unable to connect to the server. Please check your connection.');
} else {
// Generic error fallback
setApiError(error.message || 'An unexpected error occurred.');
}
} finally {
// Always turn off loading state
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="add-task-form">
{/* Show API-level errors at the top of the form */}
{apiError && (
<div className="error-message" role="alert">
{apiError}
</div>
)}
<div className="form-group">
<label htmlFor="title">
Task Title:
<span className="required">*</span>
</label>
<input
id="title"
name="title"
type="text"
value={formData.title}
onChange={handleChange}
// Disable during submission to prevent double-submit
disabled={isSubmitting}
// Show visual error state
className={fieldErrors.title ? 'input-error' : ''}
aria-invalid={fieldErrors.title ? 'true' : 'false'}
/>
{/* Show field-specific error messages */}
{fieldErrors.title && (
<span className="error-text" role="alert">
{fieldErrors.title}
</span>
)}
</div>
{/* Similar pattern for other fields... */}
<button
type="submit"
disabled={isSubmitting}
className={isSubmitting ? 'submitting' : ''}
>
{isSubmitting ? 'Adding Task...' : 'Add Task'}
</button>
</form>
);
}
Add these styles to handle error states and loading:
/* Error message container */
.error-message {
background-color: #fff3f3; /* Light red background */
border: 1px solid #ffcdd2; /* Red border */
color: #d32f2f; /* Dark red text */
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
/* Style for inputs in error state */
.input-error {
border-color: #d32f2f !important; /* Red border */
background-color: #fff8f8; /* Light red background */
}
/* Error text below inputs */
.error-text {
color: #d32f2f;
font-size: 0.875rem;
margin-top: 0.25rem;
}
/* Loading state for submit button */
.submitting {
background-color: #cccccc !important; /* Gray out button */
cursor: not-allowed !important; /* Show disabled cursor */
position: relative; /* For loading indicator */
}
/* Optional: Add loading spinner */
.submitting::after {
content: '';
position: absolute;
width: 1rem;
height: 1rem;
border: 2px solid #ffffff;
border-radius: 50%;
border-top-color: transparent;
right: 1rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
Here's a more detailed error handling utility:
// Define specific error types for better error handling
class ValidationError extends Error {
constructor(fields) {
super('Validation Failed');
this.name = 'ValidationError';
this.fields = fields;
}
}
class NetworkError extends Error {
constructor(message = 'Network request failed') {
super(message);
this.name = 'NetworkError';
}
}
// Utility to handle API responses
const handleApiResponse = async (response) => {
// First check if the response is ok (status in 200-299 range)
if (!response.ok) {
const data = await response.json().catch(() => ({}));
// Handle different error status codes
switch (response.status) {
case 400:
// Bad request - usually validation errors
throw new ValidationError(data.errors || {});
case 401:
// Unauthorized - user needs to login
throw new Error('Please log in to continue.');
case 403:
// Forbidden - user doesn't have permission
throw new Error('You don\'t have permission to perform this action.');
case 404:
// Not found
throw new Error('The requested resource was not found.');
case 429:
// Too many requests
throw new Error('Please wait before trying again.');
case 500:
// Server error
throw new Error('An unexpected error occurred. Please try again later.');
default:
// Generic error
throw new Error(data.message || 'Something went wrong.');
}
}
// If response was ok, return the parsed data
return response.json();
};
// Usage in your form submission
const submitToApi = async (taskData) => {
try {
const response = await fetch('/api/tasks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(taskData)
});
return await handleApiResponse(response);
} catch (error) {
if (error instanceof TypeError) {
// Network error (couldn't reach server)
throw new NetworkError();
}
throw error; // Re-throw other errors
}
};
We'll add the ability to:
- Filter tasks by priority and completion status
- Search tasks by title
- Sort tasks by different criteria
- Maintain filter/sort state in the URL (optional advanced feature)
First, let's enhance our TaskList component with filtering capabilities:
function TaskList() {
// State for tasks and filters
const [tasks, setTasks] = useState([
/* ... your existing tasks ... */
]);
// Filter states
const [filters, setFilters] = useState({
priority: 'all', // 'all', 'high', 'medium', 'low'
status: 'all', // 'all', 'completed', 'active'
searchQuery: '', // Text search
});
// Memoized filtered tasks to prevent unnecessary recalculations
// Only recalculate when tasks or filters change
const filteredTasks = useMemo(() => {
return tasks
// First, filter by priority
.filter(task => {
if (filters.priority === 'all') return true;
return task.priority.toLowerCase() === filters.priority;
})
// Then, filter by completion status
.filter(task => {
if (filters.status === 'all') return true;
return filters.status === 'completed' ? task.completed : !task.completed;
})
// Finally, filter by search query
.filter(task => {
if (!filters.searchQuery) return true;
return task.title.toLowerCase()
.includes(filters.searchQuery.toLowerCase());
});
}, [tasks, filters]);
// Handler for filter changes
const handleFilterChange = (filterType, value) => {
setFilters(prev => ({
...prev,
[filterType]: value
}));
};
return (
<div className="task-list-container">
{/* Filter Controls */}
<div className="filters">
{/* Priority Filter */}
<select
value={filters.priority}
onChange={(e) => handleFilterChange('priority', e.target.value)}
aria-label="Filter by priority"
>
<option value="all">All Priorities</option>
<option value="high">High Priority</option>
<option value="medium">Medium Priority</option>
<option value="low">Low Priority</option>
</select>
{/* Status Filter */}
<select
value={filters.status}
onChange={(e) => handleFilterChange('status', e.target.value)}
aria-label="Filter by status"
>
<option value="all">All Tasks</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
</select>
{/* Search Input */}
<input
type="search"
placeholder="Search tasks..."
value={filters.searchQuery}
onChange={(e) => handleFilterChange('searchQuery', e.target.value)}
aria-label="Search tasks"
/>
</div>
{/* Task Counter */}
<div className="task-counter" role="status">
Showing {filteredTasks.length} of {tasks.length} tasks
</div>
{/* Filtered Task List */}
{filteredTasks.length > 0 ? (
<div className="tasks">
{filteredTasks.map(task => (
<TaskItem
key={task.id}
task={task}
onToggle={() => toggleTask(task.id)}
onDelete={() => deleteTask(task.id)}
/>
))}
</div>
) : (
<p className="no-results">No tasks match your filters</p>
)}
</div>
);
}
Add these styles for the filter controls:
/* Container for all filter controls */
.filters {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
padding: 1rem;
background: #f5f5f5;
border-radius: 4px;
flex-wrap: wrap; /* Allow wrapping on smaller screens */
}
/* Style filter selects and search input consistently */
.filters select,
.filters input {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
min-width: 150px;
}
/* Search input specific styles */
.filters input[type="search"] {
flex-grow: 1; /* Allow search to take remaining space */
min-width: 200px;
}
/* Task counter styles */
.task-counter {
font-size: 0.9rem;
color: #666;
margin-bottom: 1rem;
}
/* No results message */
.no-results {
text-align: center;
padding: 2rem;
color: #666;
font-style: italic;
background: #f9f9f9;
border-radius: 4px;
}
Now let's add sorting capabilities:
function TaskList() {
// Add sort state
const [sortConfig, setSortConfig] = useState({
key: 'createdAt', // What we're sorting by
direction: 'desc' // 'asc' or 'desc'
});
// Sorting function
const sortTasks = (tasks) => {
return [...tasks].sort((a, b) => {
// Handle different sort keys
switch (sortConfig.key) {
case 'title':
return sortConfig.direction === 'asc'
? a.title.localeCompare(b.title)
: b.title.localeCompare(a.title);
case 'priority': {
// Convert priority to number for sorting
const priorityMap = { low: 1, medium: 2, high: 3 };
const priorityA = priorityMap[a.priority.toLowerCase()];
const priorityB = priorityMap[b.priority.toLowerCase()];
return sortConfig.direction === 'asc'
? priorityA - priorityB
: priorityB - priorityA;
}
case 'dueDate':
// Handle potentially missing due dates
if (!a.dueDate) return sortConfig.direction === 'asc' ? 1 : -1;
if (!b.dueDate) return sortConfig.direction === 'asc' ? -1 : 1;
return sortConfig.direction === 'asc'
? new Date(a.dueDate) - new Date(b.dueDate)
: new Date(b.dueDate) - new Date(a.dueDate);
case 'createdAt':
default:
return sortConfig.direction === 'asc'
? a.id - b.id
: b.id - a.id;
}
});
};
// Update our memoized filtered tasks to include sorting
const filteredAndSortedTasks = useMemo(() => {
return sortTasks(filteredTasks);
}, [filteredTasks, sortConfig]);
// Handle sort changes
const handleSort = (key) => {
setSortConfig(prev => ({
key,
// If clicking the same key, toggle direction
// Otherwise, default to descending
direction: prev.key === key && prev.direction === 'desc'
? 'asc'
: 'desc'
}));
};
return (
<div className="task-list-container">
{/* Previous filter controls remain the same */}
{/* Add Sort Controls */}
<div className="sort-controls">
<span>Sort by:</span>
{['createdAt', 'title', 'priority', 'dueDate'].map(key => (
<button
key={key}
onClick={() => handleSort(key)}
className={`sort-button ${sortConfig.key === key ? 'active' : ''}`}
>
{key.charAt(0).toUpperCase() + key.slice(1)} {/* Capitalize */}
{sortConfig.key === key && (
<span className="sort-indicator">
{sortConfig.direction === 'asc' ? '↑' : '↓'}
</span>
)}
</button>
))}
</div>
{/* Use filteredAndSortedTasks instead of filteredTasks */}
{filteredAndSortedTasks.map(task => (
<TaskItem
key={task.id}
task={task}
onToggle={() => toggleTask(task.id)}
onDelete={() => deleteTask(task.id)}
/>
))}
</div>
);
}
Add styles for the sort controls:
/* Sort controls container */
.sort-controls {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.sort-controls span {
color: #666;
font-size: 0.9rem;
}
/* Sort button styles */
.sort-button {
padding: 0.4rem 0.8rem;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
/* Active sort button */
.sort-button.active {
background: #e3f2fd;
border-color: #2196f3;
color: #1976d2;
}
/* Sort direction indicator */
.sort-indicator {
font-size: 0.8rem;
}
/* Hover state */
.sort-button:hover:not(.active) {
background: #f5f5f5;
}
Description:
Allow users to organize tasks into categories (e.g., "Work", "Personal", "Shopping") with color-coding and filtering capabilities.
Narrative:
Imagine users want to separate their work tasks from personal errands. They should be able to assign a category when creating a task, filter the task list by category, and visually distinguish different categories through color-coding.
First Steps:
- Add a 'category' field to the task form with predefined options
- Create a CategoryContext using React Context API to manage available categories
- Add category-based CSS classes for visual distinction
Description:
Save tasks to the browser's localStorage so they persist between page refreshes and browser sessions.
Narrative:
Users are frustrated that their tasks disappear when they refresh the page. Implement automatic saving of tasks to localStorage and loading when the app starts.
First Steps:
- Create a useLocalStorage custom hook that wraps useState
- Modify TaskList to use the new hook instead of useState
- Add error handling for cases when localStorage is full or unavailable
Description:
Add due dates to tasks with visual indicators for upcoming/overdue tasks and optional browser notifications.
Narrative:
Users need to track when tasks are due and receive reminders for upcoming deadlines. Implement a date picker for task creation and a notification system for due dates.
First Steps:
- Add a DatePicker component to the task form using a library like react-datepicker
- Create a function to calculate and display relative time (e.g., "due in 2 days")
- Implement the Notifications API for browser reminders
Description:
Allow users to reorder tasks by dragging and dropping them into new positions.
Narrative:
Users want to prioritize tasks by manually arranging them in their preferred order. Implement drag and drop functionality to allow intuitive reordering.
First Steps:
- Install and set up react-beautiful-dnd
- Wrap task list in DragDropContext
- Modify TaskItem to be draggable
Description:
Add subtasks to main tasks with progress tracking and completion percentages.
Narrative:
Users need to break down complex tasks into smaller subtasks and track overall progress. Implement a subtask system with automatic progress calculation.
First Steps:
- Modify the task data structure to include an array of subtasks
- Create a SubtaskList component
- Add progress calculation logic and progress bar visualization
Description:
Enable users to share tasks via generated links and allow multiple users to collaborate on shared tasks.
Narrative:
Users want to share shopping lists or project tasks with family members or colleagues. Implement a sharing system with unique URLs for each task list.
First Steps:
- Generate unique IDs for task lists using UUID
- Create a ShareModal component with sharing options
- Implement a URL-based state loading system
Description:
Add a dashboard showing task completion rates, average completion time, and other productivity metrics.
Narrative:
Users want to track their productivity and understand their task completion patterns. Create a statistics dashboard with charts and insights.
First Steps:
- Create a new Statistics component using a charting library like recharts
- Add timestamp tracking for task creation and completion
- Implement basic metrics calculations (completion rate, average time)
Each feature introduces new React concepts:
-
Task Categories
- React Context
- CSS-in-JS
- Custom Hooks
-
Local Storage
- Browser APIs
- Custom Hooks
- Effect Hooks
-
Due Dates
- Third-party Components
- Browser APIs
- Time Manipulation
-
Drag and Drop
- External Libraries
- Array Manipulation
- Ref Forwarding
-
Progress Tracking
- Complex State Management
- Recursive Components
- Computed Properties
-
Sharing
- URL Parameters
- Modal Systems
- State Serialization
-
Statistics
- Data Visualization
- Data Aggregation
- Memoization
Additional considerations for implementing these features:
- Start with user stories
- Create wireframes
- Write tests
- Document API changes
- Consider accessibility
- Handle error cases
- Add loading states
- Implement responsive design
This lab material was developed in collaboration with Anthropic's Claude (version 3.5) in October 2024. The original tutorial concept is based on React's Tic-Tac-toe tutorial, expanded and modified through AI-assisted curriculum development.