Episode 2 — React Frontend Architecture NextJS / 2.4 — React Lifecycle Methods

2.4.d — Data Fetching, Cleanup & DOM Manipulation

In one sentence: This sub-topic covers the practical side of effects — fetching data with proper error/loading states, cancelling requests with AbortController, manipulating the DOM with refs, integrating browser observers, and knowing when not to reach for useEffect.

Navigation: ← useEffect Hook · Next → Exercise Questions


Table of Contents

  1. Data Fetching Patterns
  2. Loading, Error & Data States
  3. AbortController — Cancelling Requests
  4. Race Condition Prevention
  5. Building a Reusable Fetch Hook
  6. DOM Manipulation with useRef
  7. Measuring DOM Elements
  8. Focus Management
  9. Scroll Management
  10. Integrating Third-Party DOM Libraries
  11. Observer APIs — IntersectionObserver, ResizeObserver, MutationObserver
  12. WebSocket Connection Lifecycle
  13. localStorage & sessionStorage Sync
  14. The Data Fetching Evolution
  15. Best Practices Checklist
  16. Key Takeaways

1. Data Fetching Patterns

The simplest fetch

function Posts() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetch('/api/posts')
      .then(res => res.json())
      .then(data => setPosts(data));
  }, []);

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

This works but has zero error handling, zero loading state, and a race condition bug. Let's fix everything.

Production-quality fetch

function Posts() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function load() {
      setLoading(true);
      setError(null);

      try {
        const res = await fetch('/api/posts', {
          signal: controller.signal,
        });

        if (!res.ok) {
          throw new Error(`HTTP ${res.status}: ${res.statusText}`);
        }

        const data = await res.json();
        setPosts(data);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        if (!controller.signal.aborted) {
          setLoading(false);
        }
      }
    }

    load();

    return () => controller.abort();
  }, []);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage message={error} />;
  if (posts.length === 0) return <EmptyState />;

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Fetch with dynamic parameters

function UserPosts({ userId }) {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function load() {
      setLoading(true);
      setError(null);

      try {
        const res = await fetch(`/api/users/${userId}/posts`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json();
        setPosts(data);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        if (!controller.signal.aborted) {
          setLoading(false);
        }
      }
    }

    load();

    return () => controller.abort();
  }, [userId]);  // Re-fetches when userId changes

  // ... render
}

When userId changes:

  1. Cleanup runs → AbortController aborts the old request
  2. New effect runs → Fresh request for new userId
  3. No stale data from the old user

2. Loading, Error & Data States

The state machine approach

Every fetch has exactly four possible states:

┌─────────────┐     ┌──────────────┐     ┌──────────────┐
│    IDLE      │ ──→ │   LOADING    │ ──→ │   SUCCESS    │
│  (initial)   │     │   (fetching) │     │  (data!)     │
└─────────────┘     └──────────────┘     └──────────────┘
                          │                     │
                          ↓                     ↓
                    ┌──────────────┐     ┌──────────────┐
                    │    ERROR     │     │   REFETCH    │
                    │  (failed)    │     │  (loading)   │
                    └──────────────┘     └──────────────┘

Pattern 1: Three separate states (most common)

const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

Pattern 2: Discriminated union (more robust)

const [state, setState] = useState({ status: 'idle', data: null, error: null });

// In the effect:
setState({ status: 'loading', data: null, error: null });
// On success:
setState({ status: 'success', data: response, error: null });
// On error:
setState({ status: 'error', data: null, error: err.message });

Pattern 3: useReducer (best for complex flows)

const initialState = { data: null, loading: false, error: null };

function reducer(state, action) {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true, error: null };
    case 'FETCH_SUCCESS':
      return { data: action.payload, loading: false, error: null };
    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload };
    default:
      return state;
  }
}

