Skip to content

Lab 4: Task Tracker

bobby reed edited this page Nov 7, 2024 · 11 revisions

Building a Task Tracker with React

Learning Objectives

By the end of this lab, students will be able to:

Component Basics

  • 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

State Management

  • 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

Event 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

Introduction

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.

Lab Sections:

  1. Setup and Overview: Creating the project structure and understanding components
  2. Building the Basic Interface: Creating and styling components
  3. Adding Interactivity: Implementing state and event handling
  4. Advanced Features: Adding persistence and filtering capabilities

Prerequisites

  • Basic understanding of HTML and JavaScript
  • Familiarity with functions, arrays, and objects
  • Node.js installed on your computer

Setup for the Lab

  1. Create a new React project:
npx create-react-app task-tracker
cd task-tracker
  1. Clean up the initial files:
cd src
# For Mac/Linux:
rm -f *
# For Windows:
del *
  1. Create the following files in your src directory:
  • index.js
  • App.js
  • styles.css
  1. 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;
}

Understanding Components and Styling

Component Analysis

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:

  1. We define a functional component named TaskItem. The export default makes this component available for import in other files.
  2. Every React component must return JSX (or null).
  3. The outer div has a className attribute - this is React's way of setting HTML classes.
  4. The h3 element contains our task title.
  5. The p element contains our priority level.

CSS Styling Explanation

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

Component Best Practices

When creating React components:

  1. Use PascalCase for component names (e.g., TaskItem)
  2. Keep components focused on a single responsibility
  3. Use semantic HTML elements inside your JSX
  4. Maintain consistent spacing and indentation
  5. Use className instead of class for CSS classes

Challenge 1: Creating Multiple Tasks

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:

  1. Create a new component named TaskList
  2. Create multiple task items within the component
  3. Use proper JSX structure to display them in a list

Here's how to get started:

  1. First, rename your current component:
export default function TaskList() {
  // Your code will go here
}
  1. 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

Your completed page should look like this:

image

Common Pitfalls to Avoid

  1. Forgetting to wrap multiple elements in a parent container
  2. Inconsistent use of className
  3. Missing the export default statement
  4. Incorrect component naming (must start with capital letter)

Try completing this challenge before moving on to Challenge 2!


Challenge 2: Component Separation and Props

Understanding Component Separation

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:

  1. The overall list structure
  2. Individual task display

Let's separate these concerns by creating a dedicated TaskItem component.

Detailed Instructions

  1. First, create a new TaskItem component above your TaskList:
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>
  );
}
  1. Update your TaskList component to use TaskItem:
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>
  );
}

Understanding Props

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)

Common Props Pitfalls

  1. Trying to modify props directly (they're read-only)
  2. Forgetting curly braces for non-string props:
// Incorrect:
<TaskItem number=1 /> 
// Correct:
<TaskItem number={1} />
  1. Inconsistent prop naming conventions

Adding State Management

Now that we have our component structure, let's make it dynamic with state management. We'll start with the useState hook.

Using the useState Hook

  1. 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... 😅)

  1. 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>
  );
}

Understanding useState

  • useState returns an array with two elements:
    1. The current state value
    2. A function to update the state
  • We use array destructuring to assign these to variables
  • React re-renders the component when state changes

Challenge 3: Adding Task Creation

Now it's your turn! Create a form to add new tasks:

  1. Create a new component called AddTask
  2. Use state to manage the form inputs
  3. Add a function in TaskList to add new tasks
  4. 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
}

Class Component Alternative

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>
    );
  }
}

State Management Best Practices

  1. Never modify state directly
  2. State updates may be asynchronous
  3. State updates are merged in class components
  4. Lift state up to the lowest common ancestor
  5. Use controlled components for forms

Exercise: Implement Task Deletion

Add the ability to delete tasks:

  1. Add a delete button to TaskItem
  2. Create a delete function in TaskList
  3. Pass the delete function down to TaskItem
  4. Update state when a task is deleted

Hint: Use filter() to create a new array without the deleted task.

Event Handling in React

Understanding React Events

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 of onclick (capital C!)
  • Takes functions as handlers: onClick={handleClick} instead of onclick="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.

Challenge 4: Adding Task Completion Toggle

Let's break this down into small, understandable pieces:

  1. 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
]);
  1. 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
  ));
}
  1. 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>
  );
}

Understanding Synthetic Events

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>
  );
}

Event Handling in Class Components

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>
    );
  }
}

Common Pitfalls and Solutions

  1. 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>
  1. 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!
  }
}
  1. 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);
};

Forms and Controlled Components

Understanding Controlled vs. Uncontrolled Components

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>
  );
}

Challenge 6: Create a Task 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;
}

Common Form Patterns and Best Practices

  1. 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);
};
  1. 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
  }));
};

Challenge 6: Updating the Task Form

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 */
}

Stop here! The following hasn't been tested. This is as far as we will go in this lab... for now.

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({});
};

Handling API Form Submissions

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); }
}

Error Handling Patterns

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
  }
};

Implementing Filtering and Sorting

Overview

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)

Basic Filtering Implementation

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;
}

Adding Sorting Functionality

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;
}

Task Tracker Extensions

1. Task Categories

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:

  1. Add a 'category' field to the task form with predefined options
  2. Create a CategoryContext using React Context API to manage available categories
  3. Add category-based CSS classes for visual distinction

2. Local Storage Persistence

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:

  1. Create a useLocalStorage custom hook that wraps useState
  2. Modify TaskList to use the new hook instead of useState
  3. Add error handling for cases when localStorage is full or unavailable

3. Due Date and Reminders

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:

  1. Add a DatePicker component to the task form using a library like react-datepicker
  2. Create a function to calculate and display relative time (e.g., "due in 2 days")
  3. Implement the Notifications API for browser reminders

4. Drag and Drop Reordering

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:

  1. Install and set up react-beautiful-dnd
  2. Wrap task list in DragDropContext
  3. Modify TaskItem to be draggable

5. Task Progress Tracking

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:

  1. Modify the task data structure to include an array of subtasks
  2. Create a SubtaskList component
  3. Add progress calculation logic and progress bar visualization

6. Task Sharing and Collaboration

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:

  1. Generate unique IDs for task lists using UUID
  2. Create a ShareModal component with sharing options
  3. Implement a URL-based state loading system

7. Task Statistics and Analytics

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:

  1. Create a new Statistics component using a charting library like recharts
  2. Add timestamp tracking for task creation and completion
  3. Implement basic metrics calculations (completion rate, average time)

Each feature introduces new React concepts:

  1. Task Categories

    • React Context
    • CSS-in-JS
    • Custom Hooks
  2. Local Storage

    • Browser APIs
    • Custom Hooks
    • Effect Hooks
  3. Due Dates

    • Third-party Components
    • Browser APIs
    • Time Manipulation
  4. Drag and Drop

    • External Libraries
    • Array Manipulation
    • Ref Forwarding
  5. Progress Tracking

    • Complex State Management
    • Recursive Components
    • Computed Properties
  6. Sharing

    • URL Parameters
    • Modal Systems
    • State Serialization
  7. 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.

Clone this wiki locally