Introduction

Preface

This guide is written for you as the trainer. It covers every topic in the three-day intermediate React track with enough depth to handle follow-up questions confidently. Each section explains the concept plainly, shows focused code examples, flags common misconceptions, and includes Q&A blocks drawn from real classroom questions.

The guide is structured so you can add new topics at any time without disrupting existing content. To extend: add a new section with a chapter-label and h1, then add the sidebar links under a new sec label.

Assumed prior knowledge
Your audience has covered JSX, props, state basics (useState), and functional components. This guide picks up from there. Class components are covered in Chapter 3 for recognition purposes only.

Read each chapter the evening before that training day. The Q&A sections cover questions trainees almost always ask — reviewing them ensures nothing catches you off guard.


Day 1 · Chapter 1

Hooks in Depth

Hooks are functions that let you "hook into" React state and lifecycle from function components. They were introduced in React 16.8 as a replacement for class component lifecycle methods and to enable reuse of stateful logic across components without wrapping in higher-order components or render props.

This chapter covers the rules that govern all hooks, a deeper look at useState, then useEffect, useRef, useMemo, useCallback, and ends with useContext, useReducer, and custom hooks.

1.1 Rules of Hooks

React enforces two hard rules for hooks. These are not conventions — violating them silently corrupts state and produces bugs that are very difficult to trace.

Rule 1: Only call hooks at the top level

Never call hooks inside loops, conditions, nested functions, or after an early return. React maintains hook state as an ordered list internally. Every render must call the same hooks in the same order. If a hook call is conditional, the order can shift between renders and React loses track of which state belongs to which hook.

// WRONG — hook inside a condition
function Profile({ isLoggedIn }) {
  if (isLoggedIn) {
    const [name, setName] = useState(''); // ← order breaks if isLoggedIn changes
  }
  // ...
}

// WRONG — hook after an early return
function Profile({ user }) {
  if (!user) return null; // ← early return before hooks
  const [editing, setEditing] = useState(false); // hook never reached on first render if user is null
}

// CORRECT
function Profile({ isLoggedIn, user }) {
  const [name, setName]       = useState('');
  const [editing, setEditing] = useState(false);

  if (!user) return null;       // early returns go AFTER all hooks
  if (!isLoggedIn) return ...;  // conditions go AFTER all hooks
}

How React tracks hooks internally

React stores hook state in a linked list attached to the fiber (internal component instance). On every render, React walks that list in order, pairing each hook call to the corresponding node. If the call count or order differs between renders, the pairing breaks. This is why the rules exist — they are not arbitrary; they reflect how the internals work.

Rule 2: Only call hooks from React functions

Hooks can only be called from:

They cannot be called from plain utility functions, class components, event handlers defined outside components, or third-party non-React code.

The ESLint plugin

Install eslint-plugin-react-hooks in every React project. It enforces both rules statically and catches violations before runtime. The two rules it enforces are react-hooks/rules-of-hooks and react-hooks/exhaustive-deps. Both should be set to "error".

// .eslintrc
{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

1.2 useState (Revisited)

Your trainees have seen useState already. This section goes deeper: functional updates, lazy initialisation, and the common pitfall of treating state updates as synchronous.

State updates are asynchronous and batched

Calling a state setter does not update the variable immediately. React schedules a re-render. Reading state right after calling the setter still gives the old value.

function Counter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
    // count is still 0 in all three lines above
    // result: count becomes 1, not 3
  };
}

Functional updates

When new state depends on the previous value, pass a function to the setter. React guarantees this function always receives the latest state value, even if multiple updates are batched.

function Counter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
    // result: count becomes 3 — each updater sees the result of the previous one
  };
}

Use functional updates whenever the new state value is derived from the previous one: counters, toggles, appending to arrays.

// Toggle pattern
setIsOpen(prev => !prev);

// Append to array
setItems(prev => [...prev, newItem]);

// Remove from array
setItems(prev => prev.filter(item => item.id !== id));

// Update one item in array
setItems(prev => prev.map(item =>
  item.id === id ? { ...item, ...updates } : item
));

Lazy initialisation

If the initial state value is expensive to compute (reading from localStorage, parsing a large dataset), pass a function as the argument to useState. React calls the function once on mount and ignores it on re-renders.

// INEFFICIENT — reads localStorage on every render
const [settings, setSettings] = useState(
  JSON.parse(localStorage.getItem('settings') || '{}')
);

// CORRECT — lazy initialiser, called only once
const [settings, setSettings] = useState(() =>
  JSON.parse(localStorage.getItem('settings') || '{}')
);

State with objects and arrays

React state is immutable by convention. Never mutate state objects directly — always return a new object or array. React uses reference equality to detect changes; mutating an object and setting the same reference will not trigger a re-render.

// WRONG — mutates existing state
const [user, setUser] = useState({ name: 'Alice', age: 30 });

const handleBirthday = () => {
  user.age += 1;        // mutates directly
  setUser(user);        // same reference — React sees no change, no re-render
};

// CORRECT — new object
const handleBirthday = () => {
  setUser(prev => ({ ...prev, age: prev.age + 1 }));
};

// WRONG — mutates array
const [items, setItems] = useState([1, 2, 3]);
items.push(4);         // mutates directly
setItems(items);       // same reference — no re-render

// CORRECT — new array
setItems(prev => [...prev, 4]);
Common mistake
Spreading only one level deep ({ ...obj }) does not deep-clone. Nested objects are still shared by reference. For deeply nested state, either restructure to keep state flat, or use a library like Immer which lets you write "mutating" code that is actually immutable under the hood.

1.3 useEffect Hook

useEffect lets you synchronise a component with an external system — a server, the browser DOM, a timer, or a third-party library. It runs after the browser has painted, so it never blocks the UI.

Three patterns

Dependency arrayWhen the effect runsUse for
useEffect(() => { ... }) — omittedAfter every renderSyncing with something that changes every render (rare)
useEffect(() => { ... }, []) — emptyOnce after first renderMount-only setup: subscriptions, one-time fetches
useEffect(() => { ... }, [a, b])After mount + when a or b changesFetching data based on props, syncing to external state
// Pattern 1: runs after every render
useEffect(() => {
  document.title = `You clicked ${count} times`;
});

// Pattern 2: runs once on mount
useEffect(() => {
  const socket = new WebSocket('wss://example.com');
  return () => socket.close();
}, []);

// Pattern 3: runs when userId changes
useEffect(() => {
  fetchUser(userId).then(setUser);
}, [userId]);

Async inside effects

useEffect must not be declared async. An async function returns a Promise, and React expects the callback to return either nothing or a cleanup function. The solution is to define an async function inside the effect and call it immediately.

// WRONG — async effect
useEffect(async () => {
  const data = await fetchUser(userId); // async effect returns a Promise
  setUser(data);
}, [userId]);

// CORRECT — async function inside effect
useEffect(() => {
  let cancelled = false;

  const load = async () => {
    try {
      const data = await fetchUser(userId);
      if (!cancelled) setUser(data);
    } catch (err) {
      if (!cancelled) setError(err.message);
    }
  };

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

Using AbortController

For fetch requests specifically, AbortController is the modern cancellation mechanism. It cancels the in-flight network request rather than just suppressing the state update.

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

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then(res => res.json())
    .then(data => setUser(data))
    .catch(err => {
      if (err.name === 'AbortError') return; // intentionally cancelled
      setError(err.message);
    });

  return () => controller.abort(); // cancel the request on cleanup
}, [userId]);

1.4 Dependency Array

The dependency array is the most common source of bugs in React effects. Understanding it deeply prevents entire categories of problems.

Primitives vs reference types

React compares dependencies using Object.is, which is strict equality. Primitives (strings, numbers, booleans) compare by value. Objects and arrays compare by reference.

// PROBLEM: options is a new object literal on every render
function Component({ userId }) {
  useEffect(() => {
    fetchUser(userId, options);
  }, [userId, options]); // options is always a new reference → infinite loop

  const options = { includeProfile: true }; // new object every render
}