function UserProfile({ userId }) {
  const [state, dispatch] = useReducer(reducer, initialState);

  useEffect(() => {
    const controller = new AbortController();

    async function load() {
      dispatch({ type: 'FETCH_START' });

      try {
        const res = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json();
        dispatch({ type: 'FETCH_SUCCESS', payload: data });
      } catch (err) {
        if (err.name !== 'AbortError') {
          dispatch({ type: 'FETCH_ERROR', payload: err.message });
        }
      }
    }

    load();
    return () => controller.abort();
  }, [userId]);

  const { data, loading, error } = state;
  // ... render
}

Rendering different states

function DataView({ data, loading, error, onRetry }) {
  if (loading) {
    return (
      <div className="loading" role="status" aria-label="Loading">
        <Spinner />
        <p>Loading...</p>
      </div>
    );
  }

  if (error) {
    return (
      <div className="error" role="alert">
        <p>Error: {error}</p>
        <button onClick={onRetry}>Retry</button>
      </div>
    );
  }

  if (!data || (Array.isArray(data) && data.length === 0)) {
    return (
      <div className="empty">
        <p>No data found.</p>
      </div>
    );
  }

  return <div className="content">{/* render data */}</div>;
}

3. AbortController — Cancelling Requests

What is AbortController?

A browser API that lets you cancel in-flight fetch requests.

const controller = new AbortController();

// Pass the signal to fetch
fetch(url, { signal: controller.signal });

// Later, cancel the request
controller.abort();

When aborted, fetch throws an AbortError — which you should catch and ignore.

Why it matters for React

Without cancellation:

// ❌ User clicks through pages quickly:
// Page A mount → fetch('/api/a') ...pending...
// Page B mount → fetch('/api/b') ...pending...
// Page C mount → fetch('/api/c') ...pending...
// Response A arrives → setData(a) — BUT we're on page C!
// setState on unmounted component → Warning + incorrect data

With cancellation:

// ✅ With AbortController:
// Page A mount → fetch('/api/a', { signal }) ...pending...
// Page A unmount → controller.abort() → fetch A cancelled!
// Page B mount → fetch('/api/b', { signal }) ...pending...
// Page B unmount → controller.abort() → fetch B cancelled!
// Page C mount → fetch('/api/c', { signal }) → response C arrives ✅

Complete pattern

useEffect(() => {
  const controller = new AbortController();

  async function fetchData() {
    try {
      const res = await fetch(url, { signal: controller.signal });
      const json = await res.json();
      setData(json);
    } catch (err) {
      if (err.name === 'AbortError') {
        // Request was cancelled — component unmounted or deps changed
        // Do nothing — this is expected behaviour
        return;
      }
      // Real error — show to user
      setError(err.message);
    }
  }

  fetchData();

  return () => controller.abort();  // Cancel on cleanup
}, [url]);

AbortController with multiple requests

useEffect(() => {
  const controller = new AbortController();

  async function loadDashboard() {
    try {
      const [users, stats, notifications] = await Promise.all([
        fetch('/api/users', { signal: controller.signal }).then(r => r.json()),
        fetch('/api/stats', { signal: controller.signal }).then(r => r.json()),
        fetch('/api/notifications', { signal: controller.signal }).then(r => r.json()),
      ]);

      setDashboard({ users, stats, notifications });
    } catch (err) {
      if (err.name !== 'AbortError') {
        setError(err.message);
      }
    }
  }

  loadDashboard();
  return () => controller.abort();  // Cancels ALL three requests
}, []);

AbortController with timeout

useEffect(() => {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 10000);  // 10s timeout

  async function load() {
    try {
      const res = await fetch(url, { signal: controller.signal });
      const data = await res.json();
      setData(data);
    } catch (err) {
      if (err.name === 'AbortError') {
        setError('Request timed out');
      } else {
        setError(err.message);
      }
    } finally {
      clearTimeout(timeoutId);
    }
  }

  load();
  return () => {
    controller.abort();
    clearTimeout(timeoutId);
  };
}, [url]);

4. Race Condition Prevention

The problem

