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
valueprop of a Provider? - When should I use
useContextvs the newuse()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
useContextvs the newuse()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>;
}
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>;
}
Now:
- No intermediate component needs a
userprop. - 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
RouterProvideror a customUserContextProvider?”
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;
}
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 doesvalueactually represent?”
Senior perspective:
The
valueprop is the public API of your context — the data and functions any consumer can access viauseContextoruse().
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);
Inside the provider:
const value: AuthContextValue = {
user,
authStatus,
isAuthenticated: authStatus === "authenticated",
login,
logout,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
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’svalueas 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
💬 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, whileuseContextremains 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
useStateinside a Context Provider, why does the user session disappear when the page is reloaded?”
Because:
-
useStatelives in JavaScript memory only. - On a full reload, the entire JS bundle runs from scratch.
- All components remount, all
useStatecalls 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;
}
💬 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 justuseState.”
6. PrivateRoute — Protecting Routes with Context + React Router
Quiz version:
“What’s the main logic of a
PrivateRoutecomponent?”
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;
}
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);
💬 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>
);
}
- 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 />;
}
- 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
},
},
],
},
]);
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>
💬 Interview soundbite
“In data mode, React Router defines routes as JS objects viacreateBrowserRouter. 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
valueas a public API — expose derived values likeisAuthenticatedinstead 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
isAuthenticatedinside the provider. - [ ] Use
useEffectin the provider to hydrate fromlocalStorage, cookies, or server data. - [ ] Always provide
loginandlogoutcommands as part of the contextvalue.
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
PrivateRoutecomponents that read fromAuthContextand return eitherchildrenor<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)