React Next Steps

If you’ve worked through the earlier lessons in this course, you already know the core of React: components, JSX, props, state with useState, side effects with useEffect, and the general shape of the render-and-reconcile cycle. That core is genuinely enough to build real applications. But as apps grow, you’ll hit problems that useState and useEffect alone don’t solve elegantly — state that needs to be shared across many components, state transitions that are too complex for a single setter, and bundles that get too large to load all at once. This lesson is a map, not a deep dive: it introduces the next layer of React APIs and tools, shows working code for each, and points you toward what to study next.

Overview: Where You Are, and Where to Go Next

Everything in React still boils down to the same loop you’ve already learned: state changes, React re-renders the affected components, builds a new Virtual DOM tree, diffs it against the previous tree (reconciliation), and commits the minimal set of real DOM changes. Every API in this lesson is built on top of that loop — nothing here replaces it. What changes as apps grow is how state is organized and how much code has to load before the user sees anything. Four common growth problems, and the tool that addresses each:

  • Prop drilling — passing a prop through five components that don’t use it, just to reach the one that does. Solved with the Context API (createContext / useContext), which lets a value skip straight from a provider to any descendant that reads it.
  • Tangled state updates — several useState calls whose updates depend on each other, spread across many event handlers. Solved with useReducer, which centralizes all the transition logic into one function.
  • Slow initial load — the whole app’s JavaScript downloads before anything renders. Solved with code-splitting via React.lazy and <Suspense>, which defer loading a component’s code until it’s actually needed.
  • Multiple pages/views — React itself has no concept of a URL. Solved by adding a router, most commonly React Router (<Routes>, <Route>, useNavigate, useParams), which is its own lesson in this course.

Beyond these four, there’s a wider ecosystem worth knowing exists: dedicated state-management libraries (Redux Toolkit, Zustand, Jotai) for very large apps where Context alone gets unwieldy; testing tools (React Testing Library, Vitest/Jest) for verifying components behave correctly; TypeScript for catching prop and state type errors before runtime; performance tools (useMemo, useCallback, memo, and the React DevTools Profiler) for apps with visible re-render cost; and meta-frameworks (Next.js, Remix) that add server rendering, file-based routing, and data-loading conventions on top of React. You don’t need all of these at once — reach for each only when you actually hit the problem it solves.

Syntax: A Quick-Reference Map

Tool Minimal form Use it when…
createContext / useContext const Ctx = createContext(default); useContext(Ctx) Many components need the same value without passing it as a prop at every level.
useReducer const [state, dispatch] = useReducer(reducer, initialState) State updates depend on the previous state in non-trivial ways, or there are many related actions.
React.lazy const X = lazy(() => import("./X")) A component is large or rarely needed on first load (a modal, a settings page, a chart library).
<Suspense> <Suspense fallback={<Spinner />}><X /></Suspense> Required wrapper around any lazy component; shows a fallback while it loads.
useMemo / useCallback / memo useMemo(() => compute(), [deps]) A profiler shows a specific, measurable re-render or recompute cost worth avoiding.

Examples

Example 1: Global State with the Context API

import { createContext, useContext, useMemo, useState } from "react";

const ThemeContext = createContext(null);

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("light");

  const toggleTheme = () => {
    setTheme((prev) => (prev === "light" ? "dark" : "light"));
  };

  const value = useMemo(() => ({ theme, toggleTheme }), [theme]);

  return (
    <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
  );
}

function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error("useTheme must be used inside a ThemeProvider");
  }
  return context;
}

function ThemedButton() {
  const { theme, toggleTheme } = useTheme();

  return (
    <button onClick={toggleTheme}>
      Current theme: {theme}. Click to toggle.
    </button>
  );
}

function App() {
  return (
    <ThemeProvider>
      <ThemedButton />
    </ThemeProvider>
  );
}

export default App;

This renders a button reading “Current theme: light. Click to toggle.” Clicking it flips the shared theme value and the button’s own text updates to “Current theme: dark. Click to toggle.” without any prop being passed down manually. Any other component nested inside ThemeProvider could call useTheme() and read the same value — that’s the whole point of Context: it broadcasts a value to an entire subtree. Note the small custom hook, useTheme, wrapping useContext: it’s a common pattern that gives you a friendlier API and a clear error if someone forgets the provider.

Example 2: Complex State with useReducer

import { useReducer } from "react";

const initialState = { count: 0 };

function counterReducer(state, action) {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };
    case "decrement":
      return { count: state.count - 1 };
    case "reset":
      return initialState;
    default:
      throw new Error(`Unknown action type: ${action.type}`);
  }
}

function Counter() {
  const [state, dispatch] = useReducer(counterReducer, initialState);

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: "increment" })}>+1</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-1</button>
      <button onClick={() => dispatch({ type: "reset" })}>Reset</button>
    </div>
  );
}

export default Counter;

This renders “Count: 0” with three buttons. Clicking +1 or -1 updates the count by dispatching an action — a plain object describing what happened — instead of computing the next value inline at the call site. Reset returns to “Count: 0”. The advantage over useState shows up as the logic grows: every possible transition lives in one counterReducer function, which is easy to read top to bottom, easy to unit test in isolation (it’s a plain function, no rendering involved), and easy to extend with new action types without touching the component’s JSX.

Example 3: Code-Splitting with lazy and Suspense

import { lazy, Suspense, useState } from "react";

const SettingsPanel = lazy(() => import("./SettingsPanel.jsx"));

function App() {
  const [showSettings, setShowSettings] = useState(false);

  return (
    <div>
      <button onClick={() => setShowSettings(true)}>Open Settings</button>
      {showSettings && (
        <Suspense fallback={<p>Loading settings…</p>}>
          <SettingsPanel />
        </Suspense>
      )}
    </div>
  );
}