// User types fast: "a" → "ab" → "abc"
// Three fetches fire:
// fetch("/search?q=a")     → takes 3 seconds
// fetch("/search?q=ab")    → takes 1 second
// fetch("/search?q=abc")   → takes 2 seconds

// Response order: "ab" (1s) → "abc" (2s) → "a" (3s)
// Final displayed result: "a" ← WRONG! Should be "abc"

Solution 1: Boolean flag (the ignore pattern)

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

  async function search() {
    const res = await fetch(`/search?q=${query}`);
    const data = await res.json();

    if (!ignore) {
      setResults(data);  // Only set if this is still the latest effect
    }
  }

  search();

  return () => {
    ignore = true;  // Flag the old effect as stale
  };
}, [query]);

Solution 2: AbortController (recommended — also saves bandwidth)

useEffect(() => {
  const controller = new AbortController();

  async function search() {
    try {
      const res = await fetch(`/search?q=${query}`, {
        signal: controller.signal,
      });
      const data = await res.json();
      setResults(data);
    } catch (err) {
      if (err.name !== 'AbortError') setError(err.message);
    }
  }

  search();

  return () => controller.abort();
}, [query]);

AbortController is better because it actually cancels the network request, saving bandwidth.

How it prevents the race condition

User types "a":
  Effect 1: fetch("/search?q=a") starts

User types "ab":
  Cleanup 1: controller.abort() → fetch "a" CANCELLED
  Effect 2: fetch("/search?q=ab") starts

User types "abc":
  Cleanup 2: controller.abort() → fetch "ab" CANCELLED
  Effect 3: fetch("/search?q=abc") starts

Only fetch "abc" completes → correct result displayed ✅

5. Building a Reusable Fetch Hook

Basic useFetch

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!url) {
      setData(null);
      setLoading(false);
      return;
    }

    const controller = new AbortController();

    async function load() {
      setLoading(true);
      setError(null);

      try {
        const res = await fetch(url, { signal: controller.signal });

        if (!res.ok) {
          throw new Error(`HTTP ${res.status}: ${res.statusText}`);
        }

        const json = await res.json();
        setData(json);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        if (!controller.signal.aborted) {
          setLoading(false);
        }
      }
    }

    load();
    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}

// Usage
function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);

  if (loading) return <Spinner />;
  if (error) return <Error message={error} />;
  return <Profile user={user} />;
}

Advanced useFetch with options

function useFetch(url, options = {}) {
  const {
    method = 'GET',
    body = null,
    headers = {},
    enabled = true,          // Conditional fetching
    onSuccess = null,        // Callback on success
    onError = null,          // Callback on error
    transform = (d) => d,   // Transform response data
  } = options;

  const [state, dispatch] = useReducer(fetchReducer, {
    data: null,
    loading: false,
    error: null,
  });

  const refetch = useCallback(() => {
    if (!url || !enabled) return;
    dispatch({ type: 'START' });

    const controller = new AbortController();

    fetch(url, {
      method,
      headers: { 'Content-Type': 'application/json', ...headers },
      body: body ? JSON.stringify(body) : null,
      signal: controller.signal,
    })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(data => {
        const transformed = transform(data);
        dispatch({ type: 'SUCCESS', payload: transformed });
        onSuccess?.(transformed);
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          dispatch({ type: 'ERROR', payload: err.message });
          onError?.(err);
        }
      });

    return () => controller.abort();
  }, [url, method, body, headers, enabled, transform, onSuccess, onError]);

  useEffect(() => {
    if (!enabled) return;
    const cleanup = refetch();
    return cleanup;
  }, [refetch, enabled]);

  return { ...state, refetch };
}

6. DOM Manipulation with useRef

What is useRef?

useRef creates a mutable container that persists across renders without causing re-renders.

const ref = useRef(initialValue);
// ref.current = initialValue

ref.current = newValue;  // Mutating doesn't trigger re-render

Two uses of useRef

Use CaseExample
DOM referenceAccess actual DOM nodes
Mutable variableStore values between renders

