Intermediate~20 min read

React

Modern React with function components and JSX, props, the core hooks (useState, useEffect, useContext, useReducer, useMemo/useCallback, refs), custom hooks, controlled forms, rules of hooks, lifting state, and React 19 features including Actions, use, useOptimistic, and Server Components.

HooksJSXStateComponents

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).

jsx
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.

tsx
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.

tsx
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.

DependenciesRuns
[a, b]On mount + when a or b changes
[] (empty)Once on mount only
omittedAfter every render (usually a bug)
jsx
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.

tsx
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.

tsx
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).

tsx
// 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).

tsx
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.

jsx
<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.

jsx
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.

tsx
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.

jsx
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.

jsx
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

  1. Build a counter with useState, then refactor it to useReducer with inc, dec, and reset actions.
  2. Write a useFetch(url) custom hook that returns { data, loading, error } and aborts the request on cleanup.
  3. Build a controlled multi-field form with validation that submits via a React 19 Action and shows a pending state.
  4. Fix a stale-closure bug where an interval logs the initial count instead of the current one (hint: functional updater or ref).
  5. Create a ThemeContext with a provider and a useTheme hook, and toggle theme from a deeply nested child.
  6. Add useOptimistic to a todo list so new items appear instantly before the server confirms.

Section navigation