Skip to main content

Mastering React Fundamentals: Components, Hooks, and Modern Patterns

Published: December 12, 2025 Updated: May 24, 2026 Larry Qu 12 min read

React remains the most widely adopted library for building interactive user interfaces. This guide goes beyond the basics — it covers the hooks you use daily, the ones you reach for in complex scenarios, and the patterns that separate junior from senior React development. Each section includes realistic code examples, explanations of why each pattern matters, and links to authoritative resources.


Functional Components as the Unit of Composition

Functional components are JavaScript functions that return JSX. They replaced class components as the standard because they are simpler (no this binding), composable, and compatible with hooks.

A basic functional component with local state:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser);
  }, [userId]);

  if (!user) return <p>Loading user…</p>;
  return <h2>{user.name}</h2>;
}

Components that return JSX from a single function are easy to reason about — they take props and state in, and return UI. Break complex views into smaller components that each handle one concern.


JSX — Syntax Born from JavaScript

JSX is a syntax extension that compiles to React.createElement calls. It lets you write markup alongside logic.

const userName = 'Ada';
const greeting = <h1>Welcome, {userName}</h1>;

Key JSX rules:

  • Embed expressions with { }.
  • Use className instead of class.
  • Attributes use camelCase: onClick, readOnly, tabIndex.
  • Self-closing tags required for elements with no children: <img />.

JSX is compiled at build time — browsers never see it directly. This enables tooling like linting, formatting, and type-checking with TypeScript.


Essential Hooks — useState, useEffect, useContext

useState

Returns a state variable and a setter. Use the functional update form when the new value depends on the previous one.

const [count, setCount] = useState(0);
setCount(prev => prev + 1);

Derive values when possible instead of storing redundant state. Split unrelated concerns into separate useState calls to prevent accidental coupling.

useEffect

Runs side effects after render — data fetching, subscriptions, DOM manipulation.

useEffect(() => {
  fetchData(query).then(setResults);
}, [query]);

Lifecycle equivalents:

Intention Dependency Array Example
Mount (once) [] Subscribe to a channel, fetch initial data
Update (on change) [dep1, dep2] Fetch when query changes
Unmount (cleanup) Return function Unsubscribe, clear timers
useEffect(() => {
  const connection = createWebSocketConnection();
  connection.onMessage(setMessages);
  return () => connection.disconnect();
}, []);

Missing dependencies produce stale closures. Including objects or functions as dependencies without stabilizing them (via useCallback or useMemo) may cause infinite loops.

useContext

Reads a context value without prop drilling.

const AuthContext = createContext(null);

function UserAvatar() {
  const user = useContext(AuthContext);
  return <img src={user.avatar} alt={user.name} />;
}

Use context for cross-cutting concerns (theme, locale, auth). Avoid it for rapidly changing data — every consumer re-renders when the context value changes, which can cause performance issues in deep trees.


useRef — Beyond DOM References

useRef serves two distinct purposes: holding a reference to a DOM node and persisting a mutable value across renders without causing re-renders.

DOM Refs

function VideoPlayer() {
  const videoRef = useRef(null);

  function handlePlay() {
    videoRef.current?.play();
  }

  return (
    <>
      <video ref={videoRef} src="intro.mp4" />
      <button onClick={handlePlay}>Play</button>
    </>
  );
}

Mutable Values (No Re-render)

Unlike state, changing ref.current does not trigger a re-render. Use this for timers, animation frames, or any value that must persist across renders but does not affect the UI.

function Stopwatch() {
  const [elapsed, setElapsed] = useState(0);
  const intervalRef = useRef(null);

  function start() {
    intervalRef.current = setInterval(() => {
      setElapsed(prev => prev + 1);
    }, 1000);
  }

  function stop() {
    clearInterval(intervalRef.current);
  }
  // ...
}

forwardRef — Passing Refs to Children

Wrap a component with forwardRef so a parent can access its DOM node directly.

const FancyInput = forwardRef(function FancyInput(props, ref) {
  return <input ref={ref} className="fancy" {...props} />;
});

// Parent
function Form() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return <FancyInput ref={inputRef} placeholder="Name" />;
}

Callback Refs

When you need fine-grained control over when a ref is set or cleared — for example, measuring a node’s size after it mounts — use a callback ref.

function MeasureHeight() {
  const [height, setHeight] = useState(0);

  const measuredRef = useCallback(node => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height);
    }
  }, []);

  return (
    <>
      <p ref={measuredRef}>This element's height is {height}px.</p>
    </>
  );
}

Callback refs are called both when the node mounts and when it unmounts (with null), making them reliable for lifecycle-aware measurements.


useMemo and useCallback — Memoization With Purpose

Both hooks cache values between renders. The key difference: useMemo caches a computed value; useCallback caches a function reference.

When to Use useMemo

Apply useMemo to expensive computations you do not want to repeat on every render.

function ProductList({ products, filters }) {
  const visibleProducts = useMemo(() => {
    return products
      .filter(p => p.price >= filters.minPrice)
      .sort((a, b) => a.price - b.price);
  }, [products, filters]);
  // ...
}