Attaching refs to DOM elements

function TextInput() {
  const inputRef = useRef(null);

  const handleSubmit = () => {
    // Access the actual DOM node
    console.log(inputRef.current.value);
    inputRef.current.focus();
    inputRef.current.select();
  };

  return (
    <>
      <input ref={inputRef} type="text" />
      <button onClick={handleSubmit}>Submit</button>
    </>
  );
}

Common DOM operations

function DomOps() {
  const divRef = useRef(null);

  useEffect(() => {
    const el = divRef.current;

    // Read dimensions
    const { width, height } = el.getBoundingClientRect();

    // Read scroll position
    const scrollTop = el.scrollTop;

    // Read computed styles
    const bgColor = getComputedStyle(el).backgroundColor;

    // Modify DOM (use sparingly — prefer state + JSX)
    el.classList.add('loaded');
    el.setAttribute('data-ready', 'true');
  }, []);

  return <div ref={divRef}>Content</div>;
}

Forwarding refs to child components

// Child component needs forwardRef to receive a ref
const CustomInput = forwardRef(function CustomInput({ label, ...props }, ref) {
  return (
    <label>
      {label}
      <input ref={ref} {...props} />
    </label>
  );
});

// Parent can now ref the input inside CustomInput
function Form() {
  const nameRef = useRef(null);

  const focusName = () => nameRef.current.focus();

  return (
    <>
      <CustomInput ref={nameRef} label="Name" />
      <button onClick={focusName}>Focus name</button>
    </>
  );
}

7. Measuring DOM Elements

Measuring on mount

function MeasuredBox() {
  const ref = useRef(null);
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });

  useLayoutEffect(() => {
    // Use useLayoutEffect to measure before paint
    const { width, height } = ref.current.getBoundingClientRect();
    setDimensions({ width, height });
  }, []);

  return (
    <div ref={ref}>
      <p>Width: {dimensions.width}px, Height: {dimensions.height}px</p>
    </div>
  );
}

Measuring when content changes

function AutoResizeTextarea({ value, onChange }) {
  const textareaRef = useRef(null);

  useLayoutEffect(() => {
    const el = textareaRef.current;
    el.style.height = 'auto';                      // Reset
    el.style.height = el.scrollHeight + 'px';       // Grow to content
  }, [value]);

  return (
    <textarea
      ref={textareaRef}
      value={value}
      onChange={onChange}
      rows={1}
      style={{ overflow: 'hidden', resize: 'none' }}
    />
  );
}

Callback refs for dynamic elements

function DynamicList({ items }) {
  const [heights, setHeights] = useState({});

  // Callback ref is called whenever the element is attached/detached
  const measureRef = useCallback((node) => {
    if (node !== null) {
      const height = node.getBoundingClientRect().height;
      setHeights(prev => ({
        ...prev,
        [node.dataset.id]: height,
      }));
    }
  }, []);

  return (
    <ul>
      {items.map(item => (
        <li key={item.id} ref={measureRef} data-id={item.id}>
          {item.content}
        </li>
      ))}
    </ul>
  );
}

8. Focus Management

Auto-focus on mount

function SearchBar() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} placeholder="Search..." />;
}

Focus trap for modals

function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef(null);
  const previousFocusRef = useRef(null);

  useEffect(() => {
    if (isOpen) {
      // Save current focus
      previousFocusRef.current = document.activeElement;

      // Focus the modal
      modalRef.current?.focus();

      // Trap focus inside modal
      const handleKeyDown = (e) => {
        if (e.key === 'Escape') onClose();

        if (e.key === 'Tab') {
          const focusable = modalRef.current.querySelectorAll(
            'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
          );
          const first = focusable[0];
          const last = focusable[focusable.length - 1];

          if (e.shiftKey && document.activeElement === first) {
            e.preventDefault();
            last.focus();
          } else if (!e.shiftKey && document.activeElement === last) {
            e.preventDefault();
            first.focus();
          }
        }
      };

      document.addEventListener('keydown', handleKeyDown);

      return () => {
        document.removeEventListener('keydown', handleKeyDown);
        // Restore focus when modal closes
        previousFocusRef.current?.focus();
      };
    }
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <div className="modal-overlay">
      <div ref={modalRef} className="modal" role="dialog" tabIndex={-1}>
        {children}
      </div>
    </div>
  );
}

