DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

React Context & Routing Mastery — From Prop Drilling Pain to Auth‑Ready Architectures

React Context & Routing Mastery — From Prop Drilling Pain to Auth‑Ready Architectures

React Context & Routing Mastery — From Prop Drilling Pain to Auth‑Ready Architectures

Most React interviews won’t ask you to build a whole app.

Instead, they quietly test your mental model of Context, routing, and auth flows with questions like:

  • What is prop drilling and how does Context solve it?
  • What exactly does a Provider do?
  • What goes inside the value prop of a Provider?
  • When should I use useContext vs the new use() API?
  • Why does auth state disappear on full page reload?
  • How would you protect routes with a PrivateRoute?
  • Why are <Link> and <Navigate> different?
  • What’s the deal with React Router’s data APIs and createBrowserRouter?

In this post we’ll turn those quiz-style questions into production patterns you can drop into your codebase and your next interview.


TL;DR — What you’ll learn

  • A precise mental model for prop drilling and why Context exists.
  • How Providers actually work and what belongs in their value.
  • When to reach for useContext vs the new use() API.
  • How to design an AuthContext that survives interviews and real apps.
  • How to implement PrivateRoute and Navigate-based redirects.
  • How React Router data mode (createBrowserRouter) changes route definitions.
  • A migration checklist to move from “random context usage” to a clean context + routing architecture.

All examples below use TypeScript-flavored .tsx, but the ideas apply to plain JS as well.


1. Prop Drilling — The Pain Context Was Designed To Fix

Quiz version:

“What is prop drilling and why is Context a solution?”

Senior answer:

Prop drilling is when data has to travel through components that don’t need it, just so a deep child can receive it. Context lets consumers read shared state directly without manual prop passing.

Example: classic prop drilling

type User = { name: string };

function App({ user }: { user: User }) {
  return <Parent user={user} />;
}

function Parent({ user }: { user: User }) {
  return <Child user={user} />;
}

function Child({ user }: { user: User }) {
  return <GrandChild user={user} />;
}

function GrandChild({ user }: { user: User }) {
  return <p>Hello, {user.name}</p>;
}
Enter fullscreen mode Exit fullscreen mode

Here Parent and Child are just pipes. They receive user only to pass it down.

This becomes painful when:

  • You have many levels of nesting.
  • The tree grows over time.
  • Multiple branches need the same data.
  • Small changes mean touching lots of intermediate components.

Context as a structural fix

With Context, deep components consume data directly:

import { createContext, useContext } from "react";

type User = { name: string };

const UserContext = createContext<User | null>(null);

function App({ user }: { user: User }) {
  return (
    <UserContext.Provider value={user}>
      <SomeTree />
    </UserContext.Provider>
  );
}

function SomeTree() {
  return (
    <section>
      <Toolbar />
      <Sidebar />
      <GrandChild />
    </section>
  );
}

function GrandChild() {
  const user = useContext(UserContext);
  if (!user) return null;
  return <p>Hello, {user.name}</p>;
}
Enter fullscreen mode Exit fullscreen mode

Now:

  • No intermediate component needs a user prop.
  • Any descendant can read the same context.
  • The tree is cleaner, less coupled, and easier to refactor.

💬 Interview soundbite

“Prop drilling is a maintainability problem, not a performance trick. Context solves it by letting components subscribe directly to shared state instead of forwarding props through layers that don’t care.”


2. Providers — Architectural Boundaries, Not Just Wrappers

Quiz version:

“What’s the fundamental purpose of a Provider component like RouterProvider or a custom UserContextProvider?”

Correct mental model:

A Provider wraps part of the component tree and exposes shared state, functions, or configuration to all descendants, without prop drilling.

Minimal but realistic provider

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

type Theme = "light" | "dark";

type ThemeContextValue = {
  theme: Theme;
  toggleTheme: () => void;
};

const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");

  const value: ThemeContextValue = {
    theme,
    toggleTheme: () => setTheme(t => (t === "light" ? "dark" : "light")),
  };

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

export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) {
    throw new Error("useTheme must be used within a ThemeProvider");
  }
  return ctx;
}
Enter fullscreen mode Exit fullscreen mode

What the Provider does not do:

  • It doesn’t care about render order — React already guarantees that.
  • It doesn’t protect against errors — that’s an Error Boundary’s job.
  • It doesn’t render a visual layout (that’s usually separate).

💬 Interview soundbite

“A Provider is an architectural boundary: inside that subtree, components can consume shared state and commands via context instead of wiring everything with props.”


3. Designing the value Prop — Your Context’s Public API

Quiz version:

“When you call <SomeContext.Provider value={...}>, what does value actually represent?”

Senior perspective:

The value prop is the public API of your context — the data and functions any consumer can access via useContext or use().

Example:

type AuthStatus = "checking" | "authenticated" | "not-authenticated";

type AuthContextValue = {
  user: { id: string; name: string } | null;
  authStatus: AuthStatus;
  isAuthenticated: boolean;      // ✅ derived
  login: (token: string) => Promise<void>;
  logout: () => void;
};