Without useMemo, filtering and sorting would re-run on every render even when products and filters have not changed.

When to Use useCallback

Pass useCallback when you pass a function to a memoized child component. Without it, the child receives a new function reference on every render and React.memo cannot skip rendering.

function ProductPage({ productId }) {
  const handleAddToCart = useCallback(() => {
    addItemToCart(productId);
  }, [productId]);

  return <AddToCartButton onAdd={handleAddToCart} />;
}

const AddToCartButton = React.memo(function AddToCartButton({ onAdd }) {
  return <button onClick={onAdd}>Add to Cart</button>;
});

When NOT to Use Them

Scenario Why Avoid
Trivial calculations (e.g. a + b) The overhead of the memoization check exceeds recomputation cost
Functions passed to raw DOM elements React does not memoize DOM element prop comparisons
Components that always re-render anyway useCallback adds complexity but changes nothing
Before profiling shows a bottleneck Premature optimization bloats code without measurable benefit

The React Compiler (React 19+) automates many of these decisions at build time. Until you adopt it, measure with React DevTools Profiler before wrapping code in useMemo or useCallback.


useReducer — Predictable State Transitions

When state logic involves multiple sub-values or depends on complex transitions, useReducer provides a more structured alternative to useState.

const initialState = { items: [], total: 0 };

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      return {
        ...state,
        items: [...state.items, action.payload],
        total: state.total + action.payload.price,
      };
    case 'REMOVE_ITEM':
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload.id),
        total: state.total - action.payload.price,
      };
    case 'CLEAR':
      return initialState;
    default:
      return state;
  }
}

function ShoppingCart() {
  const [cart, dispatch] = useReducer(cartReducer, initialState);

  return (
    <>
      <p>Total: ${cart.total.toFixed(2)}</p>
      <button onClick={() => dispatch({ type: 'CLEAR' })}>Clear Cart</button>
      {cart.items.map(item => (
        <CartItem key={item.id} item={item}
          onRemove={() => dispatch({ type: 'REMOVE_ITEM', payload: item })} />
      ))}
    </>
  );
}

useReducer shines when:

  • Next state depends on previous state in non-trivial ways.
  • Multiple state fields must update together.
  • The logic is easier to express as a reducer (especially if you already use Redux or similar).

For simple toggles or single-value state, useState remains the clearer choice.


Custom Hooks — Compose, Extract, Reuse

Custom hooks are functions that call other hooks. They let you extract stateful logic from components so it can be tested and reused independently.

Hook 1: useDocumentTitle — Synchronizing With External State

function useDocumentTitle(title) {
  useEffect(() => {
    document.title = title;
    return () => { document.title = 'Calmops'; };
  }, [title]);
}

Usage:

function SettingsPage() {
  useDocumentTitle('Settings — Calmops');
  // ...
}

Hook 2: useLocalStorage — Persisting State Across Sessions

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = useCallback(value => {
    setStoredValue(prev => {
      const nextValue = typeof value === 'function' ? value(prev) : value;
      window.localStorage.setItem(key, JSON.stringify(nextValue));
      return nextValue;
    });
  }, [key]);

  return [storedValue, setValue];
}

This hook manages serialization, error handling, and the synchronization between React state and localStorage automatically.

Hook 3: useDebounce — Throttling Rapid Input Changes

function useDebounce(value, delay = 300) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

Usage — only search after the user stops typing:

function SearchPage() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 400);

  const results = useSearch(debouncedQuery);

  return (
    <input value={query} onChange={e => setQuery(e.target.value)} />
  );
}

Composing Hooks Together

Hooks can call other hooks. Compose small custom hooks into richer abstractions:

function useUserPreferences(userId) {
  const [prefs, setPrefs] = useLocalStorage(`prefs-${userId}`, {
    theme: 'light',
    fontSize: 16,
  });

  useDocumentTitle(`Settings — ${userId}`);

  return [prefs, setPrefs];
}

Keep custom hooks focused on a single concern. If a hook handles data fetching, form state, and keyboard shortcuts, split it into three smaller hooks.


Performance Optimization Patterns

React.memo — Preventing Unnecessary Child Renders

Wrap a component with React.memo to skip re-rendering when its props have not changed (shallow comparison).

const ExpenseRow = React.memo(function ExpenseRow({ label, amount }) {
  return (
    <tr>
      <td>{label}</td>
      <td>${amount.toFixed(2)}</td>
    </tr>
  );
});

Use React.memo on leaf components that render frequently with the same props. Do not wrap every component — the shallow comparison itself has a cost.

useId — Accessible Unique Identifiers

useId generates stable, unique IDs for accessibility attributes, avoiding hydration mismatches in server-rendered apps.

function SearchField() {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>Search</label>
      <input id={id} type="search" />
    </>
  );
}

lazy and Suspense — Code Splitting

Split your bundle so large components load only when needed.

const AdminDashboard = lazy(() => import('./AdminDashboard'));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <AdminDashboard />
    </Suspense>
  );
}