9. Scroll Management

Scroll to bottom (chat messages)

function ChatMessages({ messages }) {
  const endRef = useRef(null);

  useEffect(() => {
    endRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  return (
    <div className="messages">
      {messages.map(msg => (
        <div key={msg.id} className="message">{msg.text}</div>
      ))}
      <div ref={endRef} />
    </div>
  );
}

Scroll restoration

function ScrollRestorer({ scrollKey }) {
  const containerRef = useRef(null);
  const scrollPositions = useRef({});

  // Save scroll position when leaving
  useEffect(() => {
    return () => {
      if (containerRef.current) {
        scrollPositions.current[scrollKey] = containerRef.current.scrollTop;
      }
    };
  }, [scrollKey]);

  // Restore scroll position when arriving
  useLayoutEffect(() => {
    const savedPosition = scrollPositions.current[scrollKey];
    if (savedPosition && containerRef.current) {
      containerRef.current.scrollTop = savedPosition;
    }
  }, [scrollKey]);

  return (
    <div ref={containerRef} style={{ overflow: 'auto', height: '100vh' }}>
      {/* content */}
    </div>
  );
}

Infinite scroll

function InfiniteList({ loadMore, hasMore }) {
  const sentinelRef = useRef(null);

  useEffect(() => {
    if (!hasMore) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          loadMore();
        }
      },
      { threshold: 0.1 }
    );

    const sentinel = sentinelRef.current;
    if (sentinel) observer.observe(sentinel);

    return () => {
      if (sentinel) observer.unobserve(sentinel);
    };
  }, [loadMore, hasMore]);

  return (
    <>
      {/* list items */}
      {hasMore && <div ref={sentinelRef} className="sentinel">Loading more...</div>}
    </>
  );
}

10. Integrating Third-Party DOM Libraries

Chart.js integration

function ChartComponent({ data, options }) {
  const canvasRef = useRef(null);
  const chartRef = useRef(null);

  // Create chart on mount
  useEffect(() => {
    const ctx = canvasRef.current.getContext('2d');
    chartRef.current = new Chart(ctx, {
      type: 'line',
      data,
      options,
    });

    return () => {
      chartRef.current.destroy();  // Critical cleanup!
      chartRef.current = null;
    };
  }, []);  // Only create once

  // Update chart when data changes
  useEffect(() => {
    if (chartRef.current) {
      chartRef.current.data = data;
      chartRef.current.update();
    }
  }, [data]);

  // Update options separately
  useEffect(() => {
    if (chartRef.current) {
      chartRef.current.options = options;
      chartRef.current.update();
    }
  }, [options]);

  return <canvas ref={canvasRef} />;
}

Map library integration (Mapbox/Leaflet)

function MapView({ center, zoom, markers }) {
  const containerRef = useRef(null);
  const mapRef = useRef(null);

  // Initialize map
  useEffect(() => {
    mapRef.current = new mapboxgl.Map({
      container: containerRef.current,
      style: 'mapbox://styles/mapbox/streets-v12',
      center,
      zoom,
    });

    return () => {
      mapRef.current.remove();  // Clean up map instance
    };
  }, []);

  // Update center/zoom
  useEffect(() => {
    mapRef.current?.flyTo({ center, zoom });
  }, [center, zoom]);

  // Update markers
  useEffect(() => {
    const markerInstances = markers.map(({ lng, lat, popup }) => {
      return new mapboxgl.Marker()
        .setLngLat([lng, lat])
        .setPopup(new mapboxgl.Popup().setHTML(popup))
        .addTo(mapRef.current);
    });

    return () => {
      markerInstances.forEach(m => m.remove());
    };
  }, [markers]);

  return <div ref={containerRef} style={{ width: '100%', height: 400 }} />;
}

