The Capstone: Assembling a Production EngiLipi Application From Everything You've Learned
See how every concept from this course — types, components, routing, auth, forms, data fetching, validation, state, persistence, search/filter/pagination, responsive and accessible UI, theming, testing, error boundaries, performance, security, and shipping — fits together into one coherent, layered, production-grade application, and learn the 'glue' patterns that make the layers compose cleanly instead of becoming a tangled mess.
You've spent this entire course learning individual pieces: a hook here, a validation schema there, a routing setup, a theming system, a test suite, a security checklist. Each of those lessons was necessarily narrow, because you can only really understand a tool by using it in isolation first. But no real application is a pile of isolated techniques — it's a system, where the pieces are deliberately arranged into layers so that a change in one place doesn't ripple uncontrollably through everything else. That arrangement is the actual skill of building production software, and it's the one thing that can't be taught one concept at a time. This capstone builds a vertical slice of EngiLipi itself — the 'Practice Problems' feature: a searchable, filterable, paginated list of coding problems, with authenticated bookmarking, that a logged-in engineer browses. It is small enough to read in one sitting and complete enough to show every layer doing real work: UI components that know nothing about data fetching, a feature module that composes hooks, an API layer that never trusts the network, runtime validation that turns 'any' into 'known-safe', state management that survives a refresh, and a shell around all of it that handles routing, auth, theming, errors, and performance. By the end, you should be able to look at any layer and say exactly which earlier lesson it descends from — and, more importantly, why it lives in that layer and not another.
Why Architecture Is the Real Lesson
Every individual technique in this course — a useReducer hook, a Zod schema, a useMemo call — is easy to justify on its own. The hard part of a real codebase is deciding where each technique lives, and making sure that decision is the same decision every time, for every feature, so that any engineer joining the team already half-understands a file before opening it. That consistency is what 'architecture' actually buys you: not cleverness, predictability.
The diagram below is the backbone of this entire lesson, and of EngiLipi's real codebase. Data and control flow downward on a request (a component asks a hook, which asks a service, which asks the backend) and flow upward on a response (validated data comes back up, gets put into state, and re-renders the UI). Every example in this lesson sits at a specific layer, and the single rule that keeps the whole thing sane is: a layer only talks to the layer directly below it. A component never calls fetch directly. A custom hook never writes raw HTML. A service function never touches React state. Violate that rule once, 'just this one time', and six months later nobody can find where the actual data-fetching logic lives anymore.
Layer 1–2: Reusable Components on a Typed Foundation
Everything sits on top of TypeScript strict mode. Every prop, every API response shape, every piece of state in this capstone has a named type — there is no any anywhere in the feature module you're about to read, and that isn't an accident, it's what strict mode plus honest typing at every boundary forces you to do. The payoff shows up exactly where it matters: the compiler refuses to let a Problem with an optional difficulty field be rendered as if it were guaranteed present, catching the bug at build time instead of in production.
On top of that foundation sit the reusable components — the dumbest layer in the whole system, and deliberately so. Badge, Spinner, EmptyState, Pagination, SearchInput know nothing about problems, users, or APIs. They accept typed props and render. This is the presentational/container split you learned early in the course, still doing work here at the very end: a component that doesn't know where its data came from can be reused by every feature module, tested with zero mocking, and reasoned about in isolation.
• Reusable components: pure functions of props, no data fetching, no business logic, fully typed with generics where useful (e.g. a generic Pagination<T> that doesn't know what T is). • TypeScript strict mode is not a lint rule you turn on at the end — it's the reason the layers above can trust each other's contracts without runtime checks everywhere.
Layer 3–4: Feature Modules and Custom Hooks — Where the Glue Lives
A feature module (here, features/practiceProblems/) is a folder, not a single file: it bundles the components, hooks, types, and API calls that are specific to one slice of the product, and it exposes a small, deliberate public surface (usually one top-level component) to the rest of the app. This is the same instinct behind splitting a big app into routes and code-split bundles — a feature module is a unit of both organization and of lazy-loading.
Inside the module, the custom hook is the seam where React-specific concerns (state, effects, re-renders) meet everything that isn't React-specific (fetching, validation, business rules). usePracticeProblems in the examples below owns the feature's state machine — loading, error, empty, and success states, plus the current search/filter/pagination parameters — and it's the only thing in the module allowed to call useState/useReducer/useEffect for this data. Every component below it just receives already-resolved, already-typed data and callbacks.
• A feature module's public API is small on purpose: import <PracticeProblemsPage />, not five internal hooks and twelve components. • Custom hooks are the boundary between 'React' and 'everything else' — they call the service layer, they don't reimplement it.
Layer 5–7: State Management, the API/Service Layer, and Runtime Validation
Below the hook sits state management proper. Not every piece of state deserves a global store — the course's rule still holds: local useState for state one component owns, useReducer for state with multiple related transitions (exactly what a search+filter+pagination panel is), and a shared store (Context, or a library like Zustand/Redux Toolkit) only for state genuinely needed across distant parts of the tree, like the authenticated user or the active theme. This capstone uses useReducer inside the hook for the list's local state, and reaches into a small global auth/theme store only for the two things that really are global.
Below state sits the API/service layer — plain, framework-agnostic async functions (getProblems, toggleBookmark) that wrap fetch, attach auth headers, handle non-2xx status codes, and return typed promises. This is the same fetch-wrapper discipline from the networking lessons: retries for 5xx, no retries for 4xx, a 401 triggers the shared auth-refresh-or-logout path, never a one-off fetch scattered inside a component.
And directly beneath the service layer — at the actual network boundary, the least trustworthy point in the whole system — sits runtime validation. TypeScript types are compile-time-only; they vanish at runtime and describe nothing about what the server actually sent back. A schema library (Zod, in these examples) is what re-establishes trust at that boundary: the raw JSON response is parsed against a schema, and only data that passes comes back up as a typed value the rest of the app can rely on without a single further null-check.
• State placement follows the rule you learned repeatedly: local first, reducer for multi-transition state, global store only for truly cross-cutting state (auth, theme). • The service layer is the single place fetch/axios is called for a feature — never inside a component, never inside a hook body directly. • Runtime validation (Zod) is what makes 'the API returns a Problem[]' an actual guarantee instead of a hopeful comment.
Cross-Cutting Concerns: Auth, Theming, Accessibility, and Persistence
Some concerns don't belong to any one layer — they wrap the whole tree. Authentication and authorization sit at the routing layer as guards (<RequireAuth>), redirecting unauthenticated users before a protected feature module ever mounts, and re-checking role/permission for actions within a page (e.g. only a logged-in user can bookmark a problem, only an admin role can edit one). Theming (light/dark/system) lives in a context provider at the root, reads prefers-color-scheme for the 'system' option, and persists the user's explicit override to localStorage so it survives a refresh — the same local-persistence pattern used for the user's last search filters, so reloading the practice problems page doesn't discard what they were looking for.
Accessibility and responsiveness aren't a separate pass bolted on afterward — they're properties every component in the reusable layer already has, because that's the layer where they're cheapest to guarantee once and reuse everywhere: correct semantic elements, visible focus states, aria-live regions for async status changes, and CSS that uses fluid/responsive units instead of fixed breakpoints wherever possible.
• Auth/authorization: route guards decide who can enter a feature; in-component checks decide who can perform an action inside it. • Theme + user preferences: Context for the live value, localStorage for what survives a reload, system media query as the default. • Accessibility and responsiveness live in the reusable-components layer so every feature inherits them for free.
Resilience: Loading/Error/Empty States and Error Boundaries
Every asynchronous operation in this capstone has four states, not one: idle, loading, error, and — the one teams forget — empty (the request succeeded but returned zero results). usePracticeProblems models this explicitly as a discriminated union rather than a handful of loose booleans, which is what makes it impossible to render 'loading' and 'error' at the same time by accident, a bug class the discriminated-union lesson exists specifically to prevent.
One layer above that, an error boundary wraps the feature module (and, more coarsely, each route) to catch render-time exceptions that slip past the hook's own error state — a malformed response that passes validation but breaks a component's rendering logic, a third-party library throwing, or a genuine bug. The boundary's job is narrow and deliberate: contain the crash to the smallest useful region of the page, log it, and show a recoverable fallback, so one broken widget doesn't take down the whole app.
• Four states per async operation: idle / loading / error / empty — each with its own explicit UI, never inferred from what's 'left over'. • Error boundaries catch what validation and state can't — render-time exceptions — and are placed per-feature and per-route, not just once at the app root.
Performance, Security, and Shipping
Performance work in this capstone is applied exactly where the earlier lessons said it should be: the search input is debounced before it ever reaches the service layer, so typing 'recursion' doesn't fire six network requests; the results list is wrapped in React.memo with a stable key so re-renders from unrelated state changes (like a theme toggle) don't re-render every row; and the feature module itself is loaded via React.lazy + Suspense from the route level, so a user who never visits Practice Problems never downloads its code.
Security is enforced at the same boundaries the security lessons identified as the real risk: the token lives in memory/a secure httpOnly cookie rather than being hand-rolled into localStorage for anything sensitive; every piece of user-supplied text that gets rendered goes through React's default escaping (no dangerouslySetInnerHTML anywhere in this module); and the runtime validation layer doubles as an input-sanitization gate — data the schema rejects never reaches a component, full stop.
None of it matters if it can't be verified and shipped safely. Every exported function in the service and hook layers has unit/integration tests (the API layer tested with mocked fetch, the hook tested with React Testing Library driving real state transitions, the components tested for accessible roles and text, not implementation details), and the whole thing is built with tsc --noEmit plus a production bundle build as a required, non-negotiable step in CI before merge — the same pipeline discipline from the CI/CD lesson, now protecting a feature that real engineers will actually use.
• Performance: debounce input at the boundary, memoize expensive re-renders, code-split by route/feature. • Security: no raw HTML injection, tokens handled deliberately, validation doubles as sanitization at the network boundary. • Shipping: type-check, test, and production-build are all required CI gates — not optional steps a developer can forget to run locally.
Code Examples
This is the runtime-validation layer from the diagram, made concrete. Notice that the TypeScript types (Problem, ProblemsPage) are derived from the schema with z.infer, not written separately — that's the glue pattern that keeps compile-time types and runtime checks from silently drifting apart as the API evolves. This file has zero React, zero fetch, zero UI: it's pure, framework-agnostic, and trivially unit-testable. If the backend ever adds a required field or changes difficulty to include a new value, .parse() in the service layer below will start throwing immediately, in development, with a precise message — instead of the bug surfacing three components downstream as 'undefined is not a function'.
export type Problem = z.infer<typeof ProblemSchema>;
This is the service layer: the only file in the feature allowed to call fetch (via the shared authorizedFetch wrapper from the security/networking lessons). It takes typed params in, and returns a typed, already-validated ProblemsPage out — by the time this promise resolves, unknown has been fully eliminated. A non-2xx status never reaches here as data; authorizedFetch throws a typed ApiError first, which the hook layer catches.
const result = ProblemsPageSchema.safeParse(raw);
Common mistake: A common shortcut is to cast the fetch response with as ProblemsPage instead of parsing it. That compiles, but it's a lie to the compiler — it silences the type error without proving anything at runtime, which defeats the entire point of having a schema in the first place.
This is the glue example the whole lesson is building toward. The hook composes: a useReducer state machine (search/filter/pagination + the four-way status), a debounced value (performance), the service layer's getProblems/toggleBookmark (already runtime-validated), request cancellation via AbortController (so a fast typist never has an old response overwrite a newer one), and a clean, minimal public return value that the UI component below consumes without knowing any of this machinery exists. Expected result: Typing 'recursion' fires exactly one network request 300ms after the user stops typing, and if they change the difficulty filter mid-flight, the stale recursion request is aborted rather than racing the new one.
return () => controller.abort(); // cancel the in-flight request if params change again
This is the top of the stack: the only file that imports both reusable components and the feature's own hook. It renders the four states explicitly, uses aria-live so screen-reader users hear when results change, and is itself wrapped by RequireAuth (routing/authorization) and ErrorBoundary (resilience) before it's exported. Every prop passed into ProblemCard, Select, and Pagination is fully typed by the compiler because everything upstream — the reducer, the hook's return type, the schema — was typed honestly from the network boundary up. In the real app, PracticeProblemsPage is the component a router route lazy-loads with React.lazy, so its ~15KB of feature code (this file, the hook, the schema, ProblemCard) is only downloaded when a user actually navigates to /practice — the code-splitting payoff of keeping a feature module self-contained.
<RequireAuth> <ErrorBoundary fallback={<ErrorBanner message="This section crashed. Please refresh." />}>
// features/practiceProblems/schema.ts
import { z } from "zod";
// The schema IS the source of truth for the shape of a Problem.
// The TypeScript type is derived from it, so the two can never drift apart.
export const DifficultySchema = z.enum(["easy", "medium", "hard"]);
export const ProblemSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1),
difficulty: DifficultySchema,
branch: z.string(), // e.g. "cs", "mechanical", "electrical"
tags: z.array(z.string()),
isBookmarked: z.boolean(),
solvedCount: z.number().int().nonnegative(),
});
export const ProblemsPageSchema = z.object({
items: z.array(ProblemSchema),
page: z.number().int().positive(),
pageSize: z.number().int().positive(),
totalCount: z.number().int().nonnegative(),
});
// Types are inferred, never hand-written — one source of truth, zero drift.
export type Difficulty = z.infer<typeof DifficultySchema>;
export type Problem = z.infer<typeof ProblemSchema>;
export type ProblemsPage = z.infer<typeof ProblemsPageSchema>;// features/practiceProblems/api.ts
import { ProblemsPageSchema, type ProblemsPage } from "./schema";
import { authorizedFetch } from "../../services/httpClient"; // shared fetch wrapper
import { ApiError } from "../../services/errors";
export interface FetchProblemsParams {
search: string;
difficulty: "all" | "easy" | "medium" | "hard";
branch: string;
page: number;
pageSize: number;
}
export async function getProblems(
params: FetchProblemsParams,
signal?: AbortSignal
): Promise<ProblemsPage> {
const query = new URLSearchParams({
search: params.search,
difficulty: params.difficulty,
branch: params.branch,
page: String(params.page),
pageSize: String(params.pageSize),
});
// authorizedFetch attaches the auth header, retries 5xx with backoff,
// and throws a typed ApiError (never a bare Error) on 4xx/5xx.
const response = await authorizedFetch(`/api/problems?${query}`, { signal });
const raw: unknown = await response.json();
// Never trust "the types line up" — prove it at the boundary.
const result = ProblemsPageSchema.safeParse(raw);
if (!result.success) {
throw new ApiError(500, "Malformed response from /api/problems", {
…// features/practiceProblems/usePracticeProblems.ts
import { useReducer, useEffect, useCallback } from "react";
import { getProblems, toggleBookmark, type FetchProblemsParams } from "./api";
import type { Problem } from "./schema";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { ApiError } from "../../services/errors";
type Status = "idle" | "loading" | "error" | "success";
interface State {
status: Status;
items: Problem[];
totalCount: number;
error: string | null;
filters: Omit<FetchProblemsParams, "search"> & { searchInput: string };
}
type Action =
| { type: "SET_FILTER"; key: "difficulty" | "branch"; value: string }
| { type: "SET_SEARCH_INPUT"; value: string }
| { type: "SET_PAGE"; page: number }
| { type: "FETCH_START" }
| { type: "FETCH_SUCCESS"; items: Problem[]; totalCount: number }
| { type: "FETCH_ERROR"; message: string }
| { type: "BOOKMARK_TOGGLED"; problemId: string; isBookmarked: boolean };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_FILTER":
return { ...state, filters: { ...state.filters, [action.key]: action.value, page: 1 } };
case "SET_SEARCH_INP
…// features/practiceProblems/PracticeProblemsPage.tsx
import { usePracticeProblems } from "./usePracticeProblems";
import { SearchInput } from "../../components/SearchInput";
import { Select } from "../../components/Select";
import { ProblemCard } from "./ProblemCard";
import { Pagination } from "../../components/Pagination";
import { Spinner } from "../../components/Spinner";
import { EmptyState } from "../../components/EmptyState";
import { ErrorBanner } from "../../components/ErrorBanner";
import { ErrorBoundary } from "../../components/ErrorBoundary";
import { RequireAuth } from "../../routing/RequireAuth";
function PracticeProblemsList() {
const {
status, problems, error, filters, pageCount,
setSearchInput, setFilter, setPage, bookmark,
} = usePracticeProblems();
return (
<section aria-labelledby="practice-problems-heading" className="pp-page">
<h1 id="practice-problems-heading">Practice Problems</h1>
<div className="pp-controls">
<SearchInput
value={filters.searchInput}
onChange={setSearchInput}
placeholder="Search problems (e.g. 'binary search')"
aria-label="Search practice problems"
/>
…EngiLipi Production Architecture — Practice Problems Feature
Requests flow down, one layer at a time; validated, typed responses flow back up. No layer skips its neighbor — that discipline is what keeps a codebase this size navigable.
┌─────────────────────────────────────────────────────────────────────┐
│ React UI (routes, layout shell, error boundaries, theme provider) │
└───────────────────────────────┬───────────────────────────────────────┘
│ renders
┌───────────────────────────────▼───────────────────────────────────────┐
│ Reusable Components (Badge, Spinner, EmptyState, Pagination, ...) │
│ — typed props only, zero data-fetching, accessible & responsive │
└───────────────────────────────┬───────────────────────────────────────┘
│ composed by
┌───────────────────────────────▼───────────────────────────────────────┐
│ Feature Module (features/practiceProblems/PracticeProblemsPage) │
│ — owns layout of this slice, wraps itself in an error boundary │
└───────────────────────────────┬───────────────────────────────────────┘
│ calls
┌───────────────────────────────▼───────────────────────────────────────┐
│ Custom Hooks (usePracticeProblems, useDebouncedValue, useAuth) │
│ — owns loading/error/empty/success state, search+filter+pagination │
└─────
…Important Rules
A layer only calls the layer directly beneath it — components never fetch, hooks never render raw HTML, services never touch React state.
Nothing from the network is trusted as typed until it has passed through runtime validation (Zod's .parse/.safeParse) — a TypeScript type alone proves nothing at runtime.
Every asynchronous operation has four explicit states (idle/loading/error/empty-success), never fewer, so no UI state is ever inferred by elimination.
Feature modules expose a small public surface (usually one component) and keep everything else — hooks, internal components, types — private to the folder.
Global state (Context/store) is reserved for what's genuinely cross-cutting (auth, theme); everything else defaults to local useState/useReducer.
Error boundaries are placed per-feature and per-route so one broken slice of the UI can never take down the whole application.
Type-check, test, and production-build are required CI gates, not developer-discretion steps — a feature isn't 'done' until all three pass.
This Is Not a Toy — It's the Actual Shape of EngiLipi's Codebase
Strip away the specific feature (practice problems could just as easily be the branch catalog, the compiler playground, or the discussion forum) and the layering in this lesson is the layering used across the whole EngiLipi application. New features get built by copying this same shape — schema, service, hook, feature component — which is precisely why the course spent so much time on each piece individually: not so you'd memorize twenty unrelated techniques, but so you'd recognize this pattern instantly the next time you open an unfamiliar feature folder in a real codebase, on this project or any other, and already know where to look for the data fetching, where the validation lives, and where a bug is most likely hiding. That recognition — not any single technique — is what makes you dangerous to hire.
const data = await response.json() as ProblemsPage;
const data = ProblemsPageSchema.parse(await response.json());
Casting with as only silences the compiler; it doesn't verify anything about what the server actually sent. .parse() (or .safeParse() for a non-throwing version) is what actually proves the shape at runtime, which is the entire reason a validation layer exists in the architecture at all.
function PracticeProblemsPage() { const [problems, setProblems] = useState([]); useEffect(() => { fetch("/api/problems").then(r => r.json()).then(setProblems); }, []); return <div>{problems.map(p => <div>{p.title}</div>)}</div>; } function PracticeProblemsPage() { const { status, problems, error } = usePracticeProblems(); // render explicit loading/error/empty/success states }
Fetching directly inside a page component collapses every layer of the architecture into one file: no reuse, no cancellation, no validation, no distinguishable loading/error/empty states, and no way to test the data logic without rendering the whole tree. This is the single most common way a codebase becomes unmaintainable as it grows.
useEffect(() => { fetchResults(searchInput); }, [searchInput]);
const debouncedSearch = useDebouncedValue(searchInput, 300); useEffect(() => { fetchResults(debouncedSearch); }, [debouncedSearch]);
Firing a network request on every keystroke wastes bandwidth, hammers the backend, and can cause response races where an older, slower request resolves after a newer one and overwrites correct results with stale ones. Debouncing at the hook boundary — plus aborting stale requests — fixes both problems at once.
In this lesson's architecture, why does the runtime-validation layer sit between the API/service layer and the backend rather than, say, inside the custom hook? A. Because Zod schemas can only be imported into .ts files that make network calls B. Because it's the earliest point the raw, untrusted response exists, so validating there means nothing above it ever handles unverified data C. Because React hooks are not allowed to import validation libraries D. It doesn't matter where validation happens as long as it happens somewhere in the app
B. Because it's the earliest point the raw, untrusted response exists, so validating there means nothing above it ever handles unverified data — Validation belongs as close to the network boundary as possible so that every layer above it — hooks, feature components, reusable components — can treat the data as fully trusted and typed. Moving it later just relocates the risk further into the app, where more code would need to handle the 'what if this is wrong' case.
The custom hook example (usePracticeProblems) cancels the previous fetch with AbortController whenever debouncedSearch or a filter changes. What problem does this specifically solve? A. It prevents the reducer from re-rendering the component more than once B. It stops a slower, older request from resolving after a newer one and overwriting fresher results with stale data C. It is required for TypeScript's strict mode to compile the file D. It replaces the need for a loading state entirely
B. It stops a slower, older request from resolving after a newer one and overwriting fresher results with stale data — Without cancellation, rapid filter/search changes can produce a race where an earlier, slower request finishes after a later, faster one — silently showing the user stale results with no error. Aborting the stale request when a new one starts eliminates that race.
Why does PracticeProblemsPage render four distinct branches (loading, error, empty-success, success) instead of, say, just checking if (!problems.length) return <Spinner />? A. Because JSX cannot contain more than one conditional B. Because collapsing states loses information — an empty successful response looks identical to 'still loading' or 'failed', which is a real, user-visible bug class C. Four branches are required by the ESLint config used in this course D. It has no functional benefit; it's purely a style preference
B. Because collapsing states loses information — an empty successful response looks identical to 'still loading' or 'failed', which is a real, user-visible bug class — If 'no items yet' is used to mean both loading and empty, a search with zero real matches will show a spinner forever, or a genuinely loading list will flash an 'nothing found' message. Modeling all four states explicitly (as the reducer's status field does) makes that class of bug structurally impossible.
Where does RequireAuth sit relative to ErrorBoundary in the final PracticeProblemsPage export, and why does that ordering matter? A. RequireAuth wraps ErrorBoundary, so an unauthenticated user is redirected before the protected feature (and its potential crashes) ever mount B. The order is arbitrary and has no effect on behavior C. ErrorBoundary must always be the outermost component in any React tree D. RequireAuth and ErrorBoundary cannot be nested and must be used in separate route trees
A. RequireAuth wraps ErrorBoundary, so an unauthenticated user is redirected before the protected feature (and its potential crashes) ever mount — Authorization is checked first so an unauthenticated user is redirected to login before the feature (and anything that could throw inside it) is even given a chance to render — keeping the security check outside, and therefore never dependent on, the resilience mechanism meant for a different kind of failure.
The lesson states a feature module should expose 'a small public surface, usually one component.' What is the main practical benefit of that discipline as an app grows to dozens of features? A. It makes the bundler produce smaller CSS files B. It lets any other part of the app depend on one stable import per feature, without needing to know or care about that feature's internal hooks, reducers, or helper components C. It is required for React.lazy to function at all D. It automatically generates the feature's unit tests
B. It lets any other part of the app depend on one stable import per feature, without needing to know or care about that feature's internal hooks, reducers, or helper components — A small, deliberate public surface means the rest of the app forms a dependency only on the feature's top-level component (e.g. PracticeProblemsPage), not on its internals — so the internals (the reducer shape, the hook's implementation, helper components) can be refactored freely without breaking anything outside the folder, which is exactly what keeps a large, multi-team codebase maintainable over time.
Extend the Practice Problems feature with a 'Saved for Later' view: a new route, guarded the same way as the main page, that shows only problems where isBookmarked === true, reusing as much of the existing architecture as possible. Decide, and be able to justify: does this need a new API endpoint and schema, or can it reuse getProblems with an added filter param? Does it need its own reducer, or can usePracticeProblems be generalized to accept a bookmarkedOnly flag? Where does the new route's error boundary go?
Add bookmarkedOnly: boolean to FetchProblemsParams and the reducer's filters, forward it as a query param in getProblems, and add a SET_BOOKMARKED_ONLY action. Create a thin SavedProblemsPage that renders <PracticeProblemsList /> (extracted to accept an initial-filter prop) inside its own <RequireAuth><ErrorBoundary> at the new route, so the crash-containment boundary matches route granularity exactly as the architecture rules require, without duplicating the schema, service function, or reducer.
Prefer extending the existing service/hook over duplicating them — add bookmarkedOnly?: boolean to FetchProblemsParams, thread it through the query string and the reducer's filters, and reuse ProblemsPageSchema unchanged since the response shape doesn't change, only which rows the backend returns.