export default App;

Initially this renders just the “Open Settings” button — the code for SettingsPanel.jsx hasn’t been downloaded yet, so it isn’t part of the initial bundle. Clicking the button briefly shows “Loading settings…” while the browser fetches that chunk, then swaps in the real, rendered SettingsPanel once the dynamic import() resolves. This is the same technique frameworks use automatically for routes: split rarely-needed code into its own file so the first page load stays small.

Under the Hood: How These Pieces Fit the Render Cycle

None of this bypasses reconciliation — it just changes what triggers it and when. A Context Provider stores its value like any other piece of data; when that value changes (by reference, using Object.is comparison), React re-renders every component that calls useContext on that context, regardless of whether those components are nested deep in the tree or memoized with React.memo — Context reads bypass memoization checks on the props chain because the subscription is direct, not prop-based. That’s why Example 1 wraps the provider’s value in useMemo: without it, every render of ThemeProvider (even for unrelated reasons) creates a new object, and every consumer re-renders needlessly.

useReducer triggers a re-render through the exact same mechanism as useState — in fact, useState is implemented internally as a reducer with a built-in “replace value” action. Calling dispatch queues an update, React runs your reducer with the current state and the action to compute the next state, and if that next state differs from the current one, the component (and its subtree) re-renders.

React.lazy works differently from the other two: it doesn’t change state at all. Calling it returns a special component that, the first time it’s rendered, starts the dynamic import() and throws a promise. React catches that thrown promise at the nearest ancestor <Suspense> boundary, renders the fallback in its place, and re-attempts rendering the real component once the promise resolves — at which point normal reconciliation swaps the fallback out for the loaded component’s output.

Common Mistakes

Mistake 1: An unmemoized object as a Context value

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("light");

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

The object literal { theme, setTheme } is recreated on every render of ThemeProvider, which means it’s a new value by reference every time — even when theme itself hasn’t changed. Every component consuming this context re-renders on every provider render, not just when the theme actually changes. Fix it by memoizing the value, exactly as shown in Example 1: const value = useMemo(() => ({ theme, setTheme }), [theme]);.

Mistake 2: Rendering a lazy component with no Suspense boundary

const SettingsPanel = lazy(() => import("./SettingsPanel.jsx"));

function App() {
  const [showSettings, setShowSettings] = useState(false);
  return (
    <div>
      <button onClick={() => setShowSettings(true)}>Open Settings</button>
      {showSettings && <SettingsPanel />}
    </div>
  );
}

This throws an error at render time: “A component suspended while responding to synchronous input” (or similar), because there’s no <Suspense> ancestor to catch the promise SettingsPanel throws while loading. Every lazy component needs a <Suspense> boundary somewhere above it in the tree — wrap the conditional render exactly as in Example 3.

Best Practices

  • Reach for Context only for values that are genuinely global to a subtree (theme, authenticated user, locale) — not as a shortcut to avoid passing two or three props down one or two levels.
  • Always memoize a Context provider’s value with useMemo if it’s an object or array, or every consumer re-renders on every provider render.
  • Split a large Context into multiple smaller ones (e.g. a rarely-changing “user” context and a frequently-changing “notifications” context) so unrelated updates don’t force unrelated re-renders.
  • Prefer useReducer once you find yourself writing more than two or three related useState calls that update together, or when the “next state” depends on multiple pieces of the “previous state” at once.
  • Code-split at natural boundaries — routes, modals, rarely-used panels — rather than every single component, which just adds loading-state overhead without a meaningful bundle-size win.
  • Always give <Suspense fallback> something quick and simple (a spinner or skeleton), since it may appear for only a fraction of a second on a fast connection.
  • Learn tools in the order you hit their problems: Context and useReducer first, then a router, then performance tools with the DevTools Profiler in hand (don’t optimize what you haven’t measured), then testing, then TypeScript, then a meta-framework if you need server rendering or file-based routing.
  • Read the official docs at react.dev as your primary reference going forward — this course covers the fundamentals thoroughly, but react.dev stays current with every new API.

Practice Exercises

  1. Build a LanguageContext that stores a current language string ("en" or "es") and a function to change it. Create a provider, a custom useLanguage hook, and two components: one that displays a greeting in the current language, and one with buttons to switch languages. Remember to memoize the context value.
  2. Take a component that manages a shopping cart with several useState calls (items array, total price, discount code) and refactor it to use a single useReducer with actions like "add-item", "remove-item", and "apply-discount". Expected result: the component’s JSX only calls dispatch, and all the logic for computing the new cart state lives in one reducer function.
  3. Take any component in a small project of yours that isn’t needed on first load (a modal, an “About” page, a chart) and convert its import to React.lazy, wrapping its usage in <Suspense fallback={<p>Loading…</p>}>. Confirm in your browser’s network tab that its code now loads in a separate chunk, only once it’s actually rendered.

Summary

  • The render-reconcile-commit cycle you already learned doesn’t change — these tools just change what triggers it and how state is organized.
  • The Context API (createContext / useContext) shares a value across a subtree without prop drilling; always memoize the provider’s value if it’s an object or array.
  • useReducer centralizes complex, related state transitions into one testable function, using the same update mechanism as useState under the hood.
  • React.lazy plus <Suspense> defers loading a component’s code until it’s needed, keeping the initial bundle small; every lazy component needs a Suspense ancestor.
  • Routing (React Router), performance memoization (useMemo/useCallback/memo), TypeScript, testing, and meta-frameworks like Next.js are the natural next stops — adopt each when you actually hit the problem it solves, not before.
  • react.dev is the authoritative, continuously updated reference to keep learning from after this course.