Pattern: Separate creation from updates

┌──────────────────────────────────────────────────────────┐
│  useEffect(() => {                                        │
│    instance = create(ref.current, config);                │
│    return () => instance.destroy();                       │
│  }, []);                          ← Create once            │
│                                                           │
│  useEffect(() => {                                        │
│    instance?.updateData(data);                            │
│  }, [data]);                      ← Update on data change  │
│                                                           │
│  useEffect(() => {                                        │
│    instance?.updateOptions(opts);                         │
│  }, [opts]);                      ← Update on options      │
└──────────────────────────────────────────────────────────┘

11. Observer APIs — IntersectionObserver, ResizeObserver, MutationObserver

IntersectionObserver — Detect visibility

function useIntersection(ref, options = {}) {
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const observer = new IntersectionObserver(([entry]) => {
      setIsVisible(entry.isIntersecting);
    }, options);

    observer.observe(element);

    return () => observer.disconnect();
  }, [ref, options.threshold, options.rootMargin]);

  return isVisible;
}

// Usage: Lazy load images
function LazyImage({ src, alt }) {
  const ref = useRef(null);
  const isVisible = useIntersection(ref, { threshold: 0.1 });
  const [loaded, setLoaded] = useState(false);

  return (
    <div ref={ref} className="lazy-image">
      {(isVisible || loaded) && (
        <img src={src} alt={alt} onLoad={() => setLoaded(true)} />
      )}
    </div>
  );
}

ResizeObserver — Track element size

function useElementSize(ref) {
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const observer = new ResizeObserver(([entry]) => {
      const { width, height } = entry.contentRect;
      setSize({ width, height });
    });

    observer.observe(element);

    return () => observer.disconnect();
  }, [ref]);

  return size;
}

// Usage
function ResponsiveChart({ data }) {
  const containerRef = useRef(null);
  const { width, height } = useElementSize(containerRef);

  return (
    <div ref={containerRef} style={{ width: '100%', height: '100%' }}>
      <Chart data={data} width={width} height={height} />
    </div>
  );
}

MutationObserver — Watch DOM changes

function useMutationObserver(ref, callback, options) {
  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const observer = new MutationObserver(callback);
    observer.observe(element, options);

    return () => observer.disconnect();
  }, [ref, callback, options]);
}

// Usage: Watch for external DOM changes (e.g., third-party widget)
function WidgetContainer() {
  const ref = useRef(null);

  useMutationObserver(
    ref,
    (mutations) => {
      for (const mutation of mutations) {
        console.log('DOM changed:', mutation.type);
      }
    },
    { childList: true, subtree: true, attributes: true }
  );

  return <div ref={ref} id="third-party-widget" />;
}

Observer cleanup pattern

All three observers share the same cleanup pattern:

useEffect(() => {
  const observer = new [Intersection|Resize|Mutation]Observer(callback, options);
  observer.observe(element);
  return () => observer.disconnect();  // Always disconnect!
}, [deps]);

12. WebSocket Connection Lifecycle

function useWebSocket(url, onMessage) {
  const [status, setStatus] = useState('disconnected');
  const wsRef = useRef(null);
  const reconnectTimerRef = useRef(null);
  const attemptsRef = useRef(0);

  const connect = useCallback(() => {
    const ws = new WebSocket(url);
    wsRef.current = ws;

    ws.onopen = () => {
      setStatus('connected');
      attemptsRef.current = 0;
    };

    ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      onMessage(data);
    };

    ws.onclose = (event) => {
      setStatus('disconnected');
      wsRef.current = null;

      // Auto-reconnect with exponential backoff
      if (!event.wasClean && attemptsRef.current < 5) {
        const delay = Math.min(1000 * Math.pow(2, attemptsRef.current), 30000);
        attemptsRef.current++;
        reconnectTimerRef.current = setTimeout(connect, delay);
      }
    };

    ws.onerror = () => {
      setStatus('error');
    };
  }, [url, onMessage]);

  useEffect(() => {
    connect();

    return () => {
      clearTimeout(reconnectTimerRef.current);
      if (wsRef.current) {
        wsRef.current.onclose = null;  // Prevent reconnect on intentional close
        wsRef.current.close();
      }
    };
  }, [connect]);

  const send = useCallback((data) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify(data));
    }
  }, []);

  return { status, send };
}