const AuthContext = createContext<AuthContextValue | undefined>(undefined);
Enter fullscreen mode Exit fullscreen mode

Inside the provider:

const value: AuthContextValue = {
  user,
  authStatus,
  isAuthenticated: authStatus === "authenticated",
  login,
  logout,
};

return (
  <AuthContext.Provider value={value}>
    {children}
  </AuthContext.Provider>
);
Enter fullscreen mode Exit fullscreen mode

Why derive isAuthenticated inside the provider?

  • Consumers don’t need to know about "checking" vs "not-authenticated".
  • If you later rename "authenticated" to "logged-in", only the provider changes.
  • Components use the simplest possible API: a boolean.

💬 Interview soundbite

“I treat the Provider’s value as a public interface. Internals like string status enums stay inside; consumers get derived booleans and functions tailored to their use cases.”


4. useContext vs the New use() API

React 18+ introduced a new use() API (primarily for Server Components and Suspense for data). It can consume:

  • Promises: const data = use(fetchSomething());
  • Contexts: const theme = use(ThemeContext);

Key difference: use() is not a hook.

That means it doesn’t follow the Rules of Hooks — it can be called in:

  • conditionals
  • loops
  • helper functions

Example that’s illegal with useContext but fine with use():

// ❌ This breaks the Rules of Hooks
// const theme = condition ? useContext(ThemeA) : useContext(ThemeB);

const theme = condition ? use(ThemeA) : use(ThemeB); // ✅ valid
Enter fullscreen mode Exit fullscreen mode

💬 Interview soundbite

use() is not a hook and doesn’t depend on call order, so it can be used inside conditionals and loops. It’s recommended for consuming context and promises in Server Components, while useContext remains the go‑to for Client Components.”


5. Why Auth Context Loses State on Full Page Reload

Quiz version:

“If an auth state is stored with useState inside a Context Provider, why does the user session disappear when the page is reloaded?”

Because:

  • useState lives in JavaScript memory only.
  • On a full reload, the entire JS bundle runs from scratch.
  • All components remount, all useState calls reset to their initial values.

If you want persistence, you must pair context with some storage:

  • localStorage / sessionStorage
  • cookies (ideally HttpOnly for tokens)
  • server session (via SSR / RSC)
  • IndexedDB

Minimal persistent auth context (localStorage style)

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

type AuthStatus = "checking" | "authenticated" | "not-authenticated";

type AuthContextValue = {
  authStatus: AuthStatus;
  isAuthenticated: boolean;
  user: { id: string; name: string } | null;
  login: (token: string) => Promise<void>;
  logout: () => void;
};

const AuthContext = createContext<AuthContextValue | undefined>(undefined);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [authStatus, setAuthStatus] = useState<AuthStatus>("checking");
  const [user, setUser] = useState<AuthContextValue["user"]>(null);

  // Load from storage on first mount
  useEffect(() => {
    const saved = localStorage.getItem("auth");
    if (!saved) {
      setAuthStatus("not-authenticated");
      return;
    }

    try {
      const parsed = JSON.parse(saved) as { user: AuthContextValue["user"] };
      setUser(parsed.user);
      setAuthStatus("authenticated");
    } catch {
      setAuthStatus("not-authenticated");
    }
  }, []);

  async function login(token: string) {
    // Call backend, get user info, etc.
    const fetchedUser = { id: "1", name: "Cristian" };
    setUser(fetchedUser);
    setAuthStatus("authenticated");
    localStorage.setItem("auth", JSON.stringify({ user: fetchedUser }));
  }

  function logout() {
    setUser(null);
    setAuthStatus("not-authenticated");
    localStorage.removeItem("auth");
  }

  const value: AuthContextValue = {
    authStatus,
    isAuthenticated: authStatus === "authenticated",
    user,
    login,
    logout,
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error("useAuth must be used within AuthProvider");
  return ctx;
}
Enter fullscreen mode Exit fullscreen mode

💬 Interview soundbite

“React state resets on full reload because it’s just in‑memory. If you want auth persistence, you need storage plus a bootstrapping effect in your AuthProvider — not just useState.”


6. PrivateRoute — Protecting Routes with Context + React Router

Quiz version:

“What’s the main logic of a PrivateRoute component?”

Senior answer:

It reads auth state (usually from context). If the user is authenticated, it renders the requested route; otherwise it redirects to /login.

Example with React Router v6+:

import { Navigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";

type PrivateRouteProps = {
  children: JSX.Element;
};

export function PrivateRoute({ children }: PrivateRouteProps) {
  const { isAuthenticated, authStatus } = useAuth();

  if (authStatus === "checking") {
    return <p>Checking session…</p>;
  }

  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }

  return children;
}
Enter fullscreen mode Exit fullscreen mode

Usage:

