Week 10: React Effects, Context API & Data Fetching

INFO 153A/253A - Front-End Web Architecture

Building Dynamic React Applications with Advanced Patterns

Part 1: Prep Work Recap

useEffect & Context from Chapters 6-7

What You Learned: Side Effects

From Chapter 6: Side effects with useEffect

"When you click on one of these, we want it to show in this form... that's called an effect or a side effect"

  • Side effects are external interactions: API calls, timers, DOM manipulation, subscriptions
  • React components should be pure: Same props = same output, no external changes during render
  • useEffect handles impure operations: Runs after render completes, keeping components predictable
  • Common use cases: Fetching data, setting up subscriptions, manually changing the DOM
  • Timing matters: Effects run after the browser paints, preventing visual blocking

Context API Introduction

From Chapter 6: Better state management

"Context provides a way to pass data through the component tree without having to pass props down manually at every level"

  • Prop drilling problem: Passing data through multiple component levels becomes unmanageable
  • Context creates a "wormhole": Components can access shared data directly, skipping intermediate layers
  • Provider wraps components: Like a broadcast tower sending data to all subscribers
  • useContext hook receives data: Components tune in to get the data they need
  • Global state management: Perfect for user data, themes, language settings
  • Not always necessary: Start with props, upgrade to Context when needed

Fetching Data Pattern

// From Chapter 7 prep work
const fetchFeedback = async () => {
  const response = await fetch('http://localhost:5000/feedback');
  const data = await response.json();
  setFeedback(data);
  setIsLoading(false);
};
  • async/await syntax: Modern way to handle promises, cleaner than .then() chains
  • fetch() returns a promise: Browser's built-in API for HTTP requests
  • response.json() also async: Parsing JSON is asynchronous operation
  • Loading states are crucial: Users need feedback while data loads
  • Error handling required: Network requests can fail - always plan for it

Key Concepts from Prep

useEffect Hook

  • Handles side effects
  • Runs after render
  • Dependency array controls when
  • Cleanup function for unmount

Context API

  • Global state management
  • Provider/Consumer pattern
  • useContext hook
  • Avoids prop drilling
  • Both solve different problems: useEffect for timing, Context for sharing
  • Often used together: Fetch data in effect, store in context
  • Foundation for complex apps: Every production React app uses these
  • Today's goal: Master these patterns for building real React applications

Part 2: Deep Dive into useEffect

Mastering Side Effects in React

useEffect Anatomy

import { useEffect, useState } from 'react';

function StudentRoster() {
  const [students, setStudents] = useState([]);

  useEffect(() => {
    // 1. Effect function - runs after render
    console.log('Effect running!');

    // 2. Side effect happens here
    fetchStudents();

    // 3. Cleanup function (optional)
    return () => {
      console.log('Cleaning up!');
    };
  }, []); // 4. Dependency array
}
  • Effect function: First argument, contains your side effect code
  • Runs after paint: DOM updates complete before effect runs
  • Cleanup function: Returned function runs before next effect or unmount
  • Dependency array: Controls when effect re-runs - empty means once
  • No array = every render: Dangerous! Can cause infinite loops
  • Mental model: "Synchronize with external system"

Dependency Array Patterns

function GradeTracker({ courseId, studentId }) {
  const [grades, setGrades] = useState([]);

  // Runs once on mount
  useEffect(() => {
    loadInitialData();
  }, []);

  // Runs when courseId changes
  useEffect(() => {
    fetchCourseGrades(courseId);
  }, [courseId]);

  // Runs when either changes
  useEffect(() => {
    fetchStudentGrades(courseId, studentId);
  }, [courseId, studentId]);
}
  • Empty array []: Run once when component mounts - perfect for initial data load
  • Single dependency: Re-run when that specific value changes
  • Multiple dependencies: Re-run when ANY of them change
  • React compares with Object.is(): Shallow equality check, not deep
  • Include all used values: ESLint will warn about missing dependencies
  • Common pattern: Fetch new data when ID props change

Cleanup Functions

function LiveScoreboard({ gameId }) {
  const [scores, setScores] = useState({});

  useEffect(() => {
    // Set up subscription
    const subscription = subscribeToGame(gameId, (newScores) => {
      setScores(newScores);
    });

    // Cleanup function - CRITICAL!
    return () => {
      subscription.unsubscribe();
    };
  }, [gameId]);
}
  • Prevents memory leaks: Subscriptions must be cleaned up or they persist
  • Runs before next effect: Old subscription removed before new one created
  • Runs on unmount: Component removal triggers cleanup
  • Common cleanups: Cancel API requests, clear timers, unsubscribe events
  • Not always needed: Simple fetches don't require cleanup
  • Rule of thumb: If you "connect" to something, disconnect in cleanup