// Usage
function LiveChat({ roomId }) {
  const [messages, setMessages] = useState([]);

  const handleMessage = useCallback((data) => {
    setMessages(prev => [...prev, data]);
  }, []);

  const { status, send } = useWebSocket(
    `wss://api.example.com/chat/${roomId}`,
    handleMessage
  );

  return (
    <div>
      <span>Status: {status}</span>
      <MessageList messages={messages} />
      <MessageInput onSend={(text) => send({ type: 'message', text })} />
    </div>
  );
}

13. localStorage & sessionStorage Sync

Basic useLocalStorage hook

function useLocalStorage(key, initialValue) {
  // Initialize from storage or fallback
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  // Update storage when value changes
  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (err) {
      console.warn('Failed to save to localStorage:', err);
    }
  }, [key, storedValue]);

  return [storedValue, setStoredValue];
}

Cross-tab synchronisation

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  // Save to storage
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  // Listen for changes from OTHER tabs
  useEffect(() => {
    const handler = (event) => {
      if (event.key === key && event.newValue !== null) {
        try {
          setValue(JSON.parse(event.newValue));
        } catch {
          setValue(event.newValue);
        }
      }
    };

    window.addEventListener('storage', handler);
    return () => window.removeEventListener('storage', handler);
  }, [key]);

  return [value, setValue];
}

14. The Data Fetching Evolution

React's approach to data fetching has evolved significantly:

┌──────────────────────────────────────────────────────────────────┐
│                  EVOLUTION OF DATA FETCHING IN REACT              │
│                                                                   │
│  2015  componentDidMount + fetch                                  │
│        └── Manual loading/error/data states                       │
│        └── Race conditions, no caching                            │
│                                                                   │
│  2019  useEffect + fetch                                          │
│        └── Same problems, cleaner syntax                          │
│        └── AbortController for cancellation                       │
│                                                                   │
│  2020  React Query (TanStack Query) / SWR                         │
│        └── Automatic caching, refetching, deduplication           │
│        └── Loading/error states built-in                          │
│        └── Background refetching, optimistic updates              │
│                                                                   │
│  2023  React Server Components                                    │
│        └── Fetch on the server — no client-side loading states    │
│        └── Data available before component renders                │
│        └── No useEffect needed for initial data                   │
│                                                                   │
│  2024+ use() hook (React 19)                                      │
│        └── Read promises during render                            │
│        └── Works with Suspense for loading states                 │
└──────────────────────────────────────────────────────────────────┘

When to use what

ApproachWhen
useEffect + fetchSimple apps, learning, one-off fetches
TanStack Query / SWRProduction apps with client-side data
Server ComponentsNext.js apps, data available at build/request time
use() + SuspenseFuture standard for React 19+ apps

Why useEffect for fetching is problematic

// Problems with useEffect + fetch:
// 1. No caching — refetch on every mount
// 2. No deduplication — 5 components fetching same URL = 5 requests
// 3. Manual loading/error management — boilerplate in every component
// 4. No background refetching — stale data after tab switch
// 5. Waterfall problem — parent fetches, then child fetches
// 6. No prefetching — user has to wait after navigation

TanStack Query comparison

// ❌ useEffect approach (30+ lines of boilerplate)
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(res => { if (!res.ok) throw new Error(); return res.json(); })
      .then(data => { setUser(data); setLoading(false); })
      .catch(err => { if (err.name !== 'AbortError') { setError(err); setLoading(false); } });
    return () => controller.abort();
  }, [userId]);

  // ...
}