// SOLUTION 1: Move the object outside the component (if static)
const OPTIONS = { includeProfile: true }; // created once

function Component({ userId }) {
  useEffect(() => {
    fetchUser(userId, OPTIONS);
  }, [userId]); // OPTIONS never changes, so not needed in deps
}

// SOLUTION 2: Memoize it with useMemo (if it depends on props)
function Component({ userId, includeProfile }) {
  const options = useMemo(
    () => ({ includeProfile }),
    [includeProfile]
  );

  useEffect(() => {
    fetchUser(userId, options);
  }, [userId, options]); // options only changes when includeProfile changes
}

Functions as dependencies

Functions defined inside a component are recreated on every render — they are new references each time. If an effect depends on a function, that effect re-runs on every render.

// PROBLEM: fetchData is recreated on every render
function Component({ userId }) {
  const fetchData = () => fetch(`/api/${userId}`); // new reference every render

  useEffect(() => {
    fetchData();
  }, [fetchData]); // runs every render because fetchData is always new
}

// SOLUTION 1: Move the function inside the effect (most common fix)
function Component({ userId }) {
  useEffect(() => {
    const fetchData = () => fetch(`/api/${userId}`);
    fetchData();
  }, [userId]); // now depends only on userId, which is a primitive

}

// SOLUTION 2: Memoize with useCallback (when the function is used in multiple places)
function Component({ userId, onLoad }) {
  const fetchData = useCallback(() => {
    return fetch(`/api/${userId}`).then(r => r.json()).then(onLoad);
  }, [userId, onLoad]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);
}

The exhaustive-deps linter rule

The react-hooks/exhaustive-deps rule warns whenever a value used inside an effect is missing from the dependency array. The correct response to this warning is to add the missing dependency — not to suppress the warning or add // eslint-disable-line. Suppressing it hides real bugs.

Trainer tip
When trainees say "I added everything to the dependency array and now I get an infinite loop," that is the cue to explain reference stability — objects, arrays, and functions need to be stable references, not recreated on every render. This is where useMemo and useCallback become genuinely motivated, not abstract.

1.5 Cleanup Functions

Every effect that creates a resource must return a function that releases it. The cleanup function runs in two situations: before the next effect execution (when dependencies change), and when the component unmounts.

useEffect(() => {
  // SETUP — create the resource
  const resource = createSomething();

  return () => {
    // CLEANUP — release the resource
    resource.destroy();
  };
}, [dep]);

Common things that need cleanup

ResourceSetupCleanup
Event listeneraddEventListenerremoveEventListener
TimersetInterval / setTimeoutclearInterval / clearTimeout
WebSocketnew WebSocket()socket.close()
Observable/subscriptionsubscribe()unsubscribe()
Fetch requestfetch(url, { signal })controller.abort()
Intersection Observerobserver.observe(el)observer.disconnect()

Subscription pattern

function OnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  useEffect(() => {
    const goOnline  = () => setIsOnline(true);
    const goOffline = () => setIsOnline(false);

    window.addEventListener('online',  goOnline);
    window.addEventListener('offline', goOffline);

    return () => {
      window.removeEventListener('online',  goOnline);
      window.removeEventListener('offline', goOffline);
    };
  }, []); // empty: these events don't depend on any state/props

  return <span>{isOnline ? 'Online' : 'Offline'}</span>;
}

Stale closures

A stale closure occurs when an effect captures a variable from its creation scope, but that variable later changes in an outer render. The effect still holds the old value because closures in JavaScript capture by reference to the scope, not a live binding to state.

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // STALE: count is captured at 0 and never updates
    }, 1000);
    return () => clearInterval(id);
  }, []); // empty deps — count never updates inside here

  return <p>{count}</p> // stays 1 forever
}

// FIX: use functional update — no need to capture count at all
useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => prev + 1); // reads latest value via updater
  }, 1000);
  return () => clearInterval(id);
}, []);

1.6 useRef Hook

useRef(initialValue) returns { current: initialValue }. The object is stable — the same reference on every render. Mutating .current does not cause a re-render.

DOM access

// Auto-focus on mount
function SearchBar() {
  const inputRef = useRef(null);

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

  return <input ref={inputRef} type="search" />;
}

// Measure element dimensions
function MeasuredBox() {
  const boxRef = useRef(null);
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    const { width, height } = boxRef.current.getBoundingClientRect();
    setSize({ width, height });
  }, []);

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

// Scroll to bottom (e.g. chat window)
function ChatWindow({ messages }) {
  const bottomRef = useRef(null);

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

  return (
    <div className="chat">
      {messages.map(m => <p key={m.id}>{m.text}</p>)}
      <div ref={bottomRef} />
    </div>
  );
}

Mutable values without re-rendering

// Store a timer ID
function Debounced({ onSearch }) {
  const timerRef = useRef(null);

  const handleChange = (e) => {
    clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => {
      onSearch(e.target.value);
    }, 300);
  };

  return <input onChange={handleChange} />;
}

// Track whether the component is mounted
function SafeAsync() {
  const isMounted = useRef(true);
  const [data, setData] = useState(null);

  useEffect(() => {
    isMounted.current = true;
    fetchData().then(d => {
      if (isMounted.current) setData(d);
    });
    return () => { isMounted.current = false; };
  }, []);
}

Storing previous values

A common pattern is to store the previous value of a prop or state to compare with the current value.

function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value; // runs after render, so ref holds previous value during render
  });
  return ref.current;
}

function Counter({ count }) {
  const prevCount = usePrevious(count);
  return (
    <p>
      Current: {count}, Previous: {prevCount}
    </p>
  );
}

1.7 useMemo & useCallback

useMemo — memoize a computed value

// Expensive computation — only re-runs when list or filter changes
function FilteredList({ list, filter, sortBy }) {
  const processed = useMemo(() => {
    console.log('Computing filtered list...');
    return list
      .filter(item => item.name.toLowerCase().includes(filter.toLowerCase()))
      .sort((a, b) => a[sortBy] > b[sortBy] ? 1 : -1);
  }, [list, filter, sortBy]);

  return <ul>{processed.map(item => <li key={item.id}>{item.name}</li>)}</ul>;
}

useCallback — memoize a function reference

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [filter, setFilter] = useState('all');

  // Stable reference — only changes if setTodos changes (it never does)
  const addTodo = useCallback((text) => {
    setTodos(prev => [...prev, { id: Date.now(), text, done: false }]);
  }, []);

  // Stable reference — only changes if filter changes
  const toggleTodo = useCallback((id) => {
    setTodos(prev =>
      prev.map(t => t.id === id ? { ...t, done: !t.done } : t)
    );
  }, []);

  return (
    <>
      <AddTodoForm onAdd={addTodo} />      {/* won't re-render due to addTodo */}
      <TodoItem onToggle={toggleTodo} />  {/* won't re-render due to toggleTodo */}
    </>
  );
}

When to use — decision guide

SituationUseReason
Filtering/sorting a large arrayuseMemoAvoids recomputing on unrelated re-renders
Passing a callback to a memoized childuseCallbackKeeps the child's props stable
Creating an object passed as dep to useEffectuseMemoPrevents effect from re-running every render
A function used inside useEffect depsuseCallbackStabilises the function reference
Simple string/number derivationNeitherCheaper to just compute it
Small component treesNeitherMemoization overhead outweighs savings

React.memo

React.memo is a higher-order component that prevents a component from re-rendering if its props have not changed (shallow comparison). It is most effective when combined with useCallback for callback props.

// Child only re-renders if name or onClick changes
const UserCard = React.memo(({ name, onClick }) => {
  console.log('UserCard rendered');
  return <div onClick={onClick}>{name}</div>;
});

function Parent() {
  const [count, setCount] = useState(0);
  const [users, setUsers] = useState(['Alice', 'Bob']);

  // Without useCallback, handleClick is a new function on every render
  // → UserCard would re-render even though name/onClick haven't meaningfully changed
  const handleClick = useCallback((name) => {
    console.log(name, 'clicked');
  }, []);

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      {users.map(name =>
        <UserCard key={name} name={name} onClick={handleClick} />
      )}
    </>
  );
}