Common useEffect Mistakes

❌ Infinite Loop

// DON'T DO THIS!
useEffect(() => {
  setCount(count + 1); // Causes re-render, triggers effect again
}); // No dependency array!

✅ Correct Pattern

useEffect(() => {
  setCount(c => c + 1); // Functional update
}, []); // Run once
  • Missing dependencies: React can't optimize, may have stale closures
  • Unnecessary effects: Derived state doesn't need effects
  • Race conditions: Multiple effects updating same state
  • Forgetting cleanup: Memory leaks from subscriptions
  • Using objects as dependencies: New object every render triggers effect

Data Fetching Pattern

function CourseList() {
  const [courses, setCourses] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;

    async function fetchCourses() {
      try {
        const response = await fetch('/api/courses');
        if (!response.ok) throw new Error('Failed to fetch');
        const data = await response.json();

        if (!cancelled) {
          setCourses(data);
          setLoading(false);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message);
          setLoading(false);
        }
      }
    }

    fetchCourses();

    return () => { cancelled = true; };
  }, []);
}
  • Cancelled flag pattern: Prevents state updates after unmount
  • Three states always: Loading, success, and error
  • Check response.ok: fetch doesn't throw on HTTP errors
  • Async function inside effect: useEffect itself can't be async
  • Error boundaries: Consider wrapping in error boundary component
  • Production pattern: This is how real apps fetch data

Part 3: Context API for Global State

Beyond Prop Drilling

Creating a Context

// StudentContext.js
import { createContext, useState, useContext } from 'react';

// 1. Create the context
const StudentContext = createContext();

// 2. Create provider component
export function StudentProvider({ children }) {
  const [students, setStudents] = useState([]);
  const [selectedStudent, setSelectedStudent] = useState(null);

  const addStudent = (student) => {
    setStudents(prev => [...prev, { ...student, id: Date.now() }]);
  };

  const value = {
    students,
    selectedStudent,
    addStudent,
    setSelectedStudent
  };

  return (
    <StudentContext.Provider value={value}>
      {children}
    </StudentContext.Provider>
  );
}

// 3. Custom hook for using context
export function useStudents() {
  const context = useContext(StudentContext);
  if (!context) {
    throw new Error('useStudents must be used within StudentProvider');
  }
  return context;
}
  • Three parts pattern: Context, Provider, and custom hook
  • Provider holds state: All state logic lives in provider component
  • Value prop is crucial: This object is what consumers receive
  • Custom hook abstracts: Cleaner than importing useContext everywhere
  • Error handling important: Catch usage outside provider
  • One source of truth: All components see same state

Using Context in Components

// App.js - Wrap with provider
import { StudentProvider } from './StudentContext';

function App() {
  return (
    <StudentProvider>
      <Header />
      <StudentList />
      <AddStudentForm />
    </StudentProvider>
  );
}

// StudentList.js - Consume context
import { useStudents } from './StudentContext';

function StudentList() {
  const { students, setSelectedStudent } = useStudents();

  return (
    <ul>
      {students.map(student => (
        <li key={student.id} onClick={() => setSelectedStudent(student)}>
          {student.name}
        </li>
      ))}
    </ul>
  );
}
  • Provider at top level: Wrap all components that need access
  • No prop passing: StudentList gets data directly from context
  • Multiple consumers OK: Any child can useStudents()
  • Updates propagate: Change in one component updates all consumers
  • Clean component interfaces: Components only receive props they truly need
  • Testability maintained: Can wrap with different provider for tests

Context with useEffect

function StudentProvider({ children }) {
  const [students, setStudents] = useState([]);
  const [loading, setLoading] = useState(true);

  // Fetch data when provider mounts
  useEffect(() => {
    async function loadStudents() {
      try {
        const response = await fetch('/api/students');
        const data = await response.json();
        setStudents(data);
      } catch (error) {
        console.error('Failed to load students:', error);
      } finally {
        setLoading(false);
      }
    }

    loadStudents();
  }, []);

  const value = { students, loading };

  return (
    <StudentContext.Provider value={value}>
      {children}
    </StudentContext.Provider>
  );
}
  • Data fetching in provider: Centralized data loading
  • Loading state in context: All components know when data arrives
  • Single fetch for all: Better than each component fetching
  • Error handling centralized: One place to handle API failures
  • Finally block useful: Always clear loading state
  • Real pattern: This is how production apps initialize

