Frontend codebases rarely decay because an engineer struggled to write a dropdown component. They decay because the boundaries between components, network requests, and application state dissolve over time.
At first, everything feels lightweight. You write a few components, fetch data inside a useEffect, drop some state into a global store, and ship. Six months later, changing a single backend field breaks four unrelated screens, components re-render unpredictably, and every feature pull request requires an archaeological survey of the entire src/ directory.
Building scalable frontend systems means treating the browser as a distributed client: managing stale caches, handling network boundaries defensively, designing for inevitable failure states, and testing contracts rather than implementation trivia.
Colocate code by business feature, not technical role
The standard framework template—splitting code into components/, hooks/, utils/, and services/—works for introductory tutorials, but it falls apart under organizational scale. When a feature changes, you end up jumping between seven directories across forty files. Worse, it encourages code reuse where no shared domain logic actually exists.
A feature-first structure groups everything required to render, test, and maintain a business capability into a cohesive slice:
src/
features/
projects/
api/
projectQueries.ts # Fetching, prefetching & TanStack query keys
projectMutations.ts # Mutations & cache invalidation logic
components/
ProjectCard.tsx # Feature-specific presentation
ProjectGrid.tsx
ProjectStatusBadge.tsx
model/
projectTypes.ts # Typed domain entities & schemas
projectNormalizer.ts # Network-to-domain boundary adapter
screens/
ProjectsDashboard.tsx # Full screen composition
index.ts # Explicit public API for other features
shared/
components/ # Pure UI design primitives (Buttons, Modals, Inputs)
lib/ # Generic utilities (formatting, client configs)
styles/ # Global tokens & theme definitions
The index.ts file acts as an architectural boundary. It defines the public surface of the feature; anything not explicitly exported is private to that feature. When another team member refactors internal helpers inside features/projects/, they know with mathematical certainty that external features cannot be broken.
Treat the network boundary as an untrusted contract
External APIs change without warning. Database columns get renamed, backend engineers switch from camelCase to snake_case, and nullable relations return unexpected values.
If your UI components consume raw API payloads directly, every backend schema change ripples across dozens of components. The solution is an Anti-Corruption Layer: normalize and validate responses at the immediate network boundary.
// features/projects/model/projectNormalizer.ts
import { z } from 'zod';
export const RawProjectResponseSchema = z.object({
id: z.string(),
project_title: z.string(),
description: z.string().nullable().optional(),
lifecycle_state: z.string(),
created_at: z.string(),
updated_at: z.string(),
});
export type RawProjectResponse = z.infer<typeof RawProjectResponseSchema>;
export type ProjectDomainModel = {
id: string;
title: string;
summary: string;
status: 'draft' | 'active' | 'archived';
lastModifiedDate: Date;
};
export function toProjectDomainModel(raw: unknown): ProjectDomainModel {
const parsed = RawProjectResponseSchema.parse(raw);
return {
id: parsed.id,
title: parsed.project_title.trim(),
summary: parsed.description ?? 'No project summary provided.',
status: normalizeProjectStatus(parsed.lifecycle_state),
lastModifiedDate: new Date(parsed.updated_at),
};
}
function normalizeProjectStatus(rawStatus: string): ProjectDomainModel['status'] {
const clean = rawStatus.toLowerCase();
if (clean === 'active' || clean === 'archived') return clean;
return 'draft';
}
Now, your UI components bind strictly to ProjectDomainModel. If the backend changes project_title to display_name tomorrow, you update a single line in your normalizer. Your components, filters, and test suites remain untouched.
The four tiers of application state
One of the costliest architectural mistakes in React is treating all state equally. Shoving network responses into Redux or maintaining complex form state inside React Context creates synchronization nightmares.
Mature applications separate state into four explicit tiers based on lifecycle and ownership:
- Server Cache (TanStack Query / SWR / Server Components): Asynchronous data whose source of truth lives in a remote database. Needs caching, background revalidation, stale timeouts, and deduplication.
- URL State (Search params & route paths): The most overlooked state in web applications. Filtering, pagination, active tabs, and opened drawer IDs belong in the URL. If a user refreshes the browser or shares a link, the exact visual state should be restored.
- Local Interaction State (
useState,useReducer): Ephemeral UI data that only matters to a single component or immediate subtree—a hover tooltip, dropdown openness, or input draft before submit. - Shared Client State (Zustand / Redux Toolkit): Truly global, cross-cutting state with no server representation—active workspace preferences, an audio playback queue, or an offline multi-step checkout wizard.
// features/projects/api/projectQueries.ts
import { useQuery } from '@tanstack/react-query';
import { toProjectDomainModel, type ProjectDomainModel } from '../model/projectNormalizer';
export const projectKeys = {
all: ['projects'] as const,
lists: () => [...projectKeys.all, 'list'] as const,
list: (filters: Record<string, string>) => [...projectKeys.lists(), filters] as const,
detail: (id: string) => [...projectKeys.all, 'detail', id] as const,
};
export function useProjects(filters: Record<string, string>) {
return useQuery({
queryKey: projectKeys.list(filters),
queryFn: async ({ signal }) => {
const params = new URLSearchParams(filters);
const res = await fetch(`/api/projects?${params}`, { signal });
if (!res.ok) {
throw new Error(`Failed to fetch projects: ${res.status} ${res.statusText}`);
}
const json = await res.json();
return (json.data as unknown[]).map(toProjectDomainModel);
},
staleTime: 60 * 1000, // Data stays fresh for 1 minute before background revalidation
});
}
By keeping query keys structured through a factory (projectKeys), invalidating related queries becomes type-safe and deterministic.
Mutations must manage optimistic feedback and cache invalidation
A mutation is not complete when the PATCH or POST request resolves. The UI must provide instant feedback, handle network errors with rollback guarantees, and revalidate affected caches.
// features/projects/api/projectMutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { projectKeys } from './projectQueries';
import type { ProjectDomainModel } from '../model/projectNormalizer';
export function useArchiveProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (projectId: string) => {
const res = await fetch(`/api/projects/${projectId}/archive`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) throw new Error('Archive operation failed');
return res.json();
},
// When mutation is initiated, apply an optimistic update
onMutate: async (projectId: string) => {
// Cancel any outgoing refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: projectKeys.all });
// Snapshot previous value for rollback
const previousProjects = queryClient.getQueryData<ProjectDomainModel[]>(projectKeys.lists());
// Optimistically update the cache
if (previousProjects) {
queryClient.setQueryData<ProjectDomainModel[]>(
projectKeys.lists(),
previousProjects.map(p => (p.id === projectId ? { ...p, status: 'archived' } : p))
);
}
return { previousProjects };
},
// If the server rejects the mutation, restore previous state
onError: (_err, _projectId, context) => {
if (context?.previousProjects) {
queryClient.setQueryData(projectKeys.lists(), context.previousProjects);
}
},
// Always invalidate after settled to ensure server truth
onSettled: () => {
queryClient.invalidateQueries({ queryKey: projectKeys.all });
},
});
}
This pattern prevents UI lag: the user sees the card change status in under 16ms, while the application safely handles network drops without showing desynchronized data.
Design for the six real UI states before writing the happy path
Tutorial code assumes two states: isLoading ? <Spinner /> : <Data />. In production, binary state handling creates jarring layouts, flickering spinners, and confusing error screens.
Every real-world screen has at least six distinct operational states:
- Initial Pending: First visit with zero cache; render skeleton cards matching the eventual layout to prevent Cumulative Layout Shift (CLS).
- Background Revalidating: Cached data is already displayed on screen; indicate activity with a subtle progress bar rather than blowing away existing content with a full-screen spinner.
- Empty: Request succeeded, but zero records matched; offer actionable next steps (e.g., "Clear active filters" or "Create your first project").
- Actionable Error: Differentiate between transient network blips (show a "Try Again" button with exponential backoff) and authorization errors (redirect or show an upgrade prompt).
- Partial / Degraded: When a dashboard aggregates five data sources and one times out; render the four successful widgets and an inline recovery badge for the failed one.
- Optimistic Pending: An edit is queued in the browser while awaiting network confirmation.
export function ProjectsDashboardScreen({ filters }: { filters: Record<string, string> }) {
const { data: projects, isPending, isError, isFetching, error, refetch } = useProjects(filters);
// 1. Initial Pending: Zero cache available
if (isPending) {
return <ProjectsSkeletonGrid count={6} />;
}
// 2. Actionable Error
if (isError) {
return (
<DashboardErrorState
title="Unable to load project workspace"
description={error.message}
onRetry={() => refetch()}
/>
);
}
// 3. Empty State
if (projects.length === 0) {
return (
<DashboardEmptyState
title="No projects match your current filters"
ctaLabel="Reset all filters"
onAction={() => resetFilterParams()}
/>
);
}
// 4. Data rendered with subtle background revalidation indicator
return (
<section>
{isFetching && <TopLoadingBar aria-label="Syncing with server..." />}
<ProjectGrid projects={projects} />
</section>
);
}
Notice the crucial difference between isPending and isFetching. In TanStack Query v5, isPending means there is no data at all yet. isFetching indicates any background network activity. Conflating the two replaces helpful screens with disruptive spinners during simple tab switches.
Use Next.js as an explicit request boundary
Pages should act as composition roots, orchestrating authentication and data prefetching before handing execution over to feature components.
In Next.js Pages router, prefetch the query into the server-side QueryClient and pass the serialized cache through dehydrate:
// pages/projects/index.tsx
import { QueryClient, dehydrate } from '@tanstack/react-query';
import type { GetServerSidePropsContext } from 'next';
import { projectKeys } from 'features/projects/api/projectQueries';
import { projectService } from 'backend/services/projectService';
import { ProjectsDashboardScreen } from 'features/projects/screens/ProjectsDashboard';
export async function getServerSideProps(context: GetServerSidePropsContext) {
const session = await getSession(context.req);
if (!session) {
return {
redirect: { destination: '/sign-in', permanent: false },
};
}
const queryClient = new QueryClient();
// Prefetch data on the server during SSR
await queryClient.prefetchQuery({
queryKey: projectKeys.list({ ownerId: session.user.id }),
queryFn: () => projectService.listByOwner(session.user.id),
});
return {
props: {
dehydratedState: dehydrate(queryClient),
},
};
}
export default function ProjectsPage() {
return <ProjectsDashboardScreen filters={{}} />;
}
In modern Next.js App Router, this responsibility divides even more cleanly: React Server Components (RSC) fetch directly against backend databases or internal microservices on the server without shipping JavaScript bundles to the browser, while interactive client islands mount at the interaction leaves.
Test behavioral contracts, not implementation trivia
The most brittle tests are those that assert whether a component has a particular internal state variable or whether a specific internal hook was invoked. Those tests break the moment you refactor without catching real bugs.
High-leverage frontend tests mock the network layer with Mock Service Worker (MSW) and assert against observable user outcomes:
import { render, screen, userEvent } from 'test-utils';
import { http, HttpResponse } from 'msw';
import { server } from 'test-mocks/server';
import { ProjectsDashboardScreen } from './ProjectsDashboard';
it('allows a user to recover when the network fails and retry succeeds', async () => {
const user = userEvent.setup();
// Mock initial failure
server.use(
http.get('/api/projects', () => {
return HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 });
}, { once: true })
);
render(<ProjectsDashboardScreen filters={{}} />);
// User sees clear error message
expect(await screen.findByText(/Unable to load project workspace/i)).toBeInTheDocument();
// Next request succeeds with project data
server.use(
http.get('/api/projects', () => {
return HttpResponse.json({
data: [{ id: 'p1', project_title: 'Platform Redesign', lifecycle_state: 'active', updated_at: '2026-08-01' }],
});
})
);
// Click retry button
await user.click(screen.getByRole('button', { name: /try again/i }));
// Interface recovers and renders project card
expect(await screen.findByRole('heading', { name: /platform redesign/i })).toBeInTheDocument();
});
Because MSW intercepts requests at the network transport layer (fetch), you test the real serialization, schema parsing, normalizers, and React component tree together. You can rewrite your hooks completely without having to rewrite your test suite.
Performance is an architectural discipline
Performance optimization rarely comes from sprinkling useMemo and useCallback indiscriminately across simple components. In fact, shallow memoization often introduces more memory and comparison overhead than it saves.
True frontend performance is governed by macroscopic architectural decisions:
- Eliminate Request Waterfalls: Avoid components that render, fetch data, discover a child component, render that child, and trigger another fetch. Prefetch queries at the route boundary or parallelize requests.
- Dynamic Imports for Heavy Payloads: Defer rich text editors, 3D Canvas visualizers, or large charting libraries using
next/dynamicso initial page chunks remain under 70KB gzip. - Guard Interaction to Next Paint (INP): Defer expensive, non-urgent state recalculations using
useDeferredValueoruseTransition, allowing the main thread to process keystrokes and button clicks immediately. - Reserve Layout Geometry: Prevent layout shifts by allocating explicit aspect ratios and skeleton dimensions for dynamic images and async cards before data finishes loading.
The standard of craftsmanship
Reliable software engineering is rarely glamorous. It doesn't rely on hype cycles or rewriting your application every time a new library trends on social media.
It shows up in the quiet, disciplined decisions:
- Boundaries are explicitly defined and defensively typed.
- Stale data rules and cache invalidation are predictable.
- Failure states are planned, designed, and graceful.
- Tests describe genuine user workflows.
- A new engineer can join the team and deploy a feature on their first week without breaking distant parts of the product.
React and Next.js offer exceptional primitives. The engineering craft is knowing where each primitive belongs and having the discipline to keep them in their proper place.