1.8 Custom Hooks

A custom hook is a function whose name starts with use and which calls other hooks inside it. Custom hooks extract stateful logic so it can be shared and tested independently, without changing component hierarchy.

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

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    setError(null);

    fetch(url)
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(data => {
        if (!cancelled) { setData(data); setLoading(false); }
      })
      .catch(err => {
        if (!cancelled) { setError(err.message); setLoading(false); }
      });

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

  return { data, loading, error };
}

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

  if (loading) return <p>Loading...</p>;
  if (error)   return <p>Error: {error}</p>;
  return <h1>{user.name}</h1>;
}

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

  const setStoredValue = useCallback((newValue) => {
    setValue(newValue);
    localStorage.setItem(key, JSON.stringify(newValue));
  }, [key]);

  return [value, setStoredValue];
}

// useDebounce — delay reacting to rapid value changes
function useDebounce(value, delay) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);

  return debounced;
}

// useWindowSize — track viewport dimensions
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    const handler = () => setSize({ width: window.innerWidth, height: window.innerHeight });
    window.addEventListener('resize', handler);
    return () => window.removeEventListener('resize', handler);
  }, []);

  return size;
}
Teaching point
Custom hooks are how React encourages code reuse. The key insight is that hooks share logic, not state — each component that calls a custom hook gets its own isolated state. Two components both calling useFetch have completely independent loading/data/error state.

1.9 useContext

useContext lets a component read a context value without passing it as a prop through every intermediate component. It is the solution to "prop drilling" — passing the same data through many layers of components that don't need it themselves.

// 1. Create the context
const ThemeContext = React.createContext('light');

// 2. Provide the value high in the tree
function App() {
  const [theme, setTheme] = useState('light');
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Page />
    </ThemeContext.Provider>
  );
}

// 3. Consume anywhere in the tree — no props needed
function Button() {
  const { theme, setTheme } = useContext(ThemeContext);
  return (
    <button
      style={{ background: theme === 'dark' ? '#333' : '#fff' }}
      onClick={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
    >
      Toggle theme
    </button>
  );
}
Context and performance
Every component that consumes a context re-renders when the context value changes — even if the part of the value they use hasn't changed. For large apps, split contexts into smaller pieces (e.g., separate UserContext from ThemeContext) or use useMemo to stabilise the context value object.

1.10 useReducer

useReducer is an alternative to useState for complex state logic. It takes a reducer function and an initial state, and returns the current state and a dispatch function. It is particularly useful when the next state depends on the previous state in complex ways, or when multiple state variables are closely related.

// Reducer — pure function: (state, action) => newState
function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      const existing = state.items.find(i => i.id === action.payload.id);
      if (existing) {
        return {
          ...state,
          items: state.items.map(i =>
            i.id === action.payload.id ? { ...i, qty: i.qty + 1 } : i
          )
        };
      }
      return { ...state, items: [...state.items, { ...action.payload, qty: 1 }] };

    case 'REMOVE_ITEM':
      return { ...state, items: state.items.filter(i => i.id !== action.payload) };

    case 'CLEAR':
      return { ...state, items: [] };

    default:
      return state;
  }
}

function ShoppingCart() {
  const [cart, dispatch] = useReducer(cartReducer, { items: [] });

  return (
    <div>
      {cart.items.map(item => (
        <div key={item.id}>
          {item.name} x{item.qty}
          <button onClick={() => dispatch({ type: 'REMOVE_ITEM', payload: item.id })}>
            Remove
          </button>
        </div>
      ))}
      <button onClick={() => dispatch({ type: 'CLEAR' })}>Clear cart</button>
    </div>
  );
}
SituationPrefer
Simple, independent valuesuseState
Multiple related fields that change togetheruseReducer
Next state depends on previous in complex waysuseReducer
You want to test state logic in isolationuseReducer — reducer is a pure function, easy to unit test
State transitions with named actions (like Redux)useReducer

Q & A — Hooks

Why does setting state not update the value immediately? ▾
State setters in React are not synchronous assignments. Calling setState schedules a re-render. The current variable binding holds the value from the current render — it doesn't live-update. To read state after setting it, you need to wait for the next render. If new state depends on old state, always use the functional updater form: setState(prev => prev + 1).
Can I call a hook conditionally if I always call it in the same way? ▾
No. Even if your condition never changes, the rule is unconditional: hooks must always be called in the same order. The reason is that React does not read your condition — it simply counts hook calls by position. If code structure ever changes (refactoring, new branches), a conditional hook will break. The linter enforces this to protect against subtle future breakage.
Why does useEffect run twice in development? ▾
In React 18 Strict Mode, React intentionally mounts, unmounts, and remounts components in development to surface effects that don't clean up properly. This does not happen in production. If your effect is broken by two runs, it means it leaks resources or doesn't have a cleanup function. The correct fix is to write a proper cleanup, not to remove Strict Mode.
When should I use useReducer instead of useState? ▾
Use useReducer when state logic becomes complex enough that multiple useState calls need to be updated together, or when the next state depends on the previous in non-trivial ways. It also makes state transitions explicit and named (via action types), which improves readability. A good rule of thumb: if your useState state is an object with 3+ fields that change together, consider useReducer.
Is it bad practice to use an async function directly in useEffect? ▾
Yes. An async function always returns a Promise. React expects the callback to return either nothing or a cleanup function — not a Promise. The pattern is: define an async function inside the effect, call it immediately. Always include a cancellation flag or AbortController so stale responses don't update state after the component unmounts or the dependency changes.
What is a stale closure in the context of useEffect? ▾
A stale closure is when an effect captures a variable (from its outer scope) that later changes, but the effect still holds the old value. This is especially common with intervals and timeouts. The fix is to use the functional updater form for state updates (setState(prev => ...)), or to include the variable in the dependency array so the effect re-runs with the fresh value.
What is the difference between useRef and createRef? ▾
useRef returns the same object on every render — it persists across re-renders. createRef creates a brand new ref object on every call. Inside function components, always use useRef. createRef is for class components, where it was called in the constructor and stored on this. Using createRef in a function component loses the ref value on every render.
Can custom hooks share state between components? ▾
No — each component that calls a custom hook gets its own isolated copy of the state. Custom hooks share logic, not state. Two components both calling useFetch('/api/users') each have independent loading, data, and error state. If you want genuinely shared state, use Context or an external state manager like Zustand or Redux.
Should I always wrap callbacks with useCallback? ▾
No. useCallback is only useful when the function is passed as a prop to a component wrapped in React.memo, or when the function appears in a useEffect dependency array. In all other cases, wrapping with useCallback adds overhead without any benefit — you're paying the cost of memoization for no gain. Profile first, optimise second.

Day 2 · Chapter 2

Forms and Validation

Forms are where most real-world React complexity lives. This day covers controlled and uncontrolled inputs in depth, handling every input type, building robust validation patterns, extracting logic into custom hooks, async form submission, and when to reach for a form library.

2.1 Controlled vs Uncontrolled Forms

Controlled components — in depth

In a controlled component, React state is the single source of truth. The input's value is always driven by state, and every change goes through a handler that updates state. The input can never diverge from what is in state.

function ControlledInput() {
  const [value, setValue] = useState('');

  return (
    <input
      value={value}                          // state drives the display
      onChange={(e) => setValue(e.target.value)} // every keystroke updates state
    />
  );
}

// What React guarantees: value in state === value displayed

The consequence: you always know what is in every field. You can validate on every keystroke, conditionally disable the submit button, format the input in real time, and synchronise fields with each other.

// Real-time formatting: phone number
function PhoneInput() {
  const [value, setValue] = useState('');

  const format = (raw) => {
    const digits = raw.replace(/\D/g, '').slice(0, 10);
    if (digits.length < 4) return digits;
    if (digits.length < 7) return `(${digits.slice(0,3)}) ${digits.slice(3)}`;
    return `(${digits.slice(0,3)}) ${digits.slice(3,6)}-${digits.slice(6)}`;
  };

  return (
    <input
      type="tel"
      value={value}
      onChange={e => setValue(format(e.target.value))}
      placeholder="(555) 555-5555"
    />
  );
}