Performance Considerations

import { useMemo, useCallback } from 'react';

function OptimizedProvider({ children }) {
  const [students, setStudents] = useState([]);

  // Memoize functions to prevent recreating
  const addStudent = useCallback((student) => {
    setStudents(prev => [...prev, student]);
  }, []);

  // Memoize value object
  const value = useMemo(() => ({
    students,
    addStudent
  }), [students, addStudent]);

  return (
    <StudentContext.Provider value={value}>
      {children}
    </StudentContext.Provider>
  );
}
  • Context triggers re-renders: All consumers re-render when value changes
  • Object recreation problem: New value object = all consumers re-render
  • useMemo prevents recreation: Same object reference if dependencies unchanged
  • useCallback for functions: Stable function references
  • Split contexts if needed: Separate frequently changing from stable data
  • Measure before optimizing: React DevTools Profiler shows actual impact

Part 4: Data Fetching with APIs

Modern Async Patterns

Understanding Async JavaScript

// Three ways to handle asynchronous code

// 1. Callbacks (old way)
getData((error, data) => {
  if (error) console.error(error);
  else console.log(data);
});

// 2. Promises with .then()
getData()
  .then(data => console.log(data))
  .catch(error => console.error(error));

// 3. Async/await (modern way)
async function fetchData() {
  try {
    const data = await getData();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
}
  • JavaScript is single-threaded: Can't block for network requests
  • Callbacks led to "callback hell": Nested callbacks become unreadable
  • Promises improved chaining: But .then() chains still get complex
  • Async/await is syntactic sugar: Makes async code look synchronous
  • Try/catch for errors: Familiar error handling pattern
  • Industry standard: Async/await is how modern apps handle async

Promises: The Foundation

Promise: An object representing the eventual completion or failure of an asynchronous operation

// Creating a promise
const myPromise = new Promise((resolve, reject) => {
  // Simulate async operation
  setTimeout(() => {
    const success = true;

    if (success) {
      resolve({ data: 'Operation successful!' });
    } else {
      reject('Something went wrong');
    }
  }, 1000);
});
  • Promise states: Pending → Fulfilled (resolved) or Rejected
  • Two parameters: resolve for success, reject for failure
  • Non-blocking: Code continues executing while promise is pending
  • Immutable once settled: Can't change from fulfilled to rejected
  • Usually consume, not create: Most APIs return promises (fetch, axios)
  • Solves callback hell: Provides cleaner way to chain async operations

Handling Promises with .then() and .catch()

// Handling a promise
const fetchStudentData = new Promise((resolve, reject) => {
  setTimeout(() => {
    const error = false;

    if (!error) {
      resolve({ name: 'Alice', gpa: 3.8 });
    } else {
      reject('Failed to fetch student data');
    }
  }, 1000);
});

// Consuming the promise
fetchStudentData
  .then(student => {
    console.log('Student:', student);
    return student.gpa; // Can return value for next .then()
  })
  .then(gpa => {
    console.log('GPA:', gpa);
  })
  .catch(error => {
    console.error('Error:', error);
  })
  .finally(() => {
    console.log('Promise settled - cleanup complete');
  });
  • .then() handles success: Receives whatever was passed to resolve()
  • .catch() handles errors: Catches any rejection in the chain
  • .finally() runs always: Executes regardless of success/failure
  • Chaining returns promises: Each .then() returns a new promise
  • Error propagation: One .catch() can handle multiple .then() errors
  • Cleaner than callbacks: Avoids deeply nested code

Promise Chaining Pattern

// Sequential async operations
fetch('/api/courses')
  .then(response => {
    if (!response.ok) throw new Error('Network error');
    return response.json(); // Returns another promise!
  })
  .then(courses => {
    console.log('Courses:', courses);
    // Fetch details for first course
    return fetch(`/api/courses/${courses[0].id}`);
  })
  .then(response => response.json())
  .then(courseDetails => {
    console.log('Course details:', courseDetails);
  })
  .catch(error => {
    console.error('Any step failed:', error);
  });
  • Returning promises in .then(): Allows chaining dependent operations
  • Flattens nested callbacks: Reads top-to-bottom instead of nested
  • Single error handler: One .catch() for entire chain
  • Response.json() is a promise: That's why we need two .then() calls
  • Throwing errors: Throws in .then() are caught by .catch()
  • Sequential execution: Each .then() waits for previous to complete

Multiple Promises: Promise.all()

// Run multiple promises in parallel
const promise1 = fetch('/api/students');
const promise2 = fetch('/api/courses');
const promise3 = fetch('/api/grades');

Promise.all([promise1, promise2, promise3])
  .then(responses => {
    // All responses arrive - parse them all
    return Promise.all(responses.map(r => r.json()));
  })
  .then(([students, courses, grades]) => {
    console.log('Students:', students);
    console.log('Courses:', courses);
    console.log('Grades:', grades);
    // All data available simultaneously!
  })
  .catch(error => {
    // If ANY promise fails, catch fires
    console.error('At least one request failed:', error);
  });
  • Parallel execution: All promises run simultaneously, not sequentially
  • Waits for all: Resolves when all promises resolve
  • Fast failure: Rejects immediately if any promise rejects
  • Array destructuring: Results array matches input order
  • Performance win: Much faster than sequential fetches
  • Use case: Loading multiple independent data sources for a dashboard

Async/Await: Syntactic Sugar

With Promises (.then)

function getCourseData(id) {
  return fetch(`/api/courses/${id}`)
    .then(res => res.json())
    .then(course => {
      console.log(course);
      return course;
    })
    .catch(err => {
      console.error(err);
      throw err;
    });
}

With Async/Await

async function getCourseData(id) {
  try {
    const res = await fetch(`/api/courses/${id}`);
    const course = await res.json();
    console.log(course);
    return course;
  } catch (err) {
    console.error(err);
    throw err;
  }
}
  • Same functionality, cleaner syntax: Async/await is syntactic sugar over promises
  • async keyword creates promise: Function automatically returns a promise
  • await pauses execution: Waits for promise to resolve, looks synchronous
  • try/catch for errors: More familiar than .catch() for many developers
  • Still uses promises: Under the hood, it's all promises
  • Modern standard: React and modern APIs heavily use async/await

Fetch API with Async/Await

// Complete fetch pattern with error handling
async function fetchCourseData(courseId) {
  const url = `https://api.university.edu/courses/${courseId}`;

  try {
    const response = await fetch(url, {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`
      }
    });

    // Check HTTP status
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    // Parse JSON
    const data = await response.json();
    return data;

  } catch (error) {
    // Network error or JSON parse error
    console.error('Fetch failed:', error);
    throw error; // Re-throw for caller to handle
  }
}
  • fetch() doesn't throw on HTTP errors: Must check response.ok
  • Two awaits needed: One for response, one for parsing JSON
  • Headers object configures request: Auth tokens, content types
  • Network errors throw: No internet, CORS issues, etc.
  • Parse errors throw: Invalid JSON will throw exception
  • Re-throw pattern: Let caller decide how to handle errors

HTTP Methods & REST

// REST API patterns
const API_URL = 'https://api.example.com/students';

// GET - Retrieve data
const getStudents = async () => {
  const response = await fetch(API_URL);
  return response.json();
};

// POST - Create new
const createStudent = async (student) => {
  const response = await fetch(API_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(student)
  });
  return response.json();
};

// PUT - Update existing
const updateStudent = async (id, updates) => {
  const response = await fetch(`${API_URL}/${id}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(updates)
  });
  return response.json();
};

// DELETE - Remove
const deleteStudent = async (id) => {
  await fetch(`${API_URL}/${id}`, { method: 'DELETE' });
};
  • REST is a convention: Predictable URL patterns and methods
  • GET retrieves, never modifies: Safe to retry, can be cached
  • POST creates new resources: Returns created item with ID
  • PUT replaces entire resource: Send complete updated object
  • PATCH for partial updates: Only send changed fields
  • DELETE may not return data: Often just status 204 (No Content)

Loading & Error States

function StudentDashboard() {
  const [students, setStudents] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchStudents()
      .then(data => {
        setStudents(data);
        setError(null);
      })
      .catch(err => {
        setError(err.message);
        setStudents([]);
      })
      .finally(() => {
        setLoading(false);
      });
  }, []);

  if (loading) return <div className="spinner">Loading...</div>;
  if (error) return <div className="error">Error: {error}</div>;
  if (students.length === 0) return <div>No students found</div>;

  return (
    <div className="student-grid">
      {students.map(student => (
        <StudentCard key={student.id} student={student} />
      ))}
    </div>
  );
}
  • Three UI states minimum: Loading, error, and success (with empty check)
  • User feedback crucial: Never leave users wondering what's happening
  • Finally block ensures cleanup: Loading always turns off
  • Error recovery: Clear error on successful retry
  • Empty state handling: Different from error - successful but no data
  • Graceful degradation: Show partial data if some requests fail