The lazy function tells React to load the component’s module on first render. Suspense shows a fallback while the module loads. Use this for routes, modals, and heavy third-party components.


Form Handling — Controlled, Uncontrolled, and React 19 Hooks

Controlled Components

React controls the input value via state. Every keystroke updates state, which re-renders the input.

function NewsletterForm() {
  const [email, setEmail] = useState('');

  function handleSubmit(e) {
    e.preventDefault();
    subscribeToNewsletter(email);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" value={email} onChange={e => setEmail(e.target.value)} />
      <button type="submit">Subscribe</button>
    </form>
  );
}

Uncontrolled Components (useRef)

The DOM manages the input value. Read it only when needed.

function UncontrolledForm() {
  const nameRef = useRef(null);

  function handleSubmit(e) {
    e.preventDefault();
    alert(`Hello, ${nameRef.current.value}`);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input ref={nameRef} defaultValue="Guest" />
      <button type="submit">Submit</button>
    </form>
  );
}

Uncontrolled inputs reduce re-renders and are useful for large forms where you only need values at submission time.

useFormStatus (React 19)

A child of a <form> can read the form’s submission status without prop drilling.

import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? 'Saving…' : 'Save'}</button>;
}

function ProfileForm() {
  return (
    <form action={updateProfile}>
      <input name="username" />
      <SubmitButton />
    </form>
  );
}

useOptimistic (React 19)

Update the UI immediately while the server request is in flight. Fall back to the real state if the request fails.

function LikeButton({ postId, initialLikes }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (state, newCount) => newCount
  );

  async function handleLike() {
    addOptimisticLike(optimisticLikes + 1);
    await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
  }

  return <button onClick={handleLike}>{optimisticLikes} ❤️</button>;
}

This pattern makes interactions feel instant while keeping eventual consistency with the server.


TypeScript Integration — Typing Every Layer

Typing Props

interface ButtonProps {
  label: string;
  variant?: 'primary' | 'secondary';
  disabled?: boolean;
  onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
  children?: React.ReactNode;
}

function Button({ label, variant = 'primary', disabled, onClick, children }: ButtonProps) {
  return (
    <button className={`btn btn--${variant}`} disabled={disabled} onClick={onClick}>
      {label} {children}
    </button>
  );
}

Typing Hooks

TypeScript infers most hook types, but you can provide generics for union states or complex reducers.

// useState with union type
const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle');

// useRef for DOM element
const inputRef = useRef<HTMLInputElement>(null);

// useRef for mutable value
const timerRef = useRef<number | null>(null);

// useReducer with typed actions
type CartAction =
  | { type: 'ADD_ITEM'; payload: Product }
  | { type: 'REMOVE_ITEM'; payload: { id: string } }
  | { type: 'CLEAR' };

function cartReducer(state: CartState, action: CartAction): CartState {
  // ...
}

Typing Events

function InputField() {
  const [value, setValue] = useState('');

  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
    setValue(event.target.value);
  }

  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={value} onChange={handleChange} />
    </form>
  );
}

Children Patterns

Since React 18, React.FC no longer includes children automatically. Declare it explicitly:

// Option A: manual
interface CardProps {
  title: string;
  children: React.ReactNode;
}

// Option B: PropsWithChildren utility
import { PropsWithChildren } from 'react';

interface CardProps {
  title: string;
}

function Card({ title, children }: PropsWithChildren<CardProps>) {
  return (
    <div className="card">
      <h3>{title}</h3>
      {children}
    </div>
  );
}

For components that accept only specific child types (e.g. Tab children inside a Tabs component), use React.ReactElement with the expected component type.


Testing Patterns — React Testing Library

React Testing Library encourages testing components the way a user interacts with them: through the DOM, not component internals.

Rendering and Querying

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';

test('increments count on button click', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  const button = screen.getByRole('button', { name: /increment/i });
  await user.click(button);

  expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
});

Query Priority

Query When to Use
getByRole Accessible elements (buttons, headings, links) — best choice
getByLabelText Form inputs with <label> associations
getByPlaceholderText Inputs with placeholder text
getByText Non-interactive text elements (paragraphs, divs)
getByTestId Escape hatch — use only when no other query works

Testing Async Behavior

test('loads and displays user data', async () => {
  render(<UserProfile userId="42" />);

  expect(screen.getByText(/loading/i)).toBeInTheDocument();
  expect(await screen.findByRole('heading', { name: /ada/i })).toBeInTheDocument();
});

Use findBy* queries (which return a promise) for elements that appear after async operations. Use waitFor for arbitrary assertion retries.

Custom Hook Testing

import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';

test('increments counter', () => {
  const { result } = renderHook(() => useCounter());

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(1);
});

What Not to Test

  • Do not test implementation details (internal state, prop names).
  • Do not use container.querySelector — it couples tests to DOM structure.
  • Do not test that a function was called unless it produces observable DOM changes.
  • Prefer userEvent over fireEvent — it dispatches the full chain of events a real browser would.

Resources

Comments

👍 Was this article helpful?