Uncontrolled components — in depth

In an uncontrolled component, the DOM owns the value. React reads it via a ref on demand. React does not re-render on every keystroke, so these can be more performant for very large forms where re-renders are costly.

function UncontrolledForm() {
  const nameRef     = useRef(null);
  const emailRef    = useRef(null);

  const handleSubmit = (e) => {
    e.preventDefault();
    const formData = {
      name:  nameRef.current.value,
      email: emailRef.current.value,
    };
    console.log(formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input ref={nameRef}  name="name"  defaultValue="" />
      <input ref={emailRef} name="email" defaultValue="" />
      <button type="submit">Submit</button>
    </form>
  );
}

// Alternative: use the native FormData API (no refs needed)
function FormDataExample() {
  const handleSubmit = (e) => {
    e.preventDefault();
    const data = Object.fromEntries(new FormData(e.target));
    console.log(data); // { name: '...', email: '...' }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" />
      <input name="email" />
      <button type="submit">Submit</button>
    </form>
  );
}

When to use each

RequirementControlledUncontrolled
Real-time validation✗ (submit only)
Conditionally disable submitAwkward
Format input as user types
Dependent fields (field B changes when A changes)
File inputs✗ (React cannot set file value)
Integrating third-party rich text editorsDifficult
Very large forms where re-render cost is visibleManageable✓ (React Hook Form approach)

2.2 Handling Multiple Inputs

The computed property key pattern ([name]: value) lets a single handler control every text-like input as long as the name attribute matches the state key.

function RegistrationForm() {
  const [form, setForm] = useState({
    firstName: '',
    lastName: '',
    email: '',
    password: '',
  });

  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;
    setForm(prev => ({
      ...prev,
      [name]: type === 'checkbox' ? checked : value
    }));
  };

  return (
    <form>
      <input name="firstName" value={form.firstName} onChange={handleChange} />
      <input name="lastName"  value={form.lastName}  onChange={handleChange} />
      <input name="email"     value={form.email}     onChange={handleChange} type="email" />
      <input name="password"  value={form.password}  onChange={handleChange} type="password" />
    </form>
  );
}

Select and Radio inputs

// Select (dropdown)
function RoleSelect() {
  const [role, setRole] = useState('viewer');
  return (
    <select name="role" value={role} onChange={e => setRole(e.target.value)}>
      <option value="admin">Admin</option>
      <option value="editor">Editor</option>
      <option value="viewer">Viewer</option>
    </select>
  );
}

// Radio group
function PlanSelector() {
  const [plan, setPlan] = useState('free');
  return (
    <fieldset>
      {['free', 'pro', 'enterprise'].map(p => (
        <label key={p}>
          <input
            type="radio"
            name="plan"
            value={p}
            checked={plan === p}
            onChange={e => setPlan(e.target.value)}
          />
          {p}
        </label>
      ))}
    </fieldset>
  );
}

Checkbox arrays

When multiple checkboxes map to an array of selected values (not a single boolean), the handler needs to add or remove values from the array.

function PermissionsForm() {
  const [permissions, setPermissions] = useState([]);

  const handleCheckbox = (e) => {
    const { value, checked } = e.target;
    setPermissions(prev =>
      checked
        ? [...prev, value]               // add when checked
        : prev.filter(p => p !== value)  // remove when unchecked
    );
  };

  const allPerms = ['read', 'write', 'delete', 'admin'];

  return (
    <fieldset>
      {allPerms.map(perm => (
        <label key={perm}>
          <input
            type="checkbox"
            value={perm}
            checked={permissions.includes(perm)}
            onChange={handleCheckbox}
          />
          {perm}
        </label>
      ))}
      <p>Selected: {permissions.join(', ')}</p>
    </fieldset>
  );
}

2.3 Form Validation Basics

Validation is about giving users accurate feedback at the right time — not too early (before they've had a chance to type), not too late (only after submit).

Submit-only validation (simplest)

function LoginForm() {
  const [form, setForm]     = useState({ email: '', password: '' });
  const [errors, setErrors] = useState({});

  const validate = (values) => {
    const errs = {};
    if (!values.email)                    errs.email = 'Email is required.';
    else if (!/\S+@\S+\.\S+/.test(values.email)) errs.email = 'Invalid email format.';

    if (!values.password)                 errs.password = 'Password is required.';
    else if (values.password.length < 8)  errs.password = 'Minimum 8 characters.';

    return errs;
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    const errs = validate(form);
    setErrors(errs);
    if (Object.keys(errs).length === 0) {
      submitToServer(form);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input name="email" value={form.email}
          onChange={e => setForm(p => ({ ...p, email: e.target.value }))} />
        {errors.email && <p style={{color:'red'}}>{errors.email}</p>}
      </div>
      <div>
        <input name="password" type="password" value={form.password}
          onChange={e => setForm(p => ({ ...p, password: e.target.value }))} />
        {errors.password && <p style={{color:'red'}}>{errors.password}</p>}
      </div>
      <button type="submit">Login</button>
    </form>
  );
}

On-change validation

Validate on change after the first submit attempt. Before that, only validate on blur (when the field loses focus). This is the best user experience: no premature errors while typing, but instant feedback when correcting.

function SignupForm() {
  const [form, setForm]         = useState({ email: '', password: '' });
  const [errors, setErrors]     = useState({});
  const [hasSubmitted, setHasSubmitted] = useState(false);

  const validate = (values) => {
    const errs = {};
    if (!/\S+@\S+\.\S+/.test(values.email)) errs.email = 'Invalid email.';
    if (values.password.length < 8)          errs.password = 'Min 8 characters.';
    return errs;
  };

  const handleChange = (e) => {
    const updated = { ...form, [e.target.name]: e.target.value };
    setForm(updated);
    if (hasSubmitted) setErrors(validate(updated)); // re-validate only after first submit
  };

  const handleBlur = (e) => {
    // Validate the field that just lost focus
    const fieldErrors = validate(form);
    setErrors(prev => ({ ...prev, [e.target.name]: fieldErrors[e.target.name] }));
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    setHasSubmitted(true);
    const errs = validate(form);
    setErrors(errs);
    if (Object.keys(errs).length === 0) submitToServer(form);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" value={form.email}
        onChange={handleChange} onBlur={handleBlur} />
      {errors.email && <p>{errors.email}</p>}

      <input name="password" type="password" value={form.password}
        onChange={handleChange} onBlur={handleBlur} />
      {errors.password && <p>{errors.password}</p>}

      <button type="submit">Sign Up</button>
    </form>
  );
}

Touched fields pattern

A more granular approach: track which fields the user has interacted with ("touched"). Show validation errors only for touched fields.

function ToucledForm() {
  const [form, setForm]     = useState({ email: '', age: '' });
  const [touched, setTouched] = useState({});
  const [errors, setErrors] = useState({});

  const validate = (values) => {
    const errs = {};
    if (!values.email.includes('@'))  errs.email = 'Invalid email.';
    if (isNaN(values.age) || values.age < 18) errs.age = 'Must be 18+.';
    return errs;
  };

  const handleChange = (e) => {
    const updated = { ...form, [e.target.name]: e.target.value };
    setForm(updated);
    setErrors(validate(updated));
  };

  const handleBlur = (e) => {
    setTouched(prev => ({ ...prev, [e.target.name]: true }));
  };

  // Only show error if field has been touched
  const showError = (field) => touched[field] && errors[field];

  return (
    <form>
      <input name="email" value={form.email}
        onChange={handleChange} onBlur={handleBlur} />
      {showError('email') && <p>{errors.email}</p>}

      <input name="age" value={form.age}
        onChange={handleChange} onBlur={handleBlur} />
      {showError('age') && <p>{errors.age}</p>}
    </form>
  );
}

Schema validation with Zod

For production forms, schema validation libraries are worth using. Zod is the most popular in the React ecosystem. It defines validation rules as a typed schema and produces structured error objects.

import { z } from 'zod';

const schema = z.object({
  email:    z.string().email('Invalid email address'),
  password: z.string().min(8, 'Minimum 8 characters')
             .regex(/[A-Z]/, 'Must contain an uppercase letter')
             .regex(/[0-9]/, 'Must contain a number'),
  age:      z.number().min(18, 'Must be at least 18'),
});

// Validate in a handler
const handleSubmit = (e) => {
  e.preventDefault();
  const result = schema.safeParse(form);
  if (!result.success) {
    const formatted = result.error.format();
    setErrors({
      email:    formatted.email?._errors[0],
      password: formatted.password?._errors[0],
      age:      formatted.age?._errors[0],
    });
    return;
  }
  submitToServer(result.data);
};

2.4 Custom Form Handling

useForm hook — full implementation

function useForm({ initialValues, validate, onSubmit }) {
  const [values, setValues]     = useState(initialValues);
  const [errors, setErrors]     = useState({});
  const [touched, setTouched]   = useState({});
  const [submitting, setSubmitting] = useState(false);
  const [submitError, setSubmitError] = useState(null);

  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;
    const newValue = type === 'checkbox' ? checked : value;
    const updated  = { ...values, [name]: newValue };
    setValues(updated);
    if (touched[name] && validate) {
      setErrors(prev => ({ ...prev, ...validate(updated) }));
    }
  };

  const handleBlur = (e) => {
    const { name } = e.target;
    setTouched(prev => ({ ...prev, [name]: true }));
    if (validate) {
      const errs = validate(values);
      setErrors(prev => ({ ...prev, [name]: errs[name] }));
    }
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    const allTouched = Object.keys(initialValues).reduce(
      (acc, k) => ({ ...acc, [k]: true }), {}
    );
    setTouched(allTouched);

    const errs = validate ? validate(values) : {};
    setErrors(errs);
    if (Object.keys(errs).length > 0) return;

    setSubmitting(true);
    setSubmitError(null);
    try {
      await onSubmit(values);
    } catch (err) {
      setSubmitError(err.message);
    } finally {
      setSubmitting(false);
    }
  };

  const reset = () => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
    setSubmitting(false);
    setSubmitError(null);
  };

  const getFieldProps = (name) => ({
    name,
    value: values[name],
    onChange: handleChange,
    onBlur: handleBlur,
  });

  return {
    values, errors, touched, submitting, submitError,
    handleChange, handleBlur, handleSubmit, reset, getFieldProps,
  };
}