Handling Race Conditions

function SearchResults({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    let cancelled = false;

    async function search() {
      // Debounce search
      await new Promise(resolve => setTimeout(resolve, 300));

      if (cancelled) return;

      try {
        const data = await searchAPI(query);

        // Only update if this effect hasn't been cancelled
        if (!cancelled) {
          setResults(data);
        }
      } catch (error) {
        if (!cancelled) {
          console.error('Search failed:', error);
        }
      }
    }

    if (query) {
      search();
    } else {
      setResults([]);
    }

    // Cleanup - cancel previous search
    return () => {
      cancelled = true;
    };
  }, [query]);
  • Race condition: Fast typing triggers multiple searches
  • Last request might not finish last: Network timing varies
  • Cancelled flag pattern: Ignore results from old searches
  • Debouncing reduces requests: Wait for typing to stop
  • Cleanup prevents bugs: Old results won't overwrite new ones
  • Common in search/autocomplete: Every search bar needs this

Part 5: Advanced Integration Patterns

Bringing It All Together

Complete Library Manager Pattern

// LibraryContext.js - Production-ready pattern
import { createContext, useContext, useState, useEffect } from 'react';

const LibraryContext = createContext();

export function LibraryProvider({ children }) {
  const [books, setBooks] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [filter, setFilter] = useState('all');

  // Fetch books on mount
  useEffect(() => {
    fetchBooks();
  }, []);

  const fetchBooks = async () => {
    try {
      const response = await fetch('/api/library/books');
      if (!response.ok) throw new Error('Failed to fetch');
      const data = await response.json();
      setBooks(data);
      setError(null);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  const addBook = async (bookData) => {
    try {
      const response = await fetch('/api/library/books', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(bookData)
      });
      const newBook = await response.json();
      setBooks(prev => [...prev, newBook]);
      return newBook;
    } catch (err) {
      setError('Failed to add book');
      throw err;
    }
  };

  const filteredBooks = books.filter(book => {
    if (filter === 'all') return true;
    if (filter === 'available') return book.status === 'available';
    if (filter === 'checked-out') return book.status === 'checked-out';
  });

  const value = {
    books: filteredBooks,
    loading,
    error,
    filter,
    setFilter,
    addBook,
    refreshBooks: fetchBooks
  };

  return (
    <LibraryContext.Provider value={value}>
      {children}
    </LibraryContext.Provider>
  );
}
  • Complete state management: Data, loading, error, and filters all centralized
  • Immediate state updates: Local state updated right after API calls
  • Error recovery: Refresh function allows users to retry failed operations
  • Computed values: filteredBooks derived from state without duplication
  • Async operations wrapped: Components just call addBook() - complexity hidden
  • Single source of truth: All library logic lives in one provider

Custom Hooks for Data

// Custom hook for data fetching
function useAPI(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;

    async function fetchData() {
      try {
        setLoading(true);
        const response = await fetch(url);
        if (!response.ok) throw new Error(`Error: ${response.status}`);
        const result = await response.json();

        if (!cancelled) {
          setData(result);
          setError(null);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message);
          setData(null);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }

    fetchData();

    return () => {
      cancelled = true;
    };
  }, [url]);

  return { data, loading, error };
}

// Using the custom hook
function CourseDetails({ courseId }) {
  const { data: course, loading, error } = useAPI(`/api/courses/${courseId}`);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  return <CourseCard course={course} />;
}
  • Reusable logic extraction: Same pattern for any API endpoint
  • Consistent error handling: All fetches work the same way
  • Clean component code: Components focus on rendering
  • Easy testing: Mock the hook for unit tests
  • Composition over inheritance: React's philosophy
  • SWR/React Query similar: But with caching, mutations

Summary & Key Takeaways

Today's Power Trio

  • useEffect: Synchronize with external world
  • Context API: Share state without drilling
  • Async/Await: Handle data fetching elegantly
  • Effects handle timing: After render, with cleanup, controlled by dependencies
  • Context eliminates prop drilling: But use wisely - not for everything
  • Always handle loading and errors: Users need feedback
  • Race conditions are real: Cleanup prevents bugs
  • Custom hooks share logic: Extract repeated patterns
  • This is production React: These patterns power real applications

Production-Ready Patterns!

You now have all the patterns needed for building real React applications:

  • Context for global state management
  • useEffect for side effects and data fetching
  • Promises and async/await for API calls
  • Proper loading and error state handling