import { RouteObject, createBrowserRouter } from "react-router-dom";
import { PrivateRoute } from "./routes/PrivateRoute";
import { DashboardPage } from "./pages/DashboardPage";
import { LoginPage } from "./pages/LoginPage";

const routes: RouteObject[] = [
  {
    path: "/login",
    element: <LoginPage />,
  },
  {
    path: "/dashboard",
    element: (
      <PrivateRoute>
        <DashboardPage />
      </PrivateRoute>
    ),
  },
];

export const router = createBrowserRouter(routes);
Enter fullscreen mode Exit fullscreen mode

💬 Interview soundbite

“PrivateRoute doesn’t validate credentials or encrypt anything — it simply branches on auth state from context and either renders the page or <Navigate>s to /login.”


7. <Link> vs <Navigate> — Navigation vs Redirection

These two are commonly confused in interviews.

<Link> — for user‑initiated navigation

import { Link } from "react-router-dom";

function Navbar() {
  return (
    <nav>
      <Link to="/dashboard">Dashboard</Link>
      <Link to="/settings">Settings</Link>
    </nav>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • Renders an anchor-like element.
  • User clicks → client-side navigation (no full page reload).
  • Preserves React state and context.

Using a raw <a href="/dashboard"> in a SPA:

  • Triggers a full page reload.
  • Destroys React state and context tree.
  • Breaks the SPA experience.

<Navigate> — for programmatic redirection

import { Navigate } from "react-router-dom";

function LegacyPage() {
  return <Navigate to="/new-home" replace />;
}
Enter fullscreen mode Exit fullscreen mode
  • Redirects as soon as the component renders.
  • Often used in guards, legacy route migration, or after critical actions.

💬 Interview soundbite

<Link> is for user clicks and preserves SPA behavior. <Navigate> is a declarative redirect triggered during render, perfect for auth guards and legacy routes.”


8. React Router Data Mode — createBrowserRouter and Route Objects

React Router 6.4+ introduced data APIs where routes are defined as an array of JavaScript objects instead of JSX.

import { createBrowserRouter } from "react-router-dom";
import { HomePage } from "./pages/HomePage";
import { ProfilePage } from "./pages/ProfilePage";

export const router = createBrowserRouter([
  {
    path: "/",
    element: <HomePage />,
    loader: async () => {
      // fetch initial data for Home
    },
    children: [
      {
        path: "profile",
        element: <ProfilePage />,
        loader: async () => {
          // fetch profile
        },
      },
    ],
  },
]);
Enter fullscreen mode Exit fullscreen mode

Key ideas:

  • Routes are plain objects: path, element, loader, action, errorElement, children, etc.
  • Loaders & actions are colocated with the route definition.
  • Works great with Suspense, deferred data, and mutations.

Compare with the old JSX-only style:

<Routes>
  <Route path="/" element={<HomePage />} />
  <Route path="/profile" element={<ProfilePage />} />
</Routes>
Enter fullscreen mode Exit fullscreen mode

💬 Interview soundbite

“In data mode, React Router defines routes as JS objects via createBrowserRouter. This unlocks loaders, actions, and better data orchestration that JSX route trees alone couldn’t express cleanly.”


9. Context + Routing — A Practical Architecture Checklist

Here’s a checklist you can apply to your own app or discuss in a senior-level interview.

Context design

  • [ ] Only create a context when multiple distant components need the same state or commands.
  • [ ] Treat the Provider’s value as a public API — expose derived values like isAuthenticated instead of raw internal enums.
  • [ ] Keep Providers high in the tree for cross-cutting concerns (auth, theme, router, data clients).
  • [ ] For local concerns, prefer lifting state or simple props instead of new contexts.

Auth & persistence

  • [ ] Model auth with a small state machine: 'checking' | 'authenticated' | 'not-authenticated'.
  • [ ] Derive isAuthenticated inside the provider.
  • [ ] Use useEffect in the provider to hydrate from localStorage, cookies, or server data.
  • [ ] Always provide login and logout commands as part of the context value.

Routing & guards

  • [ ] Use <Link> and <NavLink> for all internal navigation — avoid raw <a> tags in SPAs.
  • [ ] Use <Navigate> for redirects during render (guards, legacy routes, post‑submit flows).
  • [ ] Implement PrivateRoute components that read from AuthContext and return either children or <Navigate to="/login" />.
  • [ ] Prefer createBrowserRouter([...]) plus loaders/actions for complex data flows.

Final Thoughts

React Context, the new use() API, and modern React Router data APIs form a coherent architecture:

  • Context handles shared state and commands (auth, theme, configuration).
  • React Router handles navigation and data loading.
  • Providers and route objects give you clear boundaries in your app.

If you can explain these ideas with the level of precision we used here — and back them up with small, focused examples — you’re already answering questions like a senior React engineer.

Feel free to reuse any of these snippets in your own repos, brown‑bag sessions, or your next dev.to article.

✍️ Written by Cristian Sifuentes — building resilient front‑ends and teaching teams how to reason about async UI, Context, and routing in modern React.

Top comments (0)