// ── Usage ──────────────────────────────────────────────────────
function ContactForm() {
  const form = useForm({
    initialValues: { name: '', email: '', message: '' },
    validate: (v) => {
      const e = {};
      if (!v.name)                  e.name    = 'Name is required.';
      if (!/\S+@\S+/.test(v.email)) e.email   = 'Invalid email.';
      if (v.message.length < 10)    e.message = 'Too short.';
      return e;
    },
    onSubmit: async (values) => {
      await fetch('/api/contact', {
        method: 'POST',
        body: JSON.stringify(values),
        headers: { 'Content-Type': 'application/json' },
      });
    },
  });

  const showError = (name) =>
    form.touched[name] && form.errors[name]
      ? <p style={{color:'red'}}>{form.errors[name]}</p>
      : null;

  return (
    <form onSubmit={form.handleSubmit}>
      <input {...form.getFieldProps('name')} placeholder="Name" />
      {showError('name')}

      <input {...form.getFieldProps('email')} placeholder="Email" type="email" />
      {showError('email')}

      <textarea {...form.getFieldProps('message')} placeholder="Message" />
      {showError('message')}

      {form.submitError && <p style={{color:'red'}}>{form.submitError}</p>}

      <button type="submit" disabled={form.submitting}>
        {form.submitting ? 'Sending...' : 'Send'}
      </button>
    </form>
  );
}

Dynamic fields

Some forms require fields that can be added or removed — like adding multiple phone numbers or line items in an invoice.

function PhoneNumbers() {
  const [phones, setPhones] = useState(['']);

  const add = () => setPhones(prev => [...prev, '']);

  const remove = (index) =>
    setPhones(prev => prev.filter((_, i) => i !== index));

  const update = (index, value) =>
    setPhones(prev => prev.map((p, i) => i === index ? value : p));

  return (
    <div>
      {phones.map((phone, index) => (
        <div key={index}>
          <input
            value={phone}
            onChange={e => update(index, e.target.value)}
            placeholder={`Phone ${index + 1}`}
          />
          {phones.length > 1 && (
            <button type="button" onClick={() => remove(index)}>Remove</button>
          )}
        </div>
      ))}
      <button type="button" onClick={add}>Add phone</button>
    </div>
  );
}

2.5 Async Submission

Production form submissions involve network requests. Handle the three states: submitting (loading), success, and error.

function RegisterForm() {
  const [form, setForm] = useState({ email: '', password: '' });
  const [status, setStatus] = useState('idle'); // 'idle' | 'submitting' | 'success' | 'error'
  const [serverError, setServerError] = useState('');

  const handleSubmit = async (e) => {
    e.preventDefault();
    setStatus('submitting');
    setServerError('');

    try {
      const res = await fetch('/api/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(form),
      });

      if (!res.ok) {
        const { message } = await res.json();
        throw new Error(message || 'Registration failed');
      }

      setStatus('success');
    } catch (err) {
      setStatus('error');
      setServerError(err.message);
    }
  };

  if (status === 'success') {
    return <p>Registration successful! Check your email.</p>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" value={form.email}
        onChange={e => setForm(p => ({...p, email: e.target.value}))} />
      <input name="password" type="password" value={form.password}
        onChange={e => setForm(p => ({...p, password: e.target.value}))} />

      {status === 'error' && <p style={{color:'red'}}>{serverError}</p>}

      <button type="submit" disabled={status === 'submitting'}>
        {status === 'submitting' ? 'Registering...' : 'Register'}
      </button>
    </form>
  );
}

2.6 Form Libraries Overview

LibraryApproachBest forKey benefit
React Hook FormUncontrolled internally, minimal re-rendersPerformance-sensitive, large formsVery few re-renders; small bundle
FormikControlled, renders on every changeForms with complex logic, legacy codebasesMature, well-documented, large ecosystem
TanStack FormType-safe first, framework-agnosticTypeScript-heavy projectsBest TypeScript inference
Custom hookWhatever you buildSimple forms, full controlNo dependency, full understanding
Tell trainees
React Hook Form is the current industry standard for new projects. It uses uncontrolled inputs internally (registered with refs) and only re-renders when needed, which makes it fast. The concepts from this chapter — validation, touched state, submit handling — map directly to React Hook Form's API. Understanding the manual implementation first makes the library's design transparent.

Q & A — Forms

