Frontend System Design
Building a single component is one thing. Designing an entire system â with hundreds of components, API calls, error handling, and performance considerations â is a completely different skill. This is where you stop thinking like a coder and start thinking like an architect.
Designing an Infinite Scroll System
Infinite scroll is everywhere â Instagram, Twitter, Reddit, YouTube. It looks simple, but designing it well involves pagination, caching, error handling, and performance. Let's build it step by step.
Step 1: Pagination Strategy
Think of it like reading a book â you don't load all 500 pages at once. You load one page at a time and turn to the next when you're ready.
There are two main approaches:
Offset-based pagination â "Give me items 20â40"
// Simple but has a flaw: if new items are added, you might skip or duplicate items
const response = await fetch(`/api/posts?offset=${page * 20}&limit=20`);Cursor-based pagination â "Give me 20 items after this specific item"
// More reliable: uses the last item's ID as an anchor point
const response = await fetch(`/api/posts?cursor=${lastItemId}&limit=20`);Common Mistake: Using offset pagination for feeds where new content is constantly added. When item #5 gets inserted while you're viewing page 2, offset pagination shows you item #20 again on page 3. Cursor-based pagination avoids this.
Step 2: The Core Component
import { useState, useEffect, useRef, useCallback } from "react";
function InfinitePostFeed() {
const [posts, setPosts] = useState([]);
const [cursor, setCursor] = useState(null);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState(null);
const sentinelRef = useRef(null);
const fetchPosts = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
setError(null);
try {
const url = cursor
? `/api/posts?cursor=${cursor}&limit=20`
: `/api/posts?limit=20`;
const res = await fetch(url);
if (!res.ok) throw new Error("Failed to load posts");
const data = await res.json();
setPosts((prev) => [...prev, ...data.posts]);
setCursor(data.nextCursor);
setHasMore(data.hasMore);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [cursor, loading, hasMore]);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) fetchPosts();
},
{ threshold: 1.0 }
);
const sentinel = sentinelRef.current;
if (sentinel) observer.observe(sentinel);
return () => {
if (sentinel) observer.unobserve(sentinel);
};
}, [fetchPosts]);
return (
<div>
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
{loading && <SkeletonLoader count={3} />}
{error && (
<div>
<p>Failed to load: {error}</p>
<button onClick={fetchPosts}>Retry</button>
</div>
)}
{!hasMore && <p>You've reached the end!</p>}
<div ref={sentinelRef} style={{ height: "1px" }} />
</div>
);
}Step 3: Skeleton Loading
Instead of a spinner, show placeholder shapes that mimic the real content. This feels faster because the user's brain recognizes "content is coming."
function SkeletonLoader({ count }) {
return (
<div>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="skeleton-card">
<div className="skeleton-avatar" />
<div className="skeleton-lines">
<div className="skeleton-line" style={{ width: "60%" }} />
<div className="skeleton-line" style={{ width: "80%" }} />
<div className="skeleton-line" style={{ width: "40%" }} />
</div>
</div>
))}
</div>
);
}Step 4: Caching Loaded Data
If a user scrolls down 100 posts, navigates away, and comes back â don't refetch everything. Cache the loaded data:
const postCache = new Map();
function useCachedPosts(feedId) {
const [posts, setPosts] = useState(() => {
return postCache.get(feedId) || [];
});
const addPosts = (newPosts) => {
setPosts((prev) => {
const updated = [...prev, ...newPosts];
postCache.set(feedId, updated);
return updated;
});
};
return { posts, addPosts };
}Remember: A well-designed infinite scroll needs four things: cursor-based pagination, skeleton loading, error handling with retry, and caching so users don't lose their scroll position.
Designing a Scalable React App
When your app grows from 10 components to 200, folder structure becomes the difference between finding a file in 2 seconds vs. 2 minutes.
The Feature-Based Folder Structure
Think of it like organizing a house. You don't put all plates in one room, all forks in another, and all cups in a third room. You organize by room (kitchen, bedroom, bathroom), and each room has everything it needs.
src/
âââ features/
â âââ auth/
â â âââ components/
â â â âââ LoginForm.tsx
â â â âââ SignupForm.tsx
â â â âââ PasswordReset.tsx
â â âââ hooks/
â â â âââ useAuth.ts
â â âââ api/
â â â âââ authApi.ts
â â âââ types.ts
â â âââ index.ts
â âââ todos/
â â âââ components/
â â â âââ TodoList.tsx
â â â âââ TodoItem.tsx
â â â âââ AddTodoForm.tsx
â â âââ hooks/
â â â âââ useTodos.ts
â â âââ api/
â â â âââ todoApi.ts
â â âââ types.ts
â â âââ index.ts
â âââ dashboard/
â âââ components/
â âââ hooks/
â âââ api/
âââ shared/
â âââ components/
â â âââ Button.tsx
â â âââ Modal.tsx
â â âââ LoadingSpinner.tsx
â âââ hooks/
â â âââ useDebounce.ts
â â âââ useLocalStorage.ts
â âââ utils/
â âââ formatDate.ts
â âââ validateEmail.ts
âââ app/
âââ layout.tsx
âââ page.tsxThe Rules
- Features are self-contained â everything for "auth" lives in the auth folder
- Shared code goes in
shared/â only code used by 2+ features - Features don't import from other features â if auth needs something from todos, it belongs in
shared/ - Each feature has an
index.tsâ the public API of that feature
// features/todos/index.ts â only export what other features need
export { TodoList } from "./components/TodoList";
export { useTodos } from "./hooks/useTodos";
export type { Todo } from "./types";Common Mistake: Organizing by file type instead of feature â putting all components in one folder, all hooks in another. This scales horribly. With 200 components in one folder, good luck finding anything.
Component Architecture â Smart vs Dumb Components
Think of it like a puppet show: the puppeteer (smart component) controls everything â fetching data, managing state, handling logic. The puppets (dumb components) just look pretty and do what they're told.
Dumb Components (Presentational)
They receive data via props and render it. No state logic, no API calls. Pure display:
function UserCard({ name, email, avatarUrl, onEdit }) {
return (
<div className="user-card">
<img src={avatarUrl} alt={name} />
<h3>{name}</h3>
<p>{email}</p>
<button onClick={onEdit}>Edit Profile</button>
</div>
);
}UserCard doesn't know where the data comes from. You could pass it data from an API, from a mock, or from a test. It doesn't care. That's the power.
Smart Components (Container)
They own the logic â fetching, state, event handling â and pass data down to dumb components:
function UserProfileContainer({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <LoadingSpinner />;
return (
<div>
<UserCard
name={user.name}
email={user.email}
avatarUrl={user.avatar}
onEdit={() => setEditing(true)}
/>
{editing && <EditProfileModal user={user} onClose={() => setEditing(false)} />}
</div>
);
}Why This Separation Matters
| Benefit | How |
|---|---|
| Reusability | UserCard can be used anywhere â profile page, admin panel, search results |
| Testing | Dumb components are easy to test â just pass props, check output |
| Readability | Smart components read like a story: "fetch user, show card, handle editing" |
| Maintainability | Change the API? Only touch the smart component. Change the UI? Only touch the dumb component |
Remember: Not every component needs to be strictly "smart" or "dumb." The principle is about separation of concerns â keep logic and presentation apart as much as reasonably possible.
State Management at Scale
In a large app, state management becomes an architectural decision. Here's a pattern that scales:
Layer Your State by Type
// Layer 1: Server state â managed by React Query
function usePosts() {
return useQuery({
queryKey: ["posts"],
queryFn: () => fetch("/api/posts").then((r) => r.json()),
});
}
// Layer 2: Global UI state â managed by Zustand
const useUIStore = create((set) => ({
sidebarOpen: false,
theme: "light",
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
setTheme: (theme) => set({ theme }),
}));
// Layer 3: Local component state â managed by useState
function PostEditor() {
const [draft, setDraft] = useState("");
return <textarea value={draft} onChange={(e) => setDraft(e.target.value)} />;
}
// Layer 4: URL state â managed by the router
function FilteredPosts() {
const searchParams = useSearchParams();
const category = searchParams.get("category") || "all";
return <PostList category={category} />;
}Each layer uses the right tool for the job:
- Server data â React Query (handles caching, refetching, staleness)
- Shared UI state â Zustand (simple, performant, no providers)
- Local UI state â useState (no reason to over-engineer)
- URL-driven state â Router/search params (shareable, bookmarkable)
Common Mistake: Putting server data (API responses) in Redux or Zustand. Server data has unique concerns â caching, staleness, revalidation, pagination. React Query or SWR handle these far better than a generic state library.
API Layer Design
Don't scatter fetch() calls across 50 components. Create a centralized API layer â like having a single front desk that handles all communication with the outside world.
The API Client
const BASE_URL = "/api";
async function apiClient(endpoint, options = {}) {
const config = {
headers: {
"Content-Type": "application/json",
...options.headers,
},
...options,
};
const token = localStorage.getItem("authToken");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${BASE_URL}${endpoint}`, config);
if (response.status === 401) {
localStorage.removeItem("authToken");
window.location.href = "/login";
throw new Error("Session expired");
}
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `Request failed: ${response.status}`);
}
return response.json();
}Feature-Specific API Modules
// features/todos/api/todoApi.ts
export const todoApi = {
getAll: () => apiClient("/todos"),
getById: (id) => apiClient(`/todos/${id}`),
create: (todo) =>
apiClient("/todos", {
method: "POST",
body: JSON.stringify(todo),
}),
update: (id, updates) =>
apiClient(`/todos/${id}`, {
method: "PATCH",
body: JSON.stringify(updates),
}),
delete: (id) =>
apiClient(`/todos/${id}`, {
method: "DELETE",
}),
};
// Usage in a component or hook
function useTodos() {
const [todos, setTodos] = useState([]);
useEffect(() => {
todoApi.getAll().then(setTodos);
}, []);
const addTodo = async (text) => {
const newTodo = await todoApi.create({ text });
setTodos((prev) => [...prev, newTodo]);
};
return { todos, addTodo };
}Why Centralize the API Layer?
- Auth handling in one place â token injection, 401 redirects, refresh logic
- Error handling in one place â consistent error formatting and logging
- Easy to mock for testing â swap
apiClientwith a mock, all features benefit - Single point for changes â switch from REST to GraphQL? Only change the API layer
Error Boundary Strategy
Errors happen. Networks fail, APIs return unexpected data, and code has bugs. A good error strategy catches these gracefully instead of crashing the whole app.
Think of it like fire doors in a building â when a fire breaks out in one room, the fire doors prevent it from spreading to the whole building.
The Error Boundary Component
import { Component } from "react";
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error("Error caught by boundary:", error, errorInfo);
// Send to error tracking service (Sentry, etc.)
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div>
<h2>Something went wrong</h2>
<button onClick={() => this.setState({ hasError: false, error: null })}>
Try Again
</button>
</div>
);
}
return this.props.children;
}
}Strategic Placement
Place error boundaries at different levels for different recovery strategies:
function App() {
return (
<ErrorBoundary fallback={<FullPageError />}>
{/* App-level: catches catastrophic errors */}
<Header />
<ErrorBoundary fallback={<SidebarFallback />}>
{/* Section-level: sidebar crashes don't break the main content */}
<Sidebar />
</ErrorBoundary>
<main>
<ErrorBoundary fallback={<WidgetError />}>
{/* Widget-level: one widget crashing doesn't affect others */}
<DashboardWidget title="Revenue" />
</ErrorBoundary>
<ErrorBoundary fallback={<WidgetError />}>
<DashboardWidget title="Users" />
</ErrorBoundary>
</main>
</ErrorBoundary>
);
}Error Boundary Placement Guide
| Level | What It Protects | Fallback UI |
|---|---|---|
| App root | Entire app from crashing completely | "Something went wrong" full page |
| Route/page | Other pages from one page's errors | "This page had a problem" with navigation intact |
| Section | Main content from sidebar/header errors | Simplified fallback for that section |
| Widget | Other widgets from one widget's error | "Couldn't load this widget" with retry button |
Common Mistake: Wrapping the entire app in one giant error boundary. This means ANY error shows the same "something went wrong" screen. Use multiple boundaries so errors are contained to the smallest possible area.
Putting It All Together: Architecture Checklist
When designing a frontend system, walk through this checklist:
FOLDER STRUCTURE
â Organized by feature, not file type
â Shared code in a separate directory
â Each feature has a clear public API (index.ts)
COMPONENTS
â Smart components handle logic
â Dumb components handle presentation
â Components are small and focused
STATE MANAGEMENT
â Server state in React Query / SWR
â Global UI state in Zustand / Context
â Local state in useState
â URL state in router params
API LAYER
â Centralized API client with auth handling
â Feature-specific API modules
â Consistent error handling
ERROR HANDLING
â Error boundaries at multiple levels
â Graceful degradation (partial failures don't crash the whole app)
â Retry mechanisms for transient failures
PERFORMANCE
â Skeleton loading instead of spinners
â Cursor-based pagination for infinite scroll
â Caching to avoid redundant fetchesInterview Corner
How would you design an infinite scroll feature? Use cursor-based pagination to avoid duplicate/missing items, IntersectionObserver on a sentinel element to detect when to load more, skeleton loaders for perceived performance, error handling with retry, and a cache so users don't lose their scroll position when navigating away.
How do you structure a large React application? Organize by feature, not file type. Each feature folder contains its own components, hooks, API calls, and types. Shared utilities go in a separate directory. Features communicate through well-defined public APIs (index.ts exports).
What are smart and dumb components? Smart (container) components handle data fetching, state management, and logic. Dumb (presentational) components receive props and render UI. This separation makes components reusable, testable, and easier to maintain.
How do you handle errors in a large React app? Use Error Boundaries at multiple levels â app root, route/page, section, and widget level. Each boundary has an appropriate fallback UI. Combine with try/catch in async operations and centralized error logging.
How do you manage state in a large app? Layer state by type: server state in React Query, shared UI state in Zustand or Context, local state in useState, and URL-driven state in router params. Each type has different needs and the right tool handles them best.