// ✅ TanStack Query (3 lines)
function UserProfile({ userId }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
  });

  // Automatic: caching, refetching, deduplication, retry, background refresh
}

Learn useEffect + fetch first to understand what's happening under the hood. Then use TanStack Query/SWR in production.


15. Best Practices Checklist

Data Fetching

  • Always handle loading, error, and empty states
  • Use AbortController to cancel requests on cleanup
  • Prevent race conditions with abort or ignore flags
  • Don't fetch in useEffect for production apps — use TanStack Query / SWR
  • Handle non-OK HTTP responses (check res.ok)
  • Use try/catch/finally for clean error handling

DOM Manipulation

  • Use useRef for DOM access, never document.querySelector
  • Use useLayoutEffect for DOM measurements that affect layout
  • Use forwardRef when a child needs to expose its DOM node
  • Clean up third-party library instances (.destroy(), .remove())

Cleanup

  • Every addEventListenerremoveEventListener
  • Every setIntervalclearInterval
  • Every setTimeoutclearTimeout
  • Every observer.observe()observer.disconnect()
  • Every WebSocket open → .close()
  • Every fetchAbortController.abort()

General Effect Hygiene

  • Don't use useEffect for derived state — compute during render
  • Don't use useEffect for event responses — handle in event handlers
  • Separate unrelated effects into multiple useEffect calls
  • Always include all dependencies (trust the ESLint rule)
  • Use updater functions (setX(prev => ...)) to avoid dependency on current state
  • Test with React Strict Mode to catch missing cleanups

16. Key Takeaways

  1. Production-quality fetching requires: loading state, error state, AbortController cancellation, race condition prevention, and proper HTTP error handling.

  2. AbortController is essential — it cancels in-flight requests on cleanup, preventing setState on unmounted components and saving bandwidth.

  3. Race conditions happen when slow responses arrive after fast ones. Prevent with AbortController.abort() or an ignore boolean flag.

  4. useRef is the bridge to the DOM — use it for measurements, focus, scroll, and third-party library containers.

  5. useLayoutEffect runs before paint — use it for DOM measurements that affect visual layout to prevent flicker.

  6. Observer APIs (Intersection, Resize, Mutation) all follow the same pattern: create, observe, disconnect on cleanup.

  7. WebSocket connections need proper lifecycle: connect on mount, auto-reconnect with exponential backoff, close on unmount.

  8. localStorage sync can include cross-tab synchronisation via the storage event listener.

  9. Data fetching has evolved: useEffect → TanStack Query → Server Components. Learn useEffect first, use libraries in production.

  10. The cleanup checklist is your safety net — every setup needs a corresponding teardown.


Explain-It Challenge

  1. The Walkie-Talkie: AbortController is like a walkie-talkie with a "cancel" button. When you send a message (fetch request) and then realise you need to change the channel (userId changes), you press "cancel" on the old walkie-talkie before starting a new one. What happens if you don't cancel — does the old message disappear or does it arrive at the wrong destination?

  2. The Hotel Room Service: Race conditions are like ordering room service in three different hotels in quick succession. Hotel 1 (slow kitchen) takes 3 hours, Hotel 2 takes 1 hour, Hotel 3 takes 2 hours. If you only want the food from Hotel 3 (the last one you ordered), how does AbortController ensure Hotels 1 and 2 stop cooking (saving resources), while the ignore flag merely tells you to throw their food away when it arrives?

  3. The Smart Mirror: IntersectionObserver is like a smart mirror that only activates when you step in front of it. The mirror doesn't constantly check "is someone there?" — it uses motion sensors to trigger only when visibility changes. How does this differ from a setInterval that checks getBoundingClientRect() every 100ms, and why is the Observer approach better for performance?


Navigation: ← useEffect Hook · Next → Exercise Questions