Who is this for? Frontend developers who want to go from writing code that works → writing code that scales. This covers everything from folder structure to accessibility to hooks — with real code, comparisons, and interview-ready answers.
Table of Contents
- Scalable Architecture — What & Why
- Project Mind Map — How to Think Before You Code
- Production-Level Folder Structure
- API Layer Separation
- Atomic Design System
- State Management Patterns
- Colocation Pattern
- Accessibility (WCAG) — Deep Dive
- Module 1: Advanced React Fundamentals
- Full Architecture Summary + Interview Answers
1. Scalable Architecture — What & Why
What does "Scalable" actually mean?
Scalable Architecture means: a system that does NOT slow down, break, or become impossible to maintain when users grow, features are added, or team size increases.
In React, it means your app's structure is designed so that:
- Adding a new feature doesn't touch 10 unrelated files
- A team of 10 can work without stepping on each other
- Performance stays good even at 100k users
- A new developer can understand the codebase in hours, not weeks
The Problem with "Normal" React Apps
Most beginners write React like this:
/components
Header.jsx
TodoList.jsx
UserCard.jsx
Modal.jsx
/pages
Home.jsx
Dashboard.jsx
/utils
helpers.js
This looks organized. But as the app grows:
-
TodoList.jsxmakes direct API calls - State lives in random parent components
- Business logic is scattered everywhere
- You can't add a feature without breaking another
Result: The codebase becomes what engineers call "spaghetti code" — tangled, slow, and scary to touch.
Why Does Scalability Matter?
| Without Scalable Architecture | With Scalable Architecture |
|---|---|
| Adding a feature takes days | Adding a feature takes hours |
| One developer's change breaks another's | Teams work independently |
| Performance degrades with more features | Performance stays consistent |
| Onboarding new devs takes weeks | Onboarding takes days |
| Testing is nearly impossible | Every piece can be tested in isolation |
💡 Key Insight: Scalable architecture is not about using fancy tools. It's about making the right decisions early — folder structure, state ownership, API separation — so you don't pay the price later.
2. Project Mind Map — How to Think Before You Code
The biggest mistake developers make: They start writing code before thinking about architecture. Senior engineers think first, then code.
Here is the exact mental framework to use every time you start a new project:
Step 0 — Ask These 5 Questions First
Before writing a single line of code, answer:
- How many users? (100 vs 1 million changes everything)
- What type of app? (Dashboard / Real-time / Data-heavy / Simple CRUD)
- Team size? (Solo vs 10+ developers)
- Will features be added later? (Almost always yes)
- What are the critical flows? (Auth, Payment, Checkout — these need special care)
Your answers determine how complex your architecture should be. Over-engineering a small app is as bad as under-engineering a large one.
The Master Mind Map
Project Start
↓
┌──────── Requirements Analysis ─────────┐
↓ ↓
UI Complexity Data Complexity
↓ ↓
Component Design State Management
↓ ↓
Folder Structure API Layer Design
↓ ↓
Performance Plan Testing Strategy
↓ ↓
└────── Deployment & Monitoring ──────────┘
(CI/CD + Error Logs)
Step 1 — Feature Breakdown (Most Important)
Don't think in pages. Think in features.
❌ Page-based thinking:
/home
/dashboard
/profile
✅ Feature-based thinking:
/auth (Login, Register, Token management)
/dashboard (Stats, Charts, Summary)
/profile (View, Edit, Avatar upload)
/todo (CRUD, Filters, Archive)
Why? Because a "feature" contains all its related code — UI, logic, API calls, state — in one place. A "page" is just a container. Features are independent units.
Step 2 — State Strategy Planning
Think about your data before writing components:
| Data Type | Where It Lives | Tool |
|---|---|---|
| Form input value | Inside the form component | useState |
| Modal open/close | Inside modal component | useState |
| User authentication | App-wide global | Redux Toolkit |
| Theme (dark/light) | App-wide shared | Context API |
| API data (todos, users) | Server cache | React Query |
| Derived values (counts, filters) | Calculated on the fly | No state needed |
🔑 Golden Rule: Global state is your LAST option, not your first.
3. Production-Level Folder Structure
This is the folder structure used at companies like Airbnb, Meta, and large startups. Copy this as your base for any real project.
/src
/app
store.js ← Redux store configuration
routes.jsx ← All app routes defined here
/features ← Each feature is a self-contained module
/auth
/components ← UI components specific to auth
LoginForm.jsx
RegisterForm.jsx
/pages
LoginPage.jsx
RegisterPage.jsx
authAPI.js ← API calls for auth
authSlice.js ← Redux state for auth
index.js ← Public exports (barrel file)
/todo
/components
TodoItem.jsx
TodoList.jsx
TodoFilter.jsx
/pages
TodoPage.jsx
todoAPI.js
todoSlice.js
index.js
/dashboard
/components
StatsCard.jsx
ActivityChart.jsx
/pages
DashboardPage.jsx
dashboardAPI.js
/shared ← Code used across multiple features
/components ← Reusable UI (Button, Input, Modal)
/atoms
Button.jsx
Input.jsx
Badge.jsx
/molecules
SearchBox.jsx
FormField.jsx
/organisms
Navbar.jsx
Sidebar.jsx
/hooks ← Custom hooks used everywhere
useDebounce.js
useLocalStorage.js
useMediaQuery.js
/utils ← Pure helper functions
formatDate.js
validators.js
constants.js
/services ← API client configuration
apiClient.js ← Axios instance with interceptors
/config ← App-wide configuration
env.js ← Environment variable management
/styles ← Global styles, themes, variables
globals.css
variables.css
Why This Structure Works
Feature folder = Independent unit
↓
You can delete /auth and the rest of the app still works
↓
You can add /payments without touching anything else
↓
A developer can own /todo completely without conflicts
Store Setup (Redux Toolkit)
// src/app/store.js
import { configureStore } from "@reduxjs/toolkit";
import authReducer from "../features/auth/authSlice";
import todoReducer from "../features/todo/todoSlice";
export const store = configureStore({
reducer: {
auth: authReducer,
todo: todoReducer,
},
// Redux DevTools enabled in dev automatically
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// src/features/auth/authSlice.js
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import { loginUser } from "./authAPI";
// Async thunk for login
export const login = createAsyncThunk(
"auth/login",
async (credentials, thunkAPI) => {
try {
const response = await loginUser(credentials);
return response.data;
} catch (error) {
return thunkAPI.rejectWithValue(error.response.data);
}
}
);
const authSlice = createSlice({
name: "auth",
initialState: {
user: null,
token: null,
loading: false,
error: null,
},
reducers: {
logout: (state) => {
state.user = null;
state.token = null;
},
setUser: (state, action) => {
state.user = action.payload;
},
},
extraReducers: (builder) => {
builder
.addCase(login.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(login.fulfilled, (state, action) => {
state.loading = false;
state.user = action.payload.user;
state.token = action.payload.token;
})
.addCase(login.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
});
},
});
export const { logout, setUser } = authSlice.actions;
export default authSlice.reducer;
4. API Layer Separation
This is one of the most important architectural decisions in a React app. Never call APIs directly from components.
The Problem with Direct API Calls
// ❌ WRONG — API call directly in component
const TodoList = () => {
const [todos, setTodos] = useState([]);
useEffect(() => {
fetch("https://api.example.com/todos") // ← tightly coupled
.then(res => res.json())
.then(data => setTodos(data));
}, []);
};
Problems:
- If the API URL changes, you search through every component
- You can't test the component without mocking fetch globally
- Error handling is repeated in every component
- Auth tokens must be added everywhere manually
The Correct Pattern
Component → Custom Hook / React Query → Service Function → Axios Client → API
Layer 1 — Axios Client (Base Config)
// src/services/apiClient.js
import axios from "axios";
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
});
// Request interceptor — adds auth token to every request automatically
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor — handles 401 globally
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Redirect to login, clear token
localStorage.removeItem("token");
window.location.href = "/login";
}
return Promise.reject(error);
}
);
export default apiClient;
Layer 2 — Service Functions (API operations)
// src/features/todo/todoAPI.js
import apiClient from "../../services/apiClient";
export const getTodos = (filters = {}) =>
apiClient.get("/todos", { params: filters });
export const getTodoById = (id) =>
apiClient.get(`/todos/${id}`);
export const createTodo = (data) =>
apiClient.post("/todos", data);
export const updateTodo = (id, data) =>
apiClient.put(`/todos/${id}`, data);
export const deleteTodo = (id) =>
apiClient.delete(`/todos/${id}`);
Layer 3 — React Query (Data fetching + caching)
// src/features/todo/hooks/useTodos.js
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getTodos, createTodo, deleteTodo } from "../todoAPI";
export const useTodos = (filters) => {
return useQuery({
queryKey: ["todos", filters], // cache key — re-fetches when filters change
queryFn: () => getTodos(filters),
staleTime: 5 * 60 * 1000, // data considered fresh for 5 minutes
cacheTime: 10 * 60 * 1000, // cache kept for 10 minutes
});
};
export const useCreateTodo = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createTodo,
onSuccess: () => {
// Invalidate todos cache — triggers automatic re-fetch
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
};
// Layer 4 — Component (clean and simple)
const TodoList = () => {
const { data: todos, isLoading, error } = useTodos();
const { mutate: addTodo } = useCreateTodo();
if (isLoading) return <Loader />;
if (error) return <ErrorMessage message={error.message} />;
return (
<ul>
{todos.map(todo => <TodoItem key={todo.id} todo={todo} />)}
</ul>
);
};
Why This Layered Approach?
| Benefit | What It Means |
|---|---|
| Loose coupling | Backend URL change → only update apiClient.js |
| Easy testing | Mock service functions, not fetch/axios globally |
| Automatic caching | React Query caches, deduplicates, and refetches |
| Centralized auth | Token added/removed in one place |
| Reusability | Same service function used in multiple hooks/components |
5. Atomic Design System
Atomic Design is a methodology for building UI components that are consistent, reusable, and scalable. Inspired by chemistry — you build from the smallest pieces upward.
Atoms → Molecules → Organisms → Templates → Pages
Level 1 — Atoms (The smallest possible UI piece)
An atom has no business logic. It is purely visual and highly reusable.
// src/shared/components/atoms/Button.jsx
export const Button = ({
children,
variant = "primary", // "primary" | "secondary" | "danger"
size = "md", // "sm" | "md" | "lg"
disabled = false,
isLoading = false,
onClick,
...props
}) => {
return (
<button
className={`btn btn--${variant} btn--${size}`}
disabled={disabled || isLoading}
onClick={onClick}
{...props}
>
{isLoading ? <Spinner size="sm" /> : children}
</button>
);
};
// src/shared/components/atoms/Input.jsx
export const Input = ({
label,
id,
error,
helperText,
...props
}) => {
return (
<div className="input-wrapper">
{label && <label htmlFor={id}>{label}</label>}
<input id={id} {...props} />
{error && <span role="alert" className="input-error">{error}</span>}
{helperText && <span className="helper-text">{helperText}</span>}
</div>
);
};
Rule for atoms: If you find yourself adding an API call or business condition, it's no longer an atom. Extract that logic out.
Level 2 — Molecules (Atoms working together)
A molecule combines 2–3 atoms to create a small, focused UI piece with light logic.
// src/shared/components/molecules/SearchBox.jsx
import { Input } from "../atoms/Input";
import { Button } from "../atoms/Button";
export const SearchBox = ({ onSearch, placeholder = "Search..." }) => {
const [value, setValue] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
if (value.trim()) onSearch(value);
};
return (
<form onSubmit={handleSubmit} className="search-box">
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
aria-label="Search"
/>
<Button type="submit" variant="primary">Search</Button>
</form>
);
};
// src/shared/components/molecules/FormField.jsx
// Combines label + input + error into one reusable form field
export const FormField = ({ label, name, error, ...inputProps }) => (
<div className="form-field">
<label htmlFor={name}>{label}</label>
<Input id={name} name={name} error={error} {...inputProps} />
</div>
);
Level 3 — Organisms (Complex, feature-aware sections)
Organisms can contain business logic and are feature-specific.
// src/shared/components/organisms/Navbar.jsx
import { Logo } from "../atoms/Logo";
import { SearchBox } from "../molecules/SearchBox";
import { UserMenu } from "../molecules/UserMenu";
import { useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
export const Navbar = () => {
const user = useSelector(state => state.auth.user);
const navigate = useNavigate();
const handleSearch = (query) => {
navigate(`/search?q=${query}`);
};
return (
<header className="navbar" role="banner">
<Logo />
<SearchBox onSearch={handleSearch} placeholder="Search todos..." />
{user && <UserMenu user={user} />}
</header>
);
};
Level 4 — Templates (Layout skeleton)
Templates define page layout without real data. They are pure structure.
// src/shared/components/templates/DashboardLayout.jsx
import { Navbar } from "../organisms/Navbar";
import { Sidebar } from "../organisms/Sidebar";
export const DashboardLayout = ({ children }) => {
return (
<div className="dashboard-layout">
<Navbar />
<div className="dashboard-body">
<Sidebar />
<main className="dashboard-content" role="main">
{children}
</main>
</div>
</div>
);
};
Level 5 — Pages (Real data + business logic)
Pages connect everything — they fetch data, handle routing, and render templates.
// src/features/dashboard/pages/DashboardPage.jsx
import { DashboardLayout } from "../../../shared/components/templates/DashboardLayout";
import { StatsCard } from "../components/StatsCard";
import { ActivityChart } from "../components/ActivityChart";
import { useDashboardStats } from "../hooks/useDashboardStats";
const DashboardPage = () => {
const { data: stats, isLoading } = useDashboardStats();
return (
<DashboardLayout>
<h1>Dashboard</h1>
<div className="stats-grid">
<StatsCard title="Total Todos" value={stats?.total} isLoading={isLoading} />
<StatsCard title="Completed" value={stats?.completed} isLoading={isLoading} />
<StatsCard title="Pending" value={stats?.pending} isLoading={isLoading} />
</div>
<ActivityChart data={stats?.activity} />
</DashboardLayout>
);
};
export default DashboardPage;
Atomic Design vs Feature Architecture — How They Work Together
These are NOT competing approaches. Use both together:
/src
/features
/todo
/components ← Organisms specific to todo feature
TodoList.jsx
TodoItem.jsx
hooks/
todoAPI.js
/shared
/components
/atoms ← Used everywhere
Button.jsx
Input.jsx
/molecules ← Used across features
SearchBox.jsx
/organisms ← Shared organisms (Navbar, Sidebar)
Navbar.jsx
| Atomic Design | Feature Architecture |
|---|---|
| Organizes UI components | Organizes business features |
| Ensures reusability | Ensures scalability |
| Answers "how is this built?" | Answers "where does this belong?" |
Used in /shared
|
Used in /features
|
6. State Management Patterns
State management is where most React apps go wrong. Here are all the patterns, when to use each, and the differences between them.
Pattern 1 — Local State (useState)
Use for: UI-only state that no other component needs.
// ✅ Perfect use case for local state
const Modal = () => {
const [isOpen, setIsOpen] = useState(false); // Only Modal needs this
return (
<>
<button onClick={() => setIsOpen(true)}>Open</button>
{isOpen && (
<div role="dialog" aria-modal="true">
<button onClick={() => setIsOpen(false)}>Close</button>
<p>Modal content</p>
</div>
)}
</>
);
};
When NOT to use: When multiple unrelated components need the same data.
Pattern 2 — Lifting State Up
Use for: When a parent needs to coordinate between two sibling components.
// Parent owns state, passes it down
const TodoApp = () => {
const [filter, setFilter] = useState("all"); // Both TodoList and FilterBar need this
return (
<>
<FilterBar currentFilter={filter} onFilterChange={setFilter} />
<TodoList filter={filter} />
</>
);
};
Problem it creates: When you lift state too many levels, you get prop drilling — passing data through 5 components that don't use it, just to reach one that does.
Pattern 3 — Context API
Use for: Data that's truly "global" for a subtree — theme, language, auth.
// AuthContext.jsx
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [token, setToken] = useState(() => localStorage.getItem("token"));
const login = async (credentials) => {
const data = await loginUser(credentials);
setUser(data.user);
setToken(data.token);
localStorage.setItem("token", data.token);
};
const logout = () => {
setUser(null);
setToken(null);
localStorage.removeItem("token");
};
return (
<AuthContext.Provider value={{ user, token, login, logout }}>
{children}
</AuthContext.Provider>
);
};
// Custom hook for clean consumption
export const useAuth = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
};
Performance warning: Context re-renders ALL consumers when any value changes. Split contexts to avoid this:
// ❌ One context causes unnecessary re-renders
<AppContext.Provider value={{ user, theme, language, todos }}>
// ✅ Split contexts — each updates independently
<AuthContext.Provider value={{ user }}>
<ThemeContext.Provider value={{ theme }}>
<LanguageContext.Provider value={{ language }}>
Pattern 4 — Redux Toolkit (Global State)
Use for: Complex global state — user data, shopping cart, app-wide business logic.
// Using Redux state in a component
const UserProfile = () => {
const user = useSelector(state => state.auth.user); // read state
const dispatch = useDispatch();
const handleLogout = () => {
dispatch(logout()); // trigger action
};
return (
<div>
<h1>Hello, {user?.name}</h1>
<button onClick={handleLogout}>Logout</button>
</div>
);
};
When NOT to use Redux for:
- API data (use React Query instead)
- UI state like modals (use
useState) - Derived values like filtered lists (compute them)
Pattern 5 — React Query (Server State)
The most important pattern for 2026. API data is a completely different type of state — it needs caching, background refetching, deduplication, and loading/error states. React Query handles all of this automatically.
// Without React Query — you write all this yourself:
const [todos, setTodos] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setIsLoading(true);
getTodos()
.then(data => setTodos(data))
.catch(err => setError(err))
.finally(() => setIsLoading(false));
}, []);
// With React Query — 3 lines replace 15:
const { data: todos, isLoading, error } = useQuery({
queryKey: ["todos"],
queryFn: getTodos,
});
React Query gives you for free:
- Automatic caching (same data requested twice = 1 API call)
- Background refetching when window regains focus
- Loading and error states
- Automatic retries on failure
- Optimistic updates
Pattern 6 — Derived State (Anti-Pattern to Avoid)
Never store something that can be calculated.
// ❌ BAD — storing derived state
const [todos, setTodos] = useState([]);
const [completedCount, setCompletedCount] = useState(0); // derived!
const [pendingTodos, setPendingTodos] = useState([]); // derived!
// You now have 3 state variables to keep in sync. Bugs guaranteed.
// ✅ GOOD — derive from single source of truth
const [todos, setTodos] = useState([]);
const completedCount = todos.filter(t => t.done).length; // computed
const pendingTodos = todos.filter(t => !t.done); // computed
// One state variable. Zero sync bugs.
Pattern 7 — Zustand (Lightweight Alternative to Redux)
Use for: Medium complexity apps where Redux feels like overkill.
// store/todoStore.js
import { create } from "zustand";
import { getTodos, addTodo } from "../features/todo/todoAPI";
export const useTodoStore = create((set, get) => ({
todos: [],
isLoading: false,
fetchTodos: async () => {
set({ isLoading: true });
const data = await getTodos();
set({ todos: data, isLoading: false });
},
addTodo: (todo) => set(state => ({
todos: [...state.todos, todo]
})),
removeTodo: (id) => set(state => ({
todos: state.todos.filter(t => t.id !== id)
})),
}));
// In component — no Provider needed
const TodoList = () => {
const { todos, fetchTodos } = useTodoStore();
useEffect(() => { fetchTodos(); }, []);
return <ul>{todos.map(t => <li key={t.id}>{t.title}</li>)}</ul>;
};
The Complete Decision Chart
"Where should this state live?"
↓
Is it only used in this component?
YES → useState (local)
NO ↓
Is it needed by nearby components?
YES → Lift up to parent
NO ↓
Is it API/server data?
YES → React Query
NO ↓
Is it simple and app-wide (theme, language)?
YES → Context API
NO ↓
Is it complex business logic (cart, auth)?
YES → Redux Toolkit or Zustand
7. Colocation Pattern
What Is Colocation?
"Keep state as close as possible to where it is used."
If only one component needs a piece of state, it lives inside that component. You only move it up when multiple components genuinely need it.
Why It Matters
// ❌ OVER-LIFTED STATE — the classic beginner mistake
// App.jsx owns state that only Modal needs
const App = () => {
const [isModalOpen, setIsModalOpen] = useState(false); // used only in Modal!
return (
<>
<Navbar isModalOpen={isModalOpen} /> {/* doesn't use it */}
<Dashboard isModalOpen={isModalOpen} /> {/* doesn't use it */}
<Sidebar isModalOpen={isModalOpen} /> {/* doesn't use it */}
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
/>
</>
);
};
// Every time modal state changes, Navbar, Dashboard, Sidebar all re-render
// unnecessarily. Performance hit + messy code.
// ✅ COLOCATED STATE — clean and performant
const Modal = () => {
const [isOpen, setIsOpen] = useState(false); // lives here, used here
return (
<>
<button onClick={() => setIsOpen(true)}>Open</button>
{isOpen && (
<div role="dialog">
<button onClick={() => setIsOpen(false)}>Close</button>
<p>Content</p>
</div>
)}
</>
);
};
// Only Modal re-renders when modal state changes. Zero prop drilling.
The Colocation Rules
Rule 1: Start state inside the component that uses it.
Rule 2: Only lift state up when ANOTHER component needs it.
Rule 3: Only make state global when MANY components across the app need it.
Rule 4: Global state is the last resort, not the starting point.
Real Example — Todo App State Ownership
State: todoInput text → Inside TodoInput component (colocated)
State: filter ("all/done") → Inside TodoList (only TodoList uses it)
State: todos array → Lifted (TodoList + Dashboard both need it) → React Query
State: user authentication → Global (entire app needs it) → Redux
State: app theme → Global (entire app) → Context
8. Accessibility (WCAG) — Deep Dive
What Is Accessibility and Why Does It Matter?
Accessibility (a11y) means building apps that everyone can use — including people who are blind, have low vision, use only a keyboard, or have cognitive disabilities.
Who uses screen readers and accessibility tools:
- Blind users (cannot see the screen at all)
- Low vision users (need screen magnification + audio)
- Motor disability users (keyboard-only navigation, no mouse)
- Elderly users
- Temporary disabilities (broken arm, eye surgery recovery)
Why you must care:
- 1 in 4 adults in the US has a disability
- Many governments legally require accessible websites
- Accessible sites rank better on Google (SEO benefit)
- Better accessibility = better user experience for everyone
How Browsers Handle Accessibility Internally
When a browser parses your HTML, it builds three trees:
1. DOM Tree → The actual HTML structure
2. CSSOM Tree → Styles computed for each element
3. Accessibility Tree → What screen readers see (THIS is what matters)
The Accessibility Tree is a simplified, semantic version of the DOM. Screen reader software (NVDA, JAWS, VoiceOver) reads this tree, not the visual screen.
<!-- HTML you write -->
<button class="btn btn-primary">Submit Form</button>
<!-- Accessibility Tree entry -->
Role: button
Name: "Submit Form"
State: enabled, focusable
Without ARIA vs With ARIA — The Real Difference
Case 1 — Semantic HTML (No ARIA needed)
<!-- Browser automatically knows: Role=button, Name="Submit" -->
<button>Submit</button>
Screen reader output: 🗣️ "Submit, button"
User can Tab to it, press Enter to activate it. Perfect. No ARIA needed.
Case 2 — Div used as button (BROKEN)
<!-- ❌ Browser sees: Role=generic, Name="Submit" -->
<div onclick="submit()">Submit</div>
Screen reader output: 🗣️ "Submit" (no role announced)
Problems:
- User doesn't know it's clickable
- Tab key does NOT focus it
- Enter key does NOT activate it
- Completely broken for keyboard users
Case 3 — Div with ARIA (Fixed, but not ideal)
<!-- ✅ ARIA forces accessibility tree to show: Role=button, Name="Submit" -->
<div
role="button"
tabindex="0"
onclick="submit()"
onkeydown="if(event.key === 'Enter') submit()"
>
Submit
</div>
Screen reader output: 🗣️ "Submit, button"
This works, but you had to manually add tabindex, keyboard handler, and role. A native <button> gives all of this for free.
🔑 Golden Rule: Use semantic HTML first. Use ARIA only when semantic HTML is not enough.
WCAG — The 4 Principles (POUR)
1. Perceivable — Information must be visible or audible
// ✅ Alt text for images
<img src="profile.jpg" alt="Sarah's profile picture" />
// ✅ Decorative image — empty alt tells screen reader to skip it
<img src="decorative-line.png" alt="" />
// ✅ Icon buttons must have a label
<button aria-label="Delete todo item">
<TrashIcon aria-hidden="true" />
</button>
2. Operable — Everything must work with a keyboard
// ✅ Always use button for interactive elements (free keyboard support)
<button onClick={handleDelete}>Delete</button>
// ✅ Ensure focus is always visible
// In your CSS:
// :focus-visible { outline: 2px solid #005fcc; outline-offset: 2px; }
// ❌ NEVER DO THIS — hides focus ring without providing alternative
// button:focus { outline: none; }
3. Understandable — UI must be clear and predictable
// ✅ Form labels connected to inputs
<div>
<label htmlFor="email">Email Address</label>
<input
id="email"
type="email"
aria-describedby="email-error"
aria-invalid={!!emailError}
/>
{emailError && (
<span id="email-error" role="alert">
{emailError}
</span>
)}
</div>
4. Robust — Works with assistive technologies
// ✅ Use semantic HTML everywhere
<header> {/* not <div class="header"> */}
<nav> {/* not <div class="nav"> */}
<main> {/* not <div class="main"> */}
<article> {/* not <div class="article"> */}
<footer> {/* not <div class="footer"> */}
Common ARIA Attributes Explained
// aria-label — provides an accessible name when visible text is absent
<button aria-label="Close dialog">✕</button>
// aria-labelledby — references another element as the label
<h2 id="modal-title">Confirm Delete</h2>
<dialog aria-labelledby="modal-title">...</dialog>
// aria-describedby — references element providing extra description
<input aria-describedby="password-hint" type="password" />
<span id="password-hint">Must be at least 8 characters</span>
// aria-expanded — state of expandable elements
<button aria-expanded={isOpen} onClick={toggle}>Menu</button>
// aria-hidden — hide decorative elements from screen readers
<span aria-hidden="true">→</span>
// role — override or assign semantic role
<div role="dialog" aria-modal="true">...</div>
<ul role="listbox">...</ul>
// aria-live — announce dynamic content changes
<div aria-live="polite">
{statusMessage} {/* Screen reader announces changes automatically */}
</div>
Accessible React Components — Practical Examples
Accessible Modal with focus trap:
const Modal = ({ isOpen, onClose, title, children }) => {
const modalRef = useRef(null);
const previousFocusRef = useRef(null);
useEffect(() => {
if (isOpen) {
// Save current focus position
previousFocusRef.current = document.activeElement;
// Move focus into modal
modalRef.current?.focus();
} else {
// Restore focus when modal closes
previousFocusRef.current?.focus();
}
}, [isOpen]);
// Close on Escape key
const handleKeyDown = (e) => {
if (e.key === "Escape") onClose();
};
if (!isOpen) return null;
return (
// Backdrop
<div
className="modal-backdrop"
onClick={onClose}
aria-hidden="true"
>
{/* Modal panel — stops click propagation */}
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
className="modal-panel"
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
>
<h2 id="modal-title">{title}</h2>
<div>{children}</div>
<button onClick={onClose} aria-label="Close dialog">
Close
</button>
</div>
</div>
);
};
Accessible Dropdown:
const Dropdown = ({ label, options, onSelect }) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const handleKeyDown = (e) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex(i => Math.min(i + 1, options.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0));
break;
case "Enter":
if (selectedIndex >= 0) {
onSelect(options[selectedIndex]);
setIsOpen(false);
}
break;
case "Escape":
setIsOpen(false);
break;
}
};
return (
<div className="dropdown">
<button
aria-haspopup="listbox"
aria-expanded={isOpen}
onClick={() => setIsOpen(o => !o)}
>
{label}
</button>
{isOpen && (
<ul
role="listbox"
aria-label={label}
onKeyDown={handleKeyDown}
>
{options.map((option, index) => (
<li
key={option.value}
role="option"
aria-selected={index === selectedIndex}
onClick={() => { onSelect(option); setIsOpen(false); }}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
};
9. Module 1: Advanced React Fundamentals
1.1 Component Design Principles
Single Responsibility Principle (SRP)
Every component should do one thing only. When a component does too many things, it becomes hard to test, reuse, and understand.
// ❌ GOD COMPONENT — does everything
const UserDashboard = () => {
const [user, setUser] = useState(null);
const [todos, setTodos] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [filter, setFilter] = useState("all");
const [newTodo, setNewTodo] = useState("");
const [isModalOpen, setIsModalOpen] = useState(false);
useEffect(() => { /* fetch user */ }, []);
useEffect(() => { /* fetch todos */ }, []);
const handleAddTodo = async () => { /* ... */ };
const handleDelete = async (id) => { /* ... */ };
const handleFilter = () => { /* ... */ };
return (
<div>
{/* 300+ lines of JSX mixing concerns */}
</div>
);
};
// ✅ SRP APPLIED — each component has one job
// UserDashboard orchestrates, others do the actual work
const UserDashboard = () => {
const { data: user } = useCurrentUser();
return (
<DashboardLayout>
<UserHeader user={user} />
<TodoSection />
</DashboardLayout>
);
};
const UserHeader = ({ user }) => (
<header>
<h1>Welcome, {user?.name}</h1>
<UserAvatar src={user?.avatar} alt={`${user?.name}'s avatar`} />
</header>
);
const TodoSection = () => {
const [filter, setFilter] = useState("all");
return (
<>
<TodoInput />
<TodoFilter currentFilter={filter} onFilterChange={setFilter} />
<TodoList filter={filter} />
</>
);
};
Smart vs Presentational Components
This is a fundamental pattern that separates data/logic from visual rendering.
| Smart (Container) | Presentational (View) |
|---|---|
| Knows about Redux/API | No knowledge of data source |
| Passes data down as props | Receives everything via props |
| Has side effects | Pure — same props = same output |
| Hard to reuse | Highly reusable |
| Easy to test logic | Easy to test visually (Storybook) |
// ✅ SMART COMPONENT — knows about data, passes it down
const TodoListContainer = () => {
const { data: todos, isLoading } = useTodos();
const { mutate: deleteTodo } = useDeleteTodo();
const [filter, setFilter] = useState("all");
const filteredTodos = useMemo(
() => todos?.filter(t => filter === "all" || (filter === "done") === t.done),
[todos, filter]
);
return (
<TodoListView
todos={filteredTodos}
isLoading={isLoading}
filter={filter}
onFilterChange={setFilter}
onDelete={deleteTodo}
/>
);
};
// ✅ PRESENTATIONAL COMPONENT — pure rendering, no business logic
const TodoListView = ({ todos, isLoading, filter, onFilterChange, onDelete }) => {
if (isLoading) return <LoadingSpinner />;
if (!todos?.length) return <EmptyState message="No todos yet" />;
return (
<section aria-label="Todo list">
<TodoFilterBar currentFilter={filter} onChange={onFilterChange} />
<ul>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onDelete={() => onDelete(todo.id)}
/>
))}
</ul>
</section>
);
};
When to Extract Components
Extract when:
- JSX is more than ~50 lines
- The same UI appears in 2+ places
- A section has its own distinct responsibility
- A section has its own state that nothing else needs
- Testing a smaller piece would be valuable
Don't extract when:
- It would require passing many props to a component used only once
- The "component" has no reuse value
- Extraction makes the code harder to follow (over-engineering)
Composition Patterns
Children prop — The most underused pattern:
// ✅ Flexible container using children
const Card = ({ title, children, footer }) => (
<div className="card">
<div className="card-header"><h3>{title}</h3></div>
<div className="card-body">{children}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
);
// Usage — Card doesn't need to know what's inside it
<Card
title="User Profile"
footer={<button>Edit</button>}
>
<UserAvatar src={user.avatar} />
<p>{user.bio}</p>
</Card>
Render Props — For sharing logic with custom rendering:
// ✅ DataFetcher renders nothing itself — caller decides the UI
const DataFetcher = ({ url, render }) => {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetch(url).then(r => r.json()).then(d => {
setData(d);
setIsLoading(false);
});
}, [url]);
return render({ data, isLoading });
};
// Usage
<DataFetcher
url="/api/user"
render={({ data: user, isLoading }) =>
isLoading ? <Spinner /> : <UserCard user={user} />
}
/>
1.2 State Management Mastery
Controlled vs Uncontrolled Inputs
// CONTROLLED — React owns the value
const ControlledInput = () => {
const [value, setValue] = useState("");
return (
<input
value={value} // React controls the value
onChange={(e) => setValue(e.target.value)}
/>
);
};
// When to use: Most forms. You can validate on every keystroke,
// transform input, and React state always reflects the UI value.
// UNCONTROLLED — DOM owns the value (accessed via ref)
const UncontrolledInput = () => {
const inputRef = useRef(null);
const handleSubmit = () => {
console.log(inputRef.current.value); // read value only on submit
};
return (
<>
<input ref={inputRef} defaultValue="" />
<button onClick={handleSubmit}>Submit</button>
</>
);
};
// When to use: File inputs (must be uncontrolled), integration with
// non-React libraries, or when you truly only need value on submit.
State Normalization — Avoiding Nested Mutation Bugs
// ❌ NESTED STATE — mutation bugs waiting to happen
const [todos, setTodos] = useState([
{
id: 1,
title: "Buy groceries",
comments: [
{ id: 1, text: "Get milk" },
{ id: 2, text: "Get bread" },
]
}
]);
// Updating a comment requires deep cloning — easy to get wrong
const updateComment = (todoId, commentId, newText) => {
setTodos(todos.map(todo =>
todo.id === todoId
? {
...todo,
comments: todo.comments.map(c =>
c.id === commentId ? { ...c, text: newText } : c
)
}
: todo
));
};
// ✅ NORMALIZED STATE — flat structure, easy updates
const [todosById, setTodosById] = useState({
1: { id: 1, title: "Buy groceries", commentIds: [1, 2] }
});
const [commentsById, setCommentsById] = useState({
1: { id: 1, text: "Get milk" },
2: { id: 2, text: "Get bread" },
});
// Updating a comment is simple — no deep cloning
const updateComment = (commentId, newText) => {
setCommentsById(prev => ({
...prev,
[commentId]: { ...prev[commentId], text: newText }
}));
};
Handling Async State Consistently
Never scatter isLoading, error, and data across independent useState calls. Group them properly or use a reducer.
// ❌ SCATTERED ASYNC STATE — multiple useState can get out of sync
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
// Easy to have isLoading=true and data=something simultaneously
// ✅ OPTION 1 — useReducer for consistent state transitions
const asyncReducer = (state, action) => {
switch (action.type) {
case "LOADING": return { status: "loading", data: null, error: null };
case "SUCCESS": return { status: "success", data: action.payload, error: null };
case "ERROR": return { status: "error", data: null, error: action.error };
default: return state;
}
};
const [state, dispatch] = useReducer(asyncReducer, {
status: "idle",
data: null,
error: null
});
// ✅ OPTION 2 — Custom hook that encapsulates the pattern
const useAsync = (asyncFn, deps = []) => {
const [state, dispatch] = useReducer(asyncReducer, {
status: "idle", data: null, error: null
});
useEffect(() => {
dispatch({ type: "LOADING" });
asyncFn()
.then(data => dispatch({ type: "SUCCESS", payload: data }))
.catch(error => dispatch({ type: "ERROR", error }));
}, deps);
return state;
};
// Usage
const { status, data, error } = useAsync(() => getTodos());
1.3 Hooks at a Professional Level
useEffect — The Real Mental Model
useEffect is for synchronizing with external systems — not for running code after render.
External systems include: APIs, timers, WebSockets, browser APIs, third-party libraries.
// ✅ CORRECT use cases for useEffect
// 1. Syncing with external API
useEffect(() => {
const subscription = websocket.subscribe("/chat", setMessages);
return () => subscription.unsubscribe(); // cleanup when component unmounts
}, []);
// 2. Syncing document title with state
useEffect(() => {
document.title = `${unreadCount} unread messages`;
}, [unreadCount]);
// 3. Setting up a timer
useEffect(() => {
const interval = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(interval); // cleanup to prevent memory leak
}, []);
// ❌ WRONG — using useEffect for derived computation
useEffect(() => {
setFilteredTodos(todos.filter(t => t.done)); // NO! Compute during render
}, [todos]);
// ✅ CORRECT — derive during render (no useEffect needed)
const filteredTodos = todos.filter(t => t.done);
// ❌ WRONG — using useEffect to update parent
useEffect(() => {
onTodosChange(todos); // This causes re-render chains
}, [todos]);
// ✅ CORRECT — pass callback to parent, parent calls it when needed
const handleToggle = (id) => {
const updated = toggleTodo(id);
onTodosChange(updated);
};
Dependency Array Mastery — Avoiding Stale Closures
// ❌ STALE CLOSURE — count in callback is always 0
const Counter = () => {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
console.log(count); // Always logs 0 — stale closure!
setCount(count + 1); // Always sets to 1
}, 1000);
return () => clearInterval(interval);
}, []); // Empty deps — callback captures count=0 forever
};
// ✅ FIX 1 — Add count to dependency array
useEffect(() => {
const interval = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(interval);
}, [count]); // Re-creates interval when count changes
// ✅ FIX 2 (Better) — Use functional update form
useEffect(() => {
const interval = setInterval(() => {
setCount(prev => prev + 1); // Gets current value, no dependency needed
}, 1000);
return () => clearInterval(interval);
}, []); // Empty deps, no stale closure
useRef — The Right Uses
// Use 1 — Accessing DOM elements
const FocusInput = () => {
const inputRef = useRef(null);
const focusInput = () => {
inputRef.current?.focus();
};
return (
<>
<input ref={inputRef} type="text" />
<button onClick={focusInput}>Focus</button>
</>
);
};
// Use 2 — Storing mutable values WITHOUT causing re-renders
const Timer = () => {
const [time, setTime] = useState(0);
const intervalRef = useRef(null); // storing timer ID — we don't want re-render when this changes
const start = () => {
intervalRef.current = setInterval(() => setTime(t => t + 1), 1000);
};
const stop = () => {
clearInterval(intervalRef.current);
};
return (
<div>
<span>{time}s</span>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
};
// Use 3 — Previous value tracking (no extra state, no extra re-render)
const usePreviousValue = (value) => {
const prevRef = useRef(undefined);
useEffect(() => {
prevRef.current = value;
}); // No dependency array — runs after every render
return prevRef.current;
};
// Usage
const prev = usePreviousValue(count);
Custom Hooks — Design Principles
A custom hook extracts reusable logic. Good hooks have:
- A clear, verb-first name (
useXxx) - A single responsibility
- Predictable inputs and outputs
- Proper cleanup
// ✅ Well-designed custom hook — single responsibility, clean API
const useDebounce = (value, delay = 300) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer); // Clean up on value change
}, [value, delay]);
return debouncedValue;
};
// Usage
const [searchTerm, setSearchTerm] = useState("");
const debouncedSearch = useDebounce(searchTerm, 500);
useEffect(() => {
if (debouncedSearch) searchAPI(debouncedSearch);
}, [debouncedSearch]);
// ✅ Custom hook that encapsulates complex logic
const useLocalStorage = (key, initialValue) => {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = (value) => {
try {
const valueToStore = value instanceof Function
? value(storedValue)
: value;
setStoredValue(valueToStore);
localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue];
};
// Usage — works exactly like useState but persists across refreshes
const [theme, setTheme] = useLocalStorage("theme", "light");
Common Hook Mistakes
// ❌ MISTAKE 1 — Conditional hooks (NEVER do this — breaks Rules of Hooks)
const Component = ({ isLoggedIn }) => {
if (isLoggedIn) {
const [data, setData] = useState(null); // Hook inside condition!
}
};
// ✅ Fix — always call hooks unconditionally, use condition inside
const Component = ({ isLoggedIn }) => {
const [data, setData] = useState(null);
// Use isLoggedIn in logic, not around the hook
};
// ❌ MISTAKE 2 — Creating new objects/functions in render as dependencies
const Component = () => {
const config = { timeout: 5000 }; // New object every render!
useEffect(() => {
setup(config);
}, [config]); // This runs on EVERY render (config is always "new")
};
// ✅ Fix — move stable values outside component or use useMemo/useCallback
const config = { timeout: 5000 }; // Outside component — never changes
// ❌ MISTAKE 3 — Missing cleanup causing memory leaks
useEffect(() => {
let timer = setTimeout(() => setIsVisible(true), 2000);
// If component unmounts before 2s, timer still fires → state update on unmounted component
});
// ✅ Fix — always clean up
useEffect(() => {
let timer = setTimeout(() => setIsVisible(true), 2000);
return () => clearTimeout(timer); // cleanup
}, []);
10. Full Architecture Summary + Interview Answers
The Complete Mental Model
User visits the app
↓
React Router → determines which Page to render
↓
Page component → connects to data sources
- useQuery() for server data
- useSelector() for Redux global state
- useState() for local UI state
↓
Page renders Template (layout structure)
↓
Template renders Organisms (Navbar, Sidebar)
↓
Organisms render Molecules (SearchBox, FormField)
↓
Molecules render Atoms (Button, Input, Badge)
↓
User sees the UI
↓
User interacts → event handler triggered
↓
dispatch(action) OR mutate() OR setState()
↓
State updates → React re-renders minimal components
↓
UI updates
Quick Reference Decision Guide
FOLDER STRUCTURE
Feature code? → /features/{name}/
Shared UI? → /shared/components/{level}/
API calls? → /services/ + /features/{name}/api.js
STATE MANAGEMENT
Only one component? → useState (local)
Nearby components? → Lift up to parent
Server/API data? → React Query
Lightweight global? → Context API or Zustand
Complex business logic? → Redux Toolkit
COMPONENT TYPE
No logic, just render? → Presentational (atoms/molecules)
Has data-fetching? → Smart (container)
Complex reusable section? → Organism
Layout skeleton? → Template
Full page with data? → Page
ACCESSIBILITY
Interactive element? → Use native HTML button/a/input
Dynamic content? → aria-live
Icon-only button? → aria-label
Modal? → role="dialog" + focus management
Form field? → label + id + aria-describedby
Interview-Ready Answers
Q: What is scalable frontend architecture?
Scalable frontend architecture means designing a React app with feature-based folder structure, centralized but selective state management, abstracted API layers, lazy loading for performance, and proper testing — so the app can grow in users, features, and team size without becoming unmaintainable.
Q: When do you use Redux vs Context vs React Query?
Redux Toolkit for complex global business state (auth, cart). Context API for simple app-wide values (theme, language). React Query for all server/API data — it handles caching, loading states, and background refetching automatically. I avoid storing API data in Redux.
Q: What is Atomic Design and how do you apply it?
Atomic Design organizes UI into atoms (smallest pieces like Button), molecules (atoms combined like SearchBox), organisms (complex sections like Navbar), templates (page layouts), and pages (real data + business logic). I combine it with feature-based architecture — atoms and molecules live in
/shared/components, feature-specific organisms live inside their feature folder.
Q: What is the Colocation Pattern?
Colocation means keeping state as close as possible to where it's used. I start with state local to the component, only lift it to a parent when another component needs it, and only make it global when it's truly needed app-wide. This reduces unnecessary re-renders and keeps code easy to understand.
Q: What is accessibility and how do you implement it in React?
Accessibility ensures the app is usable by everyone, including those using screen readers, keyboard-only navigation, or assistive technologies. I use semantic HTML as the first approach, ARIA attributes when semantics aren't sufficient, always connect form labels to inputs, ensure keyboard focus is visible, manage focus in modals, and use tools like Lighthouse to audit my work.
Q: What is the correct mental model for useEffect?
useEffect is for synchronizing React with external systems — APIs, timers, WebSockets, browser APIs. It's not for running code after render or for computing derived values. I always provide cleanup functions for subscriptions and timers, and I use functional updates in setters to avoid stale closures when the dependency array would otherwise be complex.
Found this useful? Drop a ❤️ and share it. Questions or additions? Comments are open — I read every one.
Next in this series: Performance optimization (memoization, virtualization, bundle analysis) + Testing strategies (Jest + Cypress + Testing Library).
Top comments (0)