Why do we call e.preventDefault() on form submit? ▾
The default browser behaviour for a form submit is to navigate to the form's action URL, which reloads the page and destroys all React state. e.preventDefault() stops that so we can handle submission in JavaScript. Always call it first in the submit handler, before any async logic.
Why does the value prop make an input "controlled"? What happens if I set value without onChange? ▾
When you set value on an input, React takes over rendering that input. It always renders whatever is in value. If you don't provide an onChange handler, the displayed value can never change because React always overwrites it with the same state value. React will warn about this in the console: "You provided a value prop without an onChange handler." Either add an onChange or use defaultValue for an uncontrolled input.
Can I mix controlled and uncontrolled inputs? ▾
Avoid mixing them in the same form. It makes the code confusing and validation harder. The main legitimate exception is file inputs — they must be uncontrolled because browsers do not allow scripts to set a file input's value. For file inputs, use a ref to access the selected file. Everything else should be consistently controlled.
How do I show a "field required" error only after the user has interacted with a field? ▾
Use the "touched" pattern. Maintain a touched state object that tracks which fields the user has blurred (moved focus away from). In the onBlur handler, mark the field as touched. Only render error messages for fields that are in the touched set. This prevents the jarring experience of seeing "required" errors before the user has even tried to fill the form.
How do I handle a form with 20+ fields without making the code unmanageable? ▾
First, use the single-state-object pattern with a shared handleChange handler — that keeps most field handling to one function. Second, split the form into multiple logical steps (multi-step form) if possible. Third, extract the form state and validation into a custom hook. For truly large forms, React Hook Form is specifically designed for this and handles performance well with uncontrolled inputs.
How do I handle file uploads? ▾
File inputs must be uncontrolled — you cannot programmatically set their value. Use a ref to access the file:
const fileRef = useRef(null);
const handleSubmit = () => {
  const file = fileRef.current.files[0];
  const formData = new FormData();
  formData.append('avatar', file);
  fetch('/api/upload', { method: 'POST', body: formData });
};
return <input type="file" ref={fileRef} />;
For controlled-style UI (preview, clear button), maintain separate state for the file name/preview URL, but use a ref for the actual file input.
What is the difference between defaultValue and value? ▾
value makes the input controlled — React manages the displayed value. defaultValue sets the initial value of an uncontrolled input — the DOM manages it after that. Using value without onChange creates a read-only input. Using defaultValue allows the DOM to change freely. Never use both on the same input.
How do I reset a form after successful submission? ▾
With controlled components, reset the state to initialValues: setForm(initialValues). Also reset the errors, touched, and submitted state. If you built a custom hook like useForm, expose a reset() function that restores all state to initial. With React Hook Form, call reset() from the returned methods.

Day 3 · Chapter 3

Component Lifecycle

Every React component goes through a predictable lifecycle: mount (created and inserted into the DOM), update (re-rendered due to state or prop changes), and unmount (removed from the DOM). Understanding this lifecycle precisely is essential for writing effects, subscriptions, and animations that behave correctly.

3.1 Lifecycle Methods in Class Components

Class components expose lifecycle as named methods on the class. You override the ones you need. Knowing these is important because: (a) legacy codebases use them extensively, and (b) each maps directly to a useEffect pattern, making the relationship between old and new APIs concrete.

Mount phase methods

class DataComponent extends React.Component {
  constructor(props) {
    // Called first. Set initial state. Do not call fetch here.
    super(props);
    this.state = {
      data: null,
      loading: false,
      error: null,
    };
  }

  static getDerivedStateFromProps(props, state) {
    // Called before every render (mount and update).
    // Returns an object to merge into state, or null.
    // Rarely needed — exists for cases where state derives from props.
    return null;
  }

  componentDidMount() {
    // Called once, after the component's output is rendered and committed to DOM.
    // The DOM is ready here. Safe to read layout, start subscriptions, fetch data.
    this.setState({ loading: true });
    fetch(`/api/data/${this.props.id}`)
      .then(res => res.json())
      .then(data => this.setState({ data, loading: false }))
      .catch(err => this.setState({ error: err.message, loading: false }));
  }

  render() {
    // Must be a pure function of this.props and this.state.
    // No side effects here. Return JSX (or null to render nothing).
    const { data, loading, error } = this.state;
    if (loading) return <p>Loading...</p>;
    if (error)   return <p>Error: {error}</p>;
    return <div>{data?.name}</div>;
  }
}

Update phase methods

class TrackingComponent extends React.Component {
  shouldComponentUpdate(nextProps, nextState) {
    // Called before re-render when props or state change.
    // Return false to skip the re-render.
    // Equivalent to React.memo for class components.
    return nextProps.id !== this.props.id || nextState.count !== this.state.count;
  }

  getSnapshotBeforeUpdate(prevProps, prevState) {
    // Called right before the DOM is updated.
    // The return value is passed as the third argument to componentDidUpdate.
    // Use case: capture scroll position before a DOM update.
    const list = this.listRef.current;
    return list ? list.scrollHeight - list.scrollTop : null;
  }

  componentDidUpdate(prevProps, prevState, snapshot) {
    // Called after every re-render. First render doesn't trigger this.
    // Always compare prevProps/prevState before doing work, or you'll infinite loop.

    if (prevProps.id !== this.props.id) {
      // id changed — fetch new data
      this.fetchData(this.props.id);
    }

    if (snapshot !== null) {
      // Restore scroll position using the snapshot
      const list = this.listRef.current;
      list.scrollTop = list.scrollHeight - snapshot;
    }
  }

  componentWillUnmount() {
    // Called just before the component is removed from the DOM.
    // Cancel all subscriptions, timers, pending requests.
    clearInterval(this.intervalId);
    this.subscription.unsubscribe();
  }
}

Error boundaries

Error boundaries are class components that catch JavaScript errors in their child tree. There is no hook equivalent — if you need error boundaries in a functional component app, you still write a class component for this one purpose.

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    // Update state to show fallback UI
    return { hasError: true, error };
  }

  componentDidCatch(error, info) {
    // Log error to an error reporting service
    console.error('Caught error:', error, info.componentStack);
    logErrorToService(error, info);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || <h2>Something went wrong.</h2>;
    }
    return this.props.children;
  }
}

// Usage — wrap any subtree that might throw
<ErrorBoundary fallback={<p>Failed to load component.</p>}>
  <RiskyComponent />
</ErrorBoundary>
React 19 note
React 19 introduced a new use API and improved error handling. The react-error-boundary npm package provides a hook-friendly wrapper around class error boundaries and is widely used in production apps.

3.2 Component Mounting, Updating, Unmounting

Mounting

Mounting is when React creates a component instance and inserts its output into the real DOM for the first time. React calls the component function, produces JSX output, calculates what DOM nodes to create, creates them, and inserts them. After the DOM is updated, useEffect callbacks run (after the browser paints).

Updating

A component re-renders when:

Re-rendering is not the same as DOM update. React renders the component function to get new output, diffs it against the previous output (reconciliation), and only updates the DOM nodes that actually changed. Rendering can be fast even when DOM updates are sparse.

Unmounting

Unmounting happens when a component is removed from the tree — typically because a condition became false, a route changed, or the parent removed it from a list. React removes the DOM nodes and calls all cleanup functions from active effects.

Strict Mode behaviour

In React 18 with Strict Mode, React mounts → unmounts → remounts every component in development. This is intentional: it surfaces effects that don't clean up properly. Your cleanup functions must completely undo what setup did, so the component behaves identically on the second mount.

// This is fine — cleanup fully reverses setup
useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id); // complete reversal
}, []);

// This breaks on second mount — the counter is incremented twice
useEffect(() => {
  fetch('/api/increment-view-count'); // no way to "undo" this request
  // no cleanup
}, []);

State batching

React 18 introduced automatic batching: multiple state updates in the same event handler, timeout, or promise callback are batched into a single re-render. Before React 18, only updates inside event handlers were batched.

// React 18: all three updates batched → one re-render
const handleClick = () => {
  setCount(c => c + 1);
  setFlag(f => !f);
  setName('Alice');
  // only one re-render
};

// React 18: batched even in async code
setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // one re-render (was two re-renders in React 17)
}, 1000);

// To opt OUT of batching (rare):
import { flushSync } from 'react-dom';
flushSync(() => setCount(c => c + 1)); // renders immediately
flushSync(() => setFlag(f => !f));     // renders immediately

3.3 Lifecycle with useEffect

useEffect is not a lifecycle method replacement — it is a synchronisation mechanism. The mental model is: "my component needs to stay synchronised with X. When X changes, re-run the synchronisation." The dependency array defines what X is.

Class to hooks mapping

Class methoduseEffect equivalentNotes
componentDidMountuseEffect(() => { ... }, [])Runs once after first render
componentDidUpdateuseEffect(() => { ... }, [dep])Runs after mount + when dep changes
componentWillUnmountCleanup function returned from effectRuns on unmount and before next effect
componentDidMount + componentDidUpdateuseEffect(() => { ... }) — no arrayRuns after every render
shouldComponentUpdateReact.memo + custom comparatorPrevent re-renders based on prop comparison
getDerivedStateFromPropsCompute derived values during render (no effect needed)Prefer computing during render over syncing with effects

Separating concerns into multiple effects

Class components forced all lifecycle logic into single methods (componentDidMount doing 5 different things). With hooks, separate each concern into its own effect. This makes them independently readable, testable, and removable.

// BAD: one effect doing multiple unrelated things
useEffect(() => {
  fetchUser(userId);
  document.title = `User ${userId}`;
  analytics.track('page_view');
  const sub = subscribe(userId);
  return () => sub.unsubscribe();
}, [userId]);

// GOOD: separate concerns
useEffect(() => {
  fetchUser(userId).then(setUser);
}, [userId]);

useEffect(() => {
  document.title = user ? `${user.name}` : 'Loading...';
}, [user]);

useEffect(() => {
  analytics.track('page_view', { userId });
}, [userId]);

useEffect(() => {
  const sub = subscribe(userId);
  return () => sub.unsubscribe();
}, [userId]);

Complete lifecycle example

function ArticleViewer({ articleId, currentUser }) {
  const [article, setArticle]   = useState(null);
  const [comments, setComments] = useState([]);
  const [loading, setLoading]   = useState(true);

  // Effect 1: fetch article when articleId changes
  useEffect(() => {
    let active = true;
    setLoading(true);
    setArticle(null);

    fetch(`/api/articles/${articleId}`)
      .then(r => r.json())
      .then(data => { if (active) { setArticle(data); setLoading(false); } })
      .catch(() => { if (active) setLoading(false); });

    return () => { active = false; };
  }, [articleId]);

  // Effect 2: real-time comments via WebSocket
  useEffect(() => {
    const ws = new WebSocket(`wss://api.example.com/articles/${articleId}/comments`);
    ws.onmessage = (e) => setComments(JSON.parse(e.data));
    return () => ws.close();
  }, [articleId]);

  // Effect 3: track reading time — mount only
  useEffect(() => {
    const start = Date.now();
    return () => {
      analytics.track('reading_time', {
        articleId,
        ms: Date.now() - start,
      });
    };
  }, [articleId]);

  // Effect 4: update document title when article loads
  useEffect(() => {
    if (article) document.title = article.title;
    return () => { document.title = 'My App'; };
  }, [article]);

  if (loading) return <p>Loading...</p>;
  if (!article) return <p>Article not found.</p>;

  return (
    <article>
      <h1>{article.title}</h1>
      <p>{article.body}</p>
      <section>
        <h2>Comments ({comments.length})</h2>
        {comments.map(c => <p key={c.id}>{c.text}</p>)}
      </section>
    </article>
  );
}

3.4 useLayoutEffect

useLayoutEffect has identical syntax to useEffect, but runs synchronously after React updates the DOM and before the browser paints. This is useful when you need to read layout information (element positions or sizes) and synchronously update the DOM to prevent a visual flicker.

useEffectuseLayoutEffect
TimingAfter browser paintsAfter DOM update, before browser paints
Blocks painting?NoYes — can delay visual update
Use forData fetching, subscriptions, most effectsDOM measurement, tooltip positioning, scroll restoration
SSRSafeWarning on server (no DOM available)
// Tooltip that positions itself to stay on screen
function Tooltip({ text, anchorRef }) {
  const tooltipRef = useRef(null);
  const [position, setPosition] = useState({ top: 0, left: 0 });

  useLayoutEffect(() => {
    // Must run before paint to prevent flicker
    const anchor  = anchorRef.current.getBoundingClientRect();
    const tooltip = tooltipRef.current.getBoundingClientRect();

    let top  = anchor.bottom + 8;
    let left = anchor.left;

    // Prevent going off screen
    if (left + tooltip.width > window.innerWidth) {
      left = window.innerWidth - tooltip.width - 8;
    }

    setPosition({ top, left });
  }, [text]); // recalculate when text changes (tooltip might resize)

  return (
    <div
      ref={tooltipRef}
      style={{ position: 'fixed', top: position.top, left: position.left }}
    >
      {text}
    </div>
  );
}
Default to useEffect
Always start with useEffect. Only switch to useLayoutEffect when you see a visual flicker that confirms you need synchronous DOM measurement. Overusing useLayoutEffect blocks painting and makes the app feel slow.

3.5 Performance & Re-renders

Why components re-render

Understanding re-renders is fundamental to performance work. A component re-renders when:

  1. Its own state changes
  2. Its parent re-renders (most common cause of unnecessary re-renders)
  3. Its context value changes
  4. A custom hook it uses triggers a re-render
// Parent re-render causes ALL children to re-render by default
function Parent() {
  const [count, setCount] = useState(0);
  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <ExpensiveChild />   {/* re-renders every time count changes — even though it doesn't use count */}
      <CheapChild />
    </>
  );
}

Preventing unnecessary re-renders

// Solution 1: React.memo — skip re-render if props are the same
const ExpensiveChild = React.memo(function ExpensiveChild() {
  console.log('ExpensiveChild rendered');
  return <div>I am expensive</div>;
});

// Solution 2: Move state down — only the part that uses state re-renders
function Parent() {
  return (
    <>
      <Counter />     {/* counter state lives here — only Counter re-renders */}
      <ExpensiveChild /> {/* never re-renders */}
    </>
  );
}

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

// Solution 3: children prop — pass children from a parent that doesn't re-render
function ColorPicker({ children }) {
  const [color, setColor] = useState('red');
  return (
    <div style={{ background: color }}>
      <select onChange={e => setColor(e.target.value)}>
        <option>red</option>
        <option>blue</option>
      </select>
      {children}  {/* children prop is stable — doesn't re-render when color changes */}
    </div>
  );
}

// Parent creates children — ColorPicker's state change doesn't re-render them
<ColorPicker>
  <ExpensiveChild />
</ColorPicker>

React DevTools Profiler

Before optimising, measure. The React DevTools browser extension includes a Profiler tab that shows which components rendered, how long each render took, and why each component rendered (what triggered it).

Performance advice
Do not add React.memo, useMemo, or useCallback preemptively. Profile first. Most React apps don't have meaningful performance problems — and premature memoization adds complexity, can introduce bugs (stale memoized values), and doesn't always help (memoization itself has a cost).

Q & A — Lifecycle

Is componentDidMount exactly the same as useEffect with an empty array? ▾
componentDidMount fires synchronously after the DOM is updated, before the browser paints (in most cases). useEffect always fires asynchronously, after the browser has painted. In practice, both are "after mount" and the difference is usually invisible. However, if you need to read layout (element size, scroll position) immediately after mount and apply a DOM change before the user sees the initial paint, use useLayoutEffect — it runs at the same time as componentDidMount.
Can I have multiple useEffect calls in one component? ▾
Yes, and you should. Each useEffect should encapsulate one concern — data fetching, event subscriptions, analytics, title updates. They run in declaration order. Separate effects have separate cleanup, so removing one effect doesn't accidentally break another's cleanup. Multiple focused effects are always preferable to one large effect doing many things.
What causes an infinite re-render loop? ▾
The most common causes are: (1) setting state inside useEffect without a dependency array — the effect runs, sets state, state triggers a render, effect runs again, repeat forever; (2) putting an object or array literal directly in the dependency array — it's a new reference every render, so the effect always re-runs; (3) setting state unconditionally in componentDidUpdate without comparing previous props. Fix by adding a guard condition, using primitives in deps, or memoizing objects.
What is the difference between useEffect and useLayoutEffect? ▾
useEffect runs after the browser paints — asynchronously. useLayoutEffect runs after the DOM is mutated but before the browser paints — synchronously. Use useLayoutEffect when you need to read the DOM and synchronously re-render to prevent a visible flicker (tooltip positioning, scroll restoration). For everything else — data fetching, subscriptions, logging — use useEffect. Overusing useLayoutEffect delays painting and hurts perceived performance.
Why does my component re-render even though nothing changed? ▾
Most likely because its parent re-rendered. In React, when a parent re-renders, all its children re-render by default — even if their props didn't change. The solutions are: (1) wrap the child in React.memo to skip re-renders when props are identical; (2) move state closer to where it is used so fewer components are above it; (3) use the children prop pattern to pass components from a stable parent. Use the React DevTools Profiler to see exactly what triggered the render.
Should I still learn class components if we use functional components everywhere? ▾
Yes, for three reasons. First, most real codebases contain class components in at least some parts — you will maintain them. Second, error boundaries still require class components as of React 18. Third, understanding class lifecycle methods makes useEffect patterns clearer — the mapping is direct and knowing both clarifies each one. You don't need to write new class components, but you should be able to read, understand, and migrate them.
React 18 batches state updates automatically — what does that mean in practice? ▾
Before React 18, state updates inside async code (setTimeout, fetch callbacks) each triggered their own re-render. In React 18, React batches all state updates in the same async tick into a single re-render. This is generally a performance improvement and requires no code changes. If you ever need immediate separate renders (very rare), use flushSync from react-dom to opt out of batching for a specific update.

Reference

Common Patterns

Patterns that come up repeatedly across all three days. Treat this as a quick-reference section for when trainees ask "how do I do X?"

Data fetching with loading and error state

function useFetch(url) {
  const [state, setState] = useState({ data: null, loading: true, error: null });

  useEffect(() => {
    let cancelled = false;
    setState({ data: null, loading: true, error: null });

    fetch(url)
      .then(r => r.json())
      .then(data => { if (!cancelled) setState({ data, loading: false, error: null }); })
      .catch(err => { if (!cancelled) setState({ data: null, loading: false, error: err.message }); });

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

  return state;
}

Debounced search input

function SearchBar({ onSearch }) {
  const [query, setQuery] = useState('');
  const timerRef = useRef(null);

  const handleChange = (e) => {
    setQuery(e.target.value);
    clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => {
      onSearch(e.target.value);
    }, 300);
  };

  useEffect(() => () => clearTimeout(timerRef.current), []); // cleanup on unmount

  return <input value={query} onChange={handleChange} placeholder="Search..." />;
}

Toggle with useReducer

function useToggle(initial = false) {
  return useReducer(state => !state, initial);
}

function Modal() {
  const [isOpen, toggle] = useToggle(false);
  return (
    <>
      <button onClick={toggle}>Open modal</button>
      {isOpen && <div className="modal"><button onClick={toggle}>Close</button></div>}
    </>
  );
}

Infinite scroll

function useIntersectionObserver(callback, options) {
  const ref = useRef(null);
  useEffect(() => {
    const observer = new IntersectionObserver(callback, options);
    if (ref.current) observer.observe(ref.current);
    return () => observer.disconnect();
  }, [callback, options]);
  return ref;
}

function InfiniteList() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);

  useEffect(() => {
    fetchPage(page).then(newItems => setItems(prev => [...prev, ...newItems]));
  }, [page]);

  const sentinelRef = useIntersectionObserver(
    useCallback(([entry]) => { if (entry.isIntersecting) setPage(p => p + 1); }, []),
    { threshold: 1 }
  );

  return (
    <ul>
      {items.map(item => <li key={item.id}>{item.name}</li>)}
      <li ref={sentinelRef} />  {/* invisible sentinel triggers load */}
    </ul>
  );
}

Common Mistakes

A quick-reference list of mistakes trainees make repeatedly. Use this to guide code reviews and debugging sessions.

1. Missing dependency in useEffect

// BUG: effect uses userId but doesn't declare it as a dependency
useEffect(() => {
  fetch(`/api/${userId}`).then(setUser);
}, []); // stale — never re-fetches when userId changes

// FIX
}, [userId]);

2. Object/array literal in dependency array

// BUG: new object on every render → infinite loop
useEffect(() => {
  fetchData(config);
}, [{ timeout: 5000 }]); // new reference each time

// FIX: move outside component or memoize
const config = useMemo(() => ({ timeout: 5000 }), []);

3. Mutating state directly

// BUG: mutates existing array — React sees same reference, no re-render
const [items, setItems] = useState([1, 2, 3]);
items.push(4);
setItems(items); // same reference — nothing updates

// FIX
setItems(prev => [...prev, 4]);

4. Async useEffect (async directly on the callback)

// BUG: async effect returns a Promise, not a cleanup function
useEffect(async () => {
  const data = await fetch(url);
  setData(data);
}, [url]);

// FIX
useEffect(() => {
  let cancelled = false;
  async function load() {
    const data = await fetch(url).then(r => r.json());
    if (!cancelled) setData(data);
  }
  load();
  return () => { cancelled = true; };
}, [url]);

5. Forgetting cleanup

// BUG: event listener accumulates on every render
useEffect(() => {
  window.addEventListener('resize', handleResize);
  // no cleanup — adds a new listener every time deps change
});

// FIX
useEffect(() => {
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, [handleResize]);

6. Reading state immediately after setting it

// BUG: setCount is asynchronous — count is still 0 here
const [count, setCount] = useState(0);
const handleClick = () => {
  setCount(count + 1);
  console.log(count); // logs 0, not 1
};

// FIX: read state in the next render, or use functional update
setCount(prev => {
  const next = prev + 1;
  console.log(next); // correct
  return next;
});

7. Conditional hooks

// BUG: hook called conditionally — breaks hook order
if (isLoggedIn) {
  const [profile, setProfile] = useState(null); // violates rules of hooks
}

// FIX: always call the hook, conditionally use the value
const [profile, setProfile] = useState(null);
// ...use profile only when isLoggedIn is true

Glossary

Batching

React's process of grouping multiple state updates in the same event or async call into a single re-render. React 18 introduced automatic batching for all updates, including those inside setTimeout and Promises.

Cleanup Function

A function returned from a useEffect callback. Called before the next effect run and on unmount. Used to cancel subscriptions, clear timers, abort fetches, and remove event listeners.

Controlled Component

A form input whose displayed value is driven by React state. Every change updates state. React is the single source of truth, not the DOM.

Custom Hook

A JavaScript function whose name starts with use and calls other hooks. Extracts and shares stateful logic between components. Each component that calls a custom hook gets isolated state.

Dependency Array

The second argument to useEffect, useMemo, and useCallback. Declares which values the hook depends on. The hook re-runs when any dependency changes (compared with Object.is).

Fiber

React's internal data structure representing a component instance. Holds component state, effect list, and hook chain. The linked list of hooks is attached to the fiber — this is why hook order must be consistent.

Memoization

Caching the result of a computation and returning the cached value on subsequent calls when inputs haven't changed. useMemo memoizes values; useCallback memoizes function references; React.memo memoizes component output.

Mount / Unmount

Mounting: a component is inserted into the DOM for the first time. Unmounting: a component is removed from the DOM. Both trigger the appropriate useEffect runs and cleanups.

Reconciliation

React's process of comparing new render output with the previous output and computing the minimal set of DOM changes needed. Re-rendering does not always mean DOM changes.

Ref

A mutable container ({ current: value }) returned by useRef. Persists across renders without causing re-renders when mutated. Used for DOM node references, timer IDs, cancellation flags, and previous value storage.

Side Effect

Any operation outside the React render cycle: network requests, DOM mutations, timers, subscriptions, logging. Managed via useEffect.

Stale Closure

When a function (inside an effect or event handler) captures a variable from a previous render and uses the old value rather than the current one. Fixed with functional state updates, correct dependency arrays, or refs.

Uncontrolled Component

A form input managed by the DOM. React reads its value via a ref on demand (e.g., on submit), rather than on every change.

Error Boundary

A class component that catches JavaScript errors in its child tree and renders a fallback UI instead of crashing. Must be a class component (no hook equivalent as of React 18).


To add a new topic: add a new <div class="chapter-label"> + <h1 id="..."> block below, and add the corresponding sidebar links under a new <div class="sec"> label. The structure extends naturally.