Components & useState · useEffect · useContext · useReducer
Compose UI from reusable components. Pass data with props, manage local state with useState, and re-render automatically when state changes. Run side effects (data fetches, subscriptions, timers) outside the render cycle. Learn the dependency array, cleanup, and how to fetch data correctly. Stop drilling props through layers. Use Context to share theme, auth, or i18n state — wrapped in a clean custom provider component. Handle complex state with useReducer — a tiny reducer function and dispatched actions. Predictable, testable, and great for multi-step state.
What you'll learn
- 1Write function components with JSX
- 2Pass data with typed props
- 3Manage local state with useState
- 4Lift state up when two siblings need it
- 5Use useEffect with the right dependency array
- 6Return a cleanup function for subscriptions
- 7Fetch data in an effect (or prefer Suspense / server components)
- 8Avoid effect anti-patterns
- 9Create a Context with a default value
- 10Build a custom Provider component
- 11Consume the context with useContext
- 12Know when NOT to use Context (frequent updates)
- 13Write a reducer function: (state, action) => newState
- 14Dispatch actions instead of calling many setters
- 15Use a discriminated-union Action type for type safety
- 16Choose useReducer over useState for complex, interrelated state
Components & useState
Compose UI from reusable components. Pass data with props, manage local state with useState, and re-render automatically when state changes.
1. Function Components
type CardProps = { title: string; description: string };
export default function Card({ title, description }: CardProps) {
return (
<div className="rounded-xl border p-4">
<h2 className="text-lg font-bold">{title}</h2>
<p className="text-gray-600">{description}</p>
</div>
);
}2. JSX Rules
- className instead of class
- Self-closing tags need a closing slash (<br />)
- JS expressions in { } — including conditionals and array.map
- Return one root element (use <>...</> fragment when needed)
{count > 0 && <p>Items: {count}</p>}
{items.map(i => <li key={i.id}>{i.name}</li>)}
<>multiple <strong>elements</strong></>3. useState
"use client";
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
);
}Never mutate state. setCount(count + 1) is correct; count = count + 1 silently breaks rerendering.
4. Lifting State Up
When two sibling components need the same state, move it up to their common parent and pass it down via props.
function App() {
const [filter, setFilter] = useState("");
return (
<>
<SearchBar value={filter} onChange={setFilter} />
<ItemList filter={filter} />
</>
);
}useEffect
Run side effects (data fetches, subscriptions, timers) outside the render cycle. Learn the dependency array, cleanup, and how to fetch data correctly.
1. Basic Effect
useEffect(() => {
document.title = `${count} clicks`;
}, [count]); // runs after every render where count changed- Empty deps [] → runs once on mount
- No deps → runs after every render (rarely what you want)
- Deps [a, b] → runs when a or b changes
2. Cleanup
useEffect(() => {
const id = setInterval(() => console.log("tick"), 1000);
return () => clearInterval(id); // cleanup on unmount/dep change
}, []);Cleanup runs before the next effect fires and on unmount — essential for timers and subscriptions.
3. Data Fetching
function Users() {
const [users, setUsers] = useState<User[] | null>(null);
useEffect(() => {
const controller = new AbortController();
(async () => {
const res = await fetch("/api/users", { signal: controller.signal });
const data = await res.json();
setUsers(data);
})().catch(err => { if (err.name !== "AbortError") console.error(err); });
return () => controller.abort();
}, []);
if (!users) return <p>Loading...</p>;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}4. Anti-patterns
- Setting state in an effect that immediately re-triggers itself (infinite loop)
- Forgetting deps and using stale state
- Using an effect to derive state — derive in render instead
- Subscribing without cleanup
Many "do I need an effect?" answers are no. Read the React docs page "You Might Not Need an Effect".
useContext
Stop drilling props through layers. Use Context to share theme, auth, or i18n state — wrapped in a clean custom provider component.
1. Create the Context
import { createContext, useContext, useState } from "react";
type Theme = "light" | "dark";
const ThemeContext = createContext<{
theme: Theme;
toggle: () => void;
} | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("light");
const toggle = () => setTheme(t => t === "light" ? "dark" : "light");
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used inside ThemeProvider");
return ctx;
}2. Provide and Consume
// app/layout.tsx
<ThemeProvider>
<App />
</ThemeProvider>
// Anywhere in the tree
function Header() {
const { theme, toggle } = useTheme();
return <button onClick={toggle}>Theme: {theme}</button>;
}3. Context Gotchas
- Every consumer re-renders when the context value changes
- Split fast-changing state into a separate context (or use a state library)
- Memoize the provider value with useMemo when it contains objects
useReducer
Handle complex state with useReducer — a tiny reducer function and dispatched actions. Predictable, testable, and great for multi-step state.
1. Reducer Shape
type State = { count: number; step: number };
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "set-step"; step: number }
| { type: "reset" };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment": return { ...state, count: state.count + state.step };
case "decrement": return { ...state, count: state.count - state.step };
case "set-step": return { ...state, step: action.step };
case "reset": return { count: 0, step: 1 };
}
}2. Use the Reducer
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });
return (
<div>
<p>{state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<input type="number" value={state.step}
onChange={e => dispatch({ type: "set-step", step: +e.target.value })} />
<button onClick={() => dispatch({ type: "reset" })}>reset</button>
</div>
);
}3. useState vs useReducer
| Pick | When |
|---|---|
| useState | 1–2 simple values, updates are independent |
| useReducer | Multiple values that change together, complex update logic, you want to test the reducer in isolation |
All lecture materials and example code are openly available on GitHub.
View on GitHub ↗Comments 0
No comments yet. Be the first.
Only members can comment.
Comments with ads, abuse, personal information or illegal content are hidden or removed. Comments are shown with your nickname. Privacy Policy