Topic 9: Hooks
📖 7 min read · 🎯 intermediate · 🧭 Prerequisites: react-lists, react-router
Why this matters
Before React 16.8, if your component needed to remember something — like a counter value or whether a dropdown was open — you had to write a class component. Classes meant constructor, this.setState, componentDidMount... a lot of setup just to track one piece of data. Most beginners hit that wall and wondered if they were doing something wrong. You weren't. It was just the only way. Hooks changed that completely. Now a plain function can have state, run side effects, and share logic — no class needed. That's what we're learning today.
What You'll Learn
- Apply the two Rules of Hooks to avoid subtle ordering bugs
- Add and update component state with
useState - Trigger side effects like data fetching with
useEffect - Share values across the component tree with
useContext - Manage complex state transitions with
useReducer
The Analogy
Think of a React functional component as a bare apartment — it has a layout, but no furniture or utilities. Before hooks, getting those amenities meant moving into a much heavier class-based building with a fixed floor plan. Hooks are like modular service subscriptions: you call useState the way you'd call an electricity provider, and suddenly your apartment has power. You call useEffect the way you'd set up a mail-forwarding service — it runs once you're settled in and again whenever your address changes. Each hook is a focused service you opt into; the component stays lean, and you only pay for what you use.
Chapter 1: Introduction to Hooks
Hooks are functions that let you use state and other React features without writing a class. They were introduced in React 16.8 and are now the standard way to write React components. The two most commonly used hooks are useState and useEffect, but the ecosystem includes many more.
Rules of Hooks
These two rules are enforced by the eslint-plugin-react-hooks linter and must never be violated:
- Only Call Hooks at the Top Level — never inside loops, conditions, or nested functions. React relies on call order to associate each hook with its state across re-renders.
- Only Call Hooks from React Functions — call them from within React functional components or from custom hooks (never from plain JavaScript functions).
Chapter 2: Using the useState Hook
useState lets you add local state to a functional component. It returns a tuple: the current state value and a setter function. Every call to the setter triggers a re-render.
src/components/Counter.js
import React, { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<h1>Count: {count}</h1>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
</div>
);
}
export default Counter;
useState(0) sets the initial value to 0. Clicking Increment calls setCount(count + 1), React schedules a re-render, and the new count appears in the <h1>.
Chapter 3: Using the useEffect Hook
useEffect lets you perform side effects inside functional components — things like data fetching, DOM mutations, or setting up subscriptions. It runs after the component renders, keeping the render phase pure.
The second argument — the dependency array — controls when the effect re-runs:
| Dependency array | Runs… |
|---|---|
| omitted | after every render |
[] | once after the initial mount |
[a, b] | after mount, then whenever a or b changes |
src/components/FetchData.js
import React, { useState, useEffect } from 'react';
function FetchData() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/posts/1')
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
setData(data);
setLoading(false);
})
.catch(error => {
setError(error);
setLoading(false);
});
}, []); // The empty array ensures the effect runs only once
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h2>{data.title}</h2>
<p>{data.body}</p>
</div>
);
}
export default FetchData;
The empty dependency array [] guarantees the fetch fires exactly once — on mount. Three state slices (data, loading, error) cover every render branch cleanly.
Chapter 4: Using the useContext Hook
useContext reads a React context value without wrapping your JSX in a <Context.Consumer>. You pass it the context object and get the current value back directly.
src/contexts/ThemeContext.js
import React, { createContext, useContext, useState } from 'react';
const ThemeContext = createContext();
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => useContext(ThemeContext);
ThemeProvider owns the theme state and exposes both the value and its setter through context. useTheme is a custom hook that wraps useContext(ThemeContext) — consumers never touch the raw context object.
src/components/ThemeSwitcher.js
import React from 'react';
import { useTheme } from '../contexts/ThemeContext';
function ThemeSwitcher() {
const { theme, setTheme } = useTheme();
return (
<div>
<p>Current theme: {theme}</p>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</div>
);
}
export default ThemeSwitcher;
Integrating ThemeProvider and ThemeSwitcher
Wrap the app in ThemeProvider so every descendant can access the theme without prop drilling.
src/App.js
import React from 'react';
import './App.css';
import { ThemeProvider } from './contexts/ThemeContext';
import ThemeSwitcher from './components/ThemeSwitcher';
function App() {
return (
<ThemeProvider>
<div className="App">
<h1>React Hooks Example</h1>
<ThemeSwitcher />
</div>
</ThemeProvider>
);
}
export default App;
Chapter 5: Using the useReducer Hook
useReducer is an alternative to useState designed for complex state logic — multiple sub-values, next state that depends on the previous state, or state transitions that benefit from explicit action names. It follows the same reducer pattern as Redux: (state, action) => nextState.
src/components/ReducerCounter.js
import React, { useReducer } from 'react';
const initialState = { count: 0 };
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
return state;
}
}
function ReducerCounter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<h1>Count: {state.count}</h1>
<button onClick={() => dispatch({ type: 'increment' })}>Increment</button>
<button onClick={() => dispatch({ type: 'decrement' })}>Decrement</button>
</div>
);
}
export default ReducerCounter;
dispatch({ type: 'increment' }) sends an action object to reducer, which returns a new state object. React re-renders with state.count updated — the component never mutates state directly.
Integrating All Components
src/App.js — final version wiring every hook example together:
import React from 'react';
import './App.css';
import Counter from './components/Counter';
import FetchData from './components/FetchData';
import ReducerCounter from './components/ReducerCounter';
import { ThemeProvider } from './contexts/ThemeContext';
import ThemeSwitcher from './components/ThemeSwitcher';
function App() {
return (
<ThemeProvider>
<div className="App">
<h1>React Hooks Example</h1>
<Counter />
<FetchData />
<ReducerCounter />
<ThemeSwitcher />
</div>
</ThemeProvider>
);
}
export default App;
flowchart TD
App["App\n(wrapped in ThemeProvider)"]
App --> Counter["Counter\nuseState"]
App --> FetchData["FetchData\nuseState + useEffect"]
App --> ReducerCounter["ReducerCounter\nuseReducer"]
App --> ThemeSwitcher["ThemeSwitcher\nuseContext → useTheme"]
ThemeProvider["ThemeContext.Provider\n(theme, setTheme)"] --> ThemeSwitcher
🧪 Try It Yourself
Task: Build a TodoList component that lets you add and remove items using useState.
Success criterion: Type a task name, press Add, and see it appear in a list. Click Remove next to any item and see it disappear — all without a page reload.
Starter snippet:
import React, { useState } from 'react';
function TodoList() {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState('');
const addTodo = () => {
if (!input.trim()) return;
setTodos([...todos, { id: Date.now(), text: input }]);
setInput('');
};
const removeTodo = (id) =>
setTodos(todos.filter(todo => todo.id !== id));
return (
<div>
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Add a task…"
/>
<button onClick={addTodo}>Add</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>
{todo.text}{' '}
<button onClick={() => removeTodo(todo.id)}>Remove</button>
</li>
))}
</ul>
</div>
);
}
export default TodoList;
Stretch goal: Replace useState with useReducer and define ADD_TODO and REMOVE_TODO action types.
🔍 Checkpoint Quiz
Q1. Why does React require hooks to be called at the top level of a component, never inside a loop or conditional?
Q2. Given this component, what will the browser display after the button is clicked three times?
function App() {
const [count, setCount] = useState(10);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count - 3)}>Go</button>
</div>
);
}
A) 10
B) 7
C) 1
D) -3 then 10 then -3
Q3. In the FetchData example, the useEffect dependency array is []. What would happen if you changed it to [data]?
A) The effect would never run
B) The effect would run after every render indefinitely
C) The effect would run once on mount, then again every time data changes, causing an infinite loop
D) Nothing — React ignores the dependency array for fetch calls
Q4. You have a form with five related fields (name, email, age, role, active) that can each be updated independently. Would you reach for useState five times or useReducer with a single state object? Explain your reasoning.
A1. React tracks hook state by the order in which hooks are called each render. If a hook call were inside a condition, it could be skipped on some renders, shifting every subsequent hook's position in the call order — React would pair each hook with the wrong state slot, causing unpredictable bugs.
A2. C) 1 — clicking three times calls setCount(10 - 3) → 7, then setCount(7 - 3) → 4, then setCount(4 - 3) → 1.
A3. C) infinite loop — on mount, the effect fetches and calls setData(data), which changes the data state. data is now in the dependency array, so the effect runs again, fetches again, sets data again — endlessly.
A4. useReducer with a single state object is the better fit. Five independent useState calls work but scatter related state across five setters. A reducer groups all field updates behind named actions (e.g., SET_FIELD), keeps state transitions predictable, and makes the form easier to extend or reset atomically.
🪞 Recap
- Hooks, introduced in React 16.8, let functional components use state and lifecycle features without classes.
useStatemanages simple local state;useReducerhandles complex multi-action state machines.useEffectruns side effects (fetching, subscriptions, DOM mutations) after renders, controlled by a dependency array.useContextreads a context value directly — no<Context.Consumer>wrapper needed.- The two Rules of Hooks — top-level only, React functions only — must always be followed to keep hook call order stable.
📚 Further Reading
- React Hooks official docs — the canonical reference for every built-in hook
- Rules of Hooks — detailed explanation of why the rules exist and how the linter enforces them
- useReducer in depth — when to choose
useReduceroveruseState, with migration examples - ⬅️ Previous: React Router
- ➡️ Next: Introduction to Redux