Function Components & JSX
A React component is a function that returns JSX — a syntax extension that describes the UI. Components must start with a capital letter, must return a single root (use a fragment <>...</> to group without an extra DOM node), and must be pure during render (no side effects, no mutation of props/state).
function Greeting({ name, isAdmin = false }) {
return (
<>
<h1 className="title">Hello, {name}</h1>
{isAdmin && <span>Admin</span>} {/* conditional render */}
{isAdmin ? <Dashboard /> : <Home />} {/* ternary */}
</>
);
}
JSX rules: use className not class, camelCase event props (onClick), self-close void elements, and embed JavaScript expressions inside { }.
Props
Props are read-only inputs passed from parent to child — data flows one way, down the tree. To communicate upward, a parent passes a callback the child invokes. Use children to compose nested content.
function Button({ variant = 'primary', onClick, children }) {
return <button className={variant} onClick={onClick}>{children}</button>;
}
// Parent passes data down + callback for upward communication
function Toolbar() {
const handleSave = () => save();
return <Button variant="ghost" onClick={handleSave}>Save</Button>;
}
// TypeScript: type your props
type CardProps = { title: string; onClose?: () => void };
function Card({ title, onClose }: CardProps) { /* ... */ }
useState
useState adds local state to a component. It returns the current value and a setter. Calling the setter schedules a re-render. State updates are asynchronous and batched — use the functional updater when the next value depends on the previous one.
const [count, setCount] = useState<number>(0);
const [user, setUser] = useState(() => loadInitial()); // lazy init
setCount(count + 1); // uses value from this render
setCount(c => c + 1); // functional updater — always latest
// Never mutate state — create a new value
setUser(u => ({ ...u, name: 'Ada' })); // objects
setList(l => [...l, newItem]); // arrays
State is immutable
React compares state by reference to decide whether to re-render. Mutating an object or array in place keeps the same reference, so React may skip the update. Always produce a new object/array with spread or an array method.
useEffect — Dependencies & Cleanup
useEffect synchronises a component with an external system (network, subscriptions, timers, DOM). It runs after render. The dependency array controls when it re-runs, and the returned cleanup function runs before the next effect and on unmount.
| Dependencies | Runs |
|---|---|
[a, b] | On mount + when a or b changes |
[] (empty) | Once on mount only |
| omitted | After every render (usually a bug) |
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${id}`, { signal: controller.signal })
.then(r => r.json())
.then(setUser)
.catch(err => { if (err.name !== 'AbortError') setError(err); });
return () => controller.abort(); // cleanup: cancel on id change/unmount
}, [id]); // re-run whenever id changes
Not everything belongs in an effect. Data derivable from props/state should be computed during render, not stored in effect-updated state. In React 19, prefer libraries (or Server Components) for data fetching over raw effects.
useContext
useContext reads shared data without "prop drilling" through intermediate components. Create a context, wrap the subtree in a provider, and consume it anywhere below.
const ThemeContext = createContext<'light' | 'dark'>('light');
function App() {
return (
<ThemeContext.Provider value="dark">
<Page />
</ThemeContext.Provider>
);
}
function Page() {
const theme = useContext(ThemeContext); // 'dark'
return <div className={theme}>...</div>;
}
useReducer
For complex state with multiple sub-values or intertwined transitions, useReducer centralises update logic in a pure reducer. Components dispatch actions instead of calling setters directly.
type Action = { type: 'inc' } | { type: 'set'; value: number };
function reducer(state: number, action: Action): number {
switch (action.type) {
case 'inc': return state + 1;
case 'set': return action.value;
default: return state;
}
}
const [count, dispatch] = useReducer(reducer, 0);
dispatch({ type: 'inc' });
dispatch({ type: 'set', value: 10 });
useMemo & useCallback
Both cache values across renders to avoid unnecessary work. useMemo memoises a computed value; useCallback memoises a function reference (so it stays stable for memoised children or effect dependencies).
// Only recompute when items or filter change
const visible = useMemo(
() => items.filter(i => i.name.includes(filter)),
[items, filter],
);
// Stable callback identity — prevents child re-renders
const handleSelect = useCallback((id: number) => {
setSelected(id);
}, []);
// useCallback(fn, deps) === useMemo(() => fn, deps)
Don't over-memoise
Memoisation has a cost. Reach for it only for genuinely expensive computations or to keep referential equality for memoised children. The React Compiler (React 19+) can auto-memoise, making many manual useMemo/useCallback calls unnecessary.
Refs
useRef holds a mutable value that persists across renders without triggering a re-render when changed. Common uses: accessing DOM nodes and storing mutable instance data (timers, previous values).
function TextInput() {
const inputRef = useRef<HTMLInputElement>(null);
const focus = () => inputRef.current?.focus();
return (
<>
<input ref={inputRef} />
<button onClick={focus}>Focus</button>
</>
);
}
// In React 19, ref is a regular prop — no more forwardRef needed
Lists & Keys
Render lists with map. Each element needs a stable, unique key so React can track items across renders. Use a real ID — never the array index for dynamic lists, as it causes state and performance bugs on insert/reorder/delete.
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.title}</li> // stable id, not index
))}
</ul>
Controlled Inputs & Forms
In a controlled component, React state is the single source of truth: the input's value comes from state and onChange updates it.
function LoginForm() {
const [email, setEmail] = useState('');
function handleSubmit(e) {
e.preventDefault();
submit(email);
}
return (
<form onSubmit={handleSubmit}>
<input
value={email}
onChange={e => setEmail(e.target.value)}
/>
<button>Log in</button>
</form>
);
}
Custom Hooks
A custom hook is a function starting with use that calls other hooks to extract and reuse stateful logic. It shares logic, not state — each call gets its own isolated state.
function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : initial;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
// Usage
const [theme, setTheme] = useLocalStorage('theme', 'light');
Rules of Hooks & Lifting State
Rules of Hooks: (1) only call hooks at the top level — never inside loops, conditions, or nested functions; (2) only call hooks from React function components or other custom hooks. React relies on a stable call order across renders to associate each hook with its state.
Lifting state up: when two components need to share state, move it to their closest common ancestor and pass it down as props. This keeps a single source of truth and enables sibling coordination.
React 19 Features
React 19 introduces first-class support for asynchronous UI transitions.
Actions & useActionState
Actions are async functions passed to a form's action prop. useActionState tracks pending state, the result, and errors automatically.
function UpdateName() {
const [error, submitAction, isPending] = useActionState(
async (prev, formData) => {
const err = await updateName(formData.get('name'));
return err ?? null;
},
null,
);
return (
<form action={submitAction}>
<input name="name" />
<button disabled={isPending}>Save</button>
{error && <p>{error}</p>}
</form>
);
}
use & useOptimistic
The use API reads a promise or context during render (and can be called conditionally, unlike other hooks), suspending until resolved. useOptimistic shows an immediate optimistic UI while an action is in flight.
function Comments({ commentsPromise }) {
const comments = use(commentsPromise); // suspends until resolved
return comments.map(c => <p key={c.id}>{c.text}</p>);
}
function Thread({ messages, send }) {
const [optimistic, addOptimistic] = useOptimistic(
messages,
(state, newMsg) => [...state, { text: newMsg, sending: true }],
);
async function action(formData) {
const text = formData.get('msg');
addOptimistic(text); // show instantly
await send(text); // then persist
}
// render `optimistic` list + form with action
}
Server Components (brief)
React Server Components (RSC) render on the server and send serialised UI (not JavaScript) to the client — reducing bundle size and enabling direct data/DB access with async/await. They cannot use state or effects. Add 'use client' at the top of a file to opt a component into interactivity on the client. Frameworks like Next.js App Router use RSC by default.
Practice Exercises
- Build a counter with
useState, then refactor it touseReducerwithinc,dec, andresetactions. - Write a
useFetch(url)custom hook that returns{ data, loading, error }and aborts the request on cleanup. - Build a controlled multi-field form with validation that submits via a React 19 Action and shows a pending state.
- Fix a stale-closure bug where an interval logs the initial count instead of the current one (hint: functional updater or ref).
- Create a
ThemeContextwith a provider and auseThemehook, and toggle theme from a deeply nested child. - Add
useOptimisticto a todo list so new items appear instantly before the server confirms.