DNA⚛ïļ ReactFrontend System Design
ðŸĶ–DinosaurReactSystem DesignArchitectureInfinite ScrollScalability

Frontend System Design

Design infinite scroll, scalable folder structures, API layers — think like an architect, not just a coder.

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.tsx

The Rules

  1. Features are self-contained — everything for "auth" lives in the auth folder
  2. Shared code goes in shared/ — only code used by 2+ features
  3. Features don't import from other features — if auth needs something from todos, it belongs in shared/
  4. 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

BenefitHow
ReusabilityUserCard can be used anywhere — profile page, admin panel, search results
TestingDumb components are easy to test — just pass props, check output
ReadabilitySmart components read like a story: "fetch user, show card, handle editing"
MaintainabilityChange 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 apiClient with 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

LevelWhat It ProtectsFallback UI
App rootEntire app from crashing completely"Something went wrong" full page
Route/pageOther pages from one page's errors"This page had a problem" with navigation intact
SectionMain content from sidebar/header errorsSimplified fallback for that section
WidgetOther 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 fetches

Interview 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.