React Hooks & Data: Effects, Context & Forms
Go beyond basic state. Master useEffect for side effects, build reusable custom hooks, handle forms the React way, share state with Context, and fetch data without race conditions or stale closures.
4 sections · ~30 min · 5-question quiz (pass ≥ 70%)
1useEffect: Synchronizing with the Outside World
Components should be pure during render. Side effects — fetching data, subscribing to events, touching the DOM — belong in useEffect:
import { useState, useEffect } from "react";
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error("Not found");
const data = await res.json();
if (!cancelled) setUser(data);
} catch (err) {
if (!cancelled) setError(err.message);
}
}
load();
return () => { cancelled = true; };
}, [userId]);
if (error) return <p>Error: {error}</p>;
if (!user) return <p>Loading…</p>;
return <h1>{user.name}</h1>;
}
The dependency array controls when the effect re-runs. Include every value from the component scope that the effect reads and that can change. An empty array [] means "run once on mount."
The cleanup function (returned from the effect) runs before the next effect execution and on unmount — essential for cancelling fetches, clearing timers, and removing listeners. Without cleanup, fast prop changes cause race conditions where an older response overwrites a newer one.
2Custom Hooks: Extract and Reuse Stateful Logic
A custom hook is a function whose name starts with use and that calls other hooks. It lets you share stateful logic without sharing UI:
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
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 });
});
return () => { cancelled = true; };
}, [url]);
return state;
}
Custom hooks are not a performance feature — each call site gets its own independent state. They are an organization tool: colocate related effects and state, test logic in isolation, and keep components focused on rendering.
Rules: only call hooks at the top level (not inside loops/conditions), and only from React functions or other custom hooks.
3Forms & Controlled Inputs
In React, form elements whose value is driven by state are controlled components. React becomes the single source of truth:
function SignupForm({ onSubmit }) {
const [form, setForm] = useState({ email: "", password: "" });
const [errors, setErrors] = useState({});
function handleChange(e) {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
}
function handleSubmit(e) {
e.preventDefault();
const nextErrors = {};
if (!form.email.includes("@")) nextErrors.email = "Invalid email";
if (form.password.length < 8) nextErrors.password = "Too short";
setErrors(nextErrors);
if (Object.keys(nextErrors).length === 0) onSubmit(form);
}
return (
<form onSubmit={handleSubmit}>
<input name="email" value={form.email} onChange={handleChange} />
{errors.email && <span>{errors.email}</span>}
<input
name="password"
type="password"
value={form.password}
onChange={handleChange}
/>
{errors.password && <span>{errors.password}</span>}
<button type="submit">Sign up</button>
</form>
);
}
Why controlled? You can validate on every keystroke, disable submit until valid, reset programmatically, and keep multiple fields in sync. For large forms, libraries like React Hook Form reduce re-renders by registering uncontrolled refs — but understanding controlled inputs is prerequisite knowledge.
Checkboxes and selects work the same way: bind checked or value to state and update via onChange.
4Context, Lifting State & Data-Fetching Patterns
When many components need the same data, prop drilling (passing props through every layer) becomes painful. Two solutions:
Lifting state — move shared state to the nearest common ancestor and pass down state + setters:
function App() {
const [theme, setTheme] = useState("light");
return (
<Layout theme={theme}>
<Sidebar theme={theme} />
<Settings theme={theme} onThemeChange={setTheme} />
</Layout>
);
}
Context API — provide a value to an entire subtree without explicit prop passing:
const ThemeContext = createContext("light");
function App() {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<DeeplyNestedToolbar />
</ThemeContext.Provider>
);
}
function DeeplyNestedToolbar() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
Toggle theme
</button>
);
}
Use Context for global-ish, low-churn data (theme, locale, auth session). Don't put fast-changing values (keystrokes, scroll position) in Context — every consumer re-renders on change.
For server data, prefer colocated fetching in effects or dedicated libraries (TanStack Query, SWR) that handle caching, deduplication, and background refresh. Always guard against stale responses with cleanup flags or AbortController.