Platform-agnostic patterns for building React applications with functional components, hooks, and feature-based architecture.
Note: This guide assumes familiarity with JavaScript fundamentals. See javascript.md for async/await, array methods, fetch API, and other JavaScript patterns.
- Feature-based folder structure - Organize by feature/domain (Users, Products) not by type (components, containers)
- Co-locate related files - Keep component, styles, and service files together
- One component per file - Export as default, name file
index.jsx - Service layer for API calls - Isolate all fetch logic in
service.*.jsfiles
- Functional components only - No class components, use hooks for state/effects
- Single responsibility - Each component does one thing well
- Composition over inheritance - Build complex UIs from simple components
- Props for data flow - Data flows down, events flow up
- useState for component state - Local state in components
- localStorage for config - App-wide configuration, non-sensitive data
- sessionStorage for auth - Temporary session data, auth tokens
- No global state library - Keep it simple until you need Redux/Zustand
- useEffect for side effects - Data fetching, subscriptions, DOM manipulation
- Async/await in effects - Define async function inside useEffect, then call it
- Cleanup in effects - Return cleanup function for subscriptions/timers
- CSS Modules for scoped styles - Import styles as JavaScript object
- Inline styles for dynamic values - Use for one-off or computed styles
- Explicit conditionals - Use
{data && <Component />}not ternaries everywhere - Destructure props - Extract what you need:
function User({ name, age }) - Meaningful names -
isLoadingnotflag,handleSubmitnotsubmit - Try-catch for errors - Wrap async operations, display errors to users
- Native fetch API - No axios needed for simple requests
- React Router for navigation - Single
<Router>at app root - useParams for URL params - Access dynamic route segments
- useNavigate for redirects - Programmatic navigation after actions
JSX is JavaScript syntax extension that looks like HTML. It describes UI structure and compiles to JavaScript.
// JSX (what you write)
const element = <h1 className="title">Hello World</h1>;
// Compiles to
const element = React.createElement('h1', { className: 'title' }, 'Hello World');Rules:
- Use
classNamenotclass(class is JavaScript keyword) - Use
htmlFornotfor(for is JavaScript keyword) - Close all tags:
<img />not<img> - Wrap in parentheses for multi-line
- One root element (or use fragment
<>...</>)
Embedding JavaScript:
const name = 'Alice';
const age = 30;
// Curly braces for JavaScript expressions
<h1>Hello {name}</h1>
<p>Next year you'll be {age + 1}</p>
<div className={isActive ? 'active' : 'inactive'}>Content</div>Hooks are functions that let you "hook into" React features from functional components.
Rules:
- Only call at top level (not in loops, conditions, or nested functions)
- Only call from React functions (components or custom hooks)
- Names start with
use(useState, useEffect, useCustomHook)
This example demonstrates a full CRUD detail page with all key patterns explained inline.
File: src/Users/Detail/index.jsx
import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { retrieveUser, updateUser, deleteUser } from '../service.users';
import UserForm from '../Form';
import styles from './styles.module.css';
export default function UserDetail() {
// URL parameters from route /users/:id
const { id } = useParams();
// Navigation for redirects after actions
const navigate = useNavigate();
// Component state
const [user, setUser] = useState(null); // Loaded user data
const [loading, setLoading] = useState(true); // Loading indicator
const [error, setError] = useState(null); // Error message
// Fetch user data when component mounts or id changes
useEffect(() => {
async function fetchData() {
try {
setLoading(true);
setError(null);
const data = await retrieveUser(id);
setUser(data);
} catch (err) {
console.error('Failed to fetch user:', err);
setError('Failed to load user. Please try again.');
} finally {
setLoading(false);
}
}
fetchData();
}, [id]); // Re-run when id changes
// Update user handler - passed to form
async function handleUpdate(userData) {
try {
return await updateUser(id, userData);
} catch (err) {
console.error('Failed to update user:', err);
throw err; // Re-throw so form can handle it
}
}
// Success callback - update local state with saved data
function handleSuccess(updatedUser) {
setUser(updatedUser);
}
// Delete user with confirmation
async function handleDelete() {
const confirmed = confirm('Are you sure you want to delete this user?');
if (!confirmed) return;
try {
await deleteUser(id);
navigate('/users'); // Redirect to list after delete
} catch (err) {
console.error('Failed to delete user:', err);
setError('Failed to delete user. Please try again.');
}
}
// Loading state
if (loading) {
return <div className={styles.loading}>Loading...</div>;
}
// Error state
if (error) {
return (
<div className={styles.error}>
<p>{error}</p>
<Link to="/users">Back to Users</Link>
</div>
);
}
// No user found
if (!user) {
return (
<div>
<p>User not found</p>
<Link to="/users">Back to Users</Link>
</div>
);
}
// Main render
return (
<div className={styles.container}>
<h1>{user.name}</h1>
<p className={styles.email}>{user.email}</p>
<UserForm
initialUser={user}
submitHandler={handleUpdate}
onSuccess={handleSuccess}
submitBtnLabel="Save Changes"
/>
<div className={styles.actions}>
<button
onClick={handleDelete}
className={styles.deleteBtn}
>
Delete User
</button>
<Link to="/users">Back to List</Link>
</div>
</div>
);
}Explanation:
- useParams: Extracts
idfrom URL (/users/123→id = '123') - useNavigate: Enables redirect after delete
- useState: Three pieces of state (user, loading, error)
- useEffect: Fetches data on mount and when
idchanges - Async function in effect: Can't make useEffect async directly, so define async function inside
- Error handling: Try-catch blocks with user-friendly error messages
- Conditional rendering: Show different UI for loading, error, and success states
- Props to child: Pass handlers to UserForm for controlled behavior
- CSS Modules: Import styles as object, use
styles.className
Component Structure:
- Imports (React hooks, Router hooks, services, styles)
- Component function definition
- Hooks at top (useParams, useNavigate, useState, useEffect)
- Handler functions
- Early returns for loading/error states
- Main render return
Organize code by feature/domain rather than by file type. Each feature is self-contained with its components, services, and styles.
src/
├── App/ # Root application
│ ├── index.jsx # Router and routes
│ ├── config.js # Configuration loader
│ └── styles.module.css
├── Users/ # Users feature
│ ├── index.jsx # User list component
│ ├── Detail/ # User detail page
│ │ ├── index.jsx
│ │ └── styles.module.css
│ ├── Create/ # User creation page
│ │ └── index.jsx
│ ├── Form/ # Reusable user form
│ │ ├── index.jsx
│ │ └── styles.module.css
│ ├── service.users.js # API calls for users
│ └── styles.module.css
├── Products/ # Products feature
│ ├── index.jsx
│ ├── Detail/
│ ├── service.products.js
│ └── styles.module.css
├── Shared/ # Shared components
│ ├── Form/
│ │ ├── TextInput/
│ │ │ ├── index.jsx
│ │ │ └── styles.module.css
│ │ └── NumberInput/
│ │ └── index.jsx
│ └── Layout/
│ └── index.jsx
└── main.jsx # App entry point
Why feature-based?
- All related code in one place
- Easy to find and modify features
- Clear boundaries between features
- Can move/delete entire features easily
File Naming Rules:
- Components:
index.jsx(one per folder) - Services:
service.featurename.js(e.g.,service.users.js) - Styles:
styles.module.css(CSS Modules) - Folders: PascalCase for components (
UserDetail/), camelCase for utilities
The service layer isolates all API interactions. Components call service functions, never fetch directly.
File: src/Users/service.users.js
// Base URL from localStorage (set at app startup)
function apiUrl() {
return localStorage.getItem('apiBaseUrl');
}
function baseUrl() {
return `${apiUrl()}/users`;
}
// Helper to get auth header
function getAuthHeader() {
const token = sessionStorage.getItem('auth-token');
return token ? { 'Authorization': `Bearer ${token}` } : {};
}
// CREATE - POST request
export async function createUser(user) {
const response = await fetch(baseUrl(), {
method: 'POST',
headers: {
...getAuthHeader(),
'Content-Type': 'application/json',
},
body: JSON.stringify(user),
});
return await response.json();
}
// READ - GET single user
export async function retrieveUser(id) {
const response = await fetch(`${baseUrl()}/${id}`, {
headers: getAuthHeader(),
});
return await response.json();
}
// UPDATE - PATCH request
export async function updateUser(id, user) {
const response = await fetch(`${baseUrl()}/${id}`, {
method: 'PATCH',
headers: {
...getAuthHeader(),
'Content-Type': 'application/json',
},
body: JSON.stringify(user),
});
return await response.json();
}
// DELETE - DELETE request
export async function deleteUser(id) {
const response = await fetch(`${baseUrl()}/${id}`, {
method: 'DELETE',
headers: getAuthHeader(),
});
return await response.json();
}
// LIST - GET all users
export async function getUsers() {
const response = await fetch(baseUrl(), {
headers: getAuthHeader(),
});
return await response.json();
}
// GET current user's items
export async function getMyUsers() {
const url = `${apiUrl()}/my/users`;
const response = await fetch(url, {
headers: getAuthHeader(),
});
return await response.json();
}Explanation:
- Named exports: Each function is exported individually
- Helper functions:
apiUrl(),baseUrl(),getAuthHeader()not exported (internal) - Consistent patterns: All functions follow same structure
- RESTful naming:
create,retrieve,update,deletematch HTTP verbs - Authorization: Auth token added to headers when available
- localStorage: API URL configured at runtime, not hardcoded
Benefits:
- Components stay clean (no fetch code)
- Easy to test (mock service functions)
- API changes isolated to one file
- Consistent error handling
- Reusable across components
File: src/App/index.jsx
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import HomePage from '../HomePage';
import Dashboard from '../Dashboard';
import UserList from '../Users';
import UserDetail from '../Users/Detail';
import UserCreate from '../Users/Create';
import NotFound from '../NotFound';
import styles from './styles.module.css';
export default function App() {
return (
<div className={styles.app}>
<Router>
<Routes>
{/* Static routes */}
<Route path="/" element={<HomePage />} />
<Route path="/dashboard" element={<Dashboard />} />
{/* Feature routes */}
<Route path="/users" element={<UserList />} />
<Route path="/users/new" element={<UserCreate />} />
<Route path="/users/:id" element={<UserDetail />} />
{/* Catch-all for 404 */}
<Route path="*" element={<NotFound />} />
</Routes>
</Router>
</div>
);
}Explanation:
- BrowserRouter: Enables client-side routing
- Routes: Container for all Route components
- Route: Maps path to component
- path="/": Root route
- path="/users/:id": Dynamic parameter (accessed with useParams)
- path="*": Catch-all for unmatched routes (404)
- element prop: Component to render for this route
Route Order: More specific routes before general ones (e.g., /users/new before /users/:id)
Adds state to functional components. Returns current value and setter function.
import { useState } from 'react';
function Counter() {
// [currentValue, setterFunction] = useState(initialValue)
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}Explanation: useState(0) initializes count to 0. setCount updates the value and triggers re-render.
With Objects:
const [user, setUser] = useState({ name: '', email: '' });
// Update entire object
setUser({ name: 'Alice', email: 'alice@example.com' });
// Update one property (merge with existing)
setUser(prev => ({ ...prev, name: 'Alice' }));Explanation: When updating objects, spread previous state (...prev) to keep other properties intact.
With Arrays:
const [items, setItems] = useState([]);
// Add item
setItems(prev => [...prev, newItem]);
// Remove item
setItems(prev => prev.filter(item => item.id !== idToRemove));
// Update item
setItems(prev => prev.map(item =>
item.id === targetId ? { ...item, name: newName } : item
));Explanation: Use array methods (filter, map) with spread to create new arrays rather than mutating.
Performs side effects (data fetching, subscriptions, DOM manipulation) in functional components.
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
// Run after every render
useEffect(() => {
console.log('Component rendered');
});
// Run once on mount
useEffect(() => {
console.log('Component mounted');
}, []);
// Run when dependency changes
useEffect(() => {
fetchUser(userId).then(data => setUser(data));
}, [userId]); // Re-run when userId changes
// With cleanup
useEffect(() => {
const timer = setInterval(() => console.log('tick'), 1000);
// Cleanup function (runs before next effect and on unmount)
return () => clearInterval(timer);
}, []);
return <div>{user?.name}</div>;
}Explanation:
- No dependency array: Runs after every render
- Empty array
[]: Runs once on mount - With dependencies
[userId]: Runs when userId changes - Return cleanup function: Unsubscribe, clear timers, cancel requests
Data fetching pattern:
useEffect(() => {
// Define async function inside effect
async function fetchData() {
try {
const data = await getUsers();
setUsers(data);
} catch (error) {
console.error(error);
}
}
fetchData();
}, []);Explanation: Can't make useEffect callback async directly. Define async function inside and call it immediately.
Accesses dynamic URL parameters from React Router.
import { useParams } from 'react-router-dom';
function UserDetail() {
// Route: /users/:id
const { id } = useParams(); // id = '123' from /users/123
// Route: /posts/:postId/comments/:commentId
const { postId, commentId } = useParams();
return <div>User ID: {id}</div>;
}Explanation: Parameter names in route definition (:id) become object keys in useParams().
Programmatically navigate to different routes.
import { useNavigate } from 'react-router-dom';
function CreateUser() {
const navigate = useNavigate();
async function handleSubmit(userData) {
const newUser = await createUser(userData);
// Navigate to detail page
navigate(`/users/${newUser.id}`);
}
function handleCancel() {
// Go back one page in history
navigate(-1);
}
function goHome() {
// Navigate to specific path
navigate('/');
}
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
<button type="submit">Create</button>
<button type="button" onClick={handleCancel}>Cancel</button>
</form>
);
}Explanation:
navigate('/path')- Go to specific routenavigate(-1)- Go back one page (like browser back button)navigate(1)- Go forward one page
Access current route information.
import { useLocation } from 'react-router-dom';
function Navigation() {
const location = useLocation();
return (
<nav>
<a
href="/"
className={location.pathname === '/' ? 'active' : ''}
>
Home
</a>
<a
href="/about"
className={location.pathname === '/about' ? 'active' : ''}
>
About
</a>
</nav>
);
}Explanation: location.pathname contains current route path. Use it to highlight active navigation links.
CSS Modules scope styles to components, preventing naming conflicts.
File: src/Users/Detail/styles.module.css
.container {
max-width: 800px;
margin: 2rem auto;
padding: 2rem;
}
.loading {
text-align: center;
color: #999;
padding: 2rem;
}
.error {
color: #d32f2f;
background-color: #ffebee;
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
}
.email {
color: #666;
font-size: 0.875rem;
margin-bottom: 2rem;
}
.actions {
margin-top: 2rem;
display: flex;
gap: 1rem;
}
.deleteBtn {
background-color: #d32f2f;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.deleteBtn:hover {
background-color: #b71c1c;
}Using in Component:
import styles from './styles.module.css';
function UserDetail() {
return (
<div className={styles.container}>
<p className={styles.email}>user@example.com</p>
<button className={styles.deleteBtn}>Delete</button>
</div>
);
}Explanation: Import styles as JavaScript object. Access class names as object properties. Vite/Webpack transforms class names to be unique (container_a3x9b).
Combining Multiple Classes:
<div className={`${styles.button} ${styles.primary}`}>Click</div>Conditional Classes:
<div className={isActive ? styles.active : styles.inactive}>Content</div>Inline Styles for Dynamic Values:
<div
className={styles.box}
style={{ backgroundColor: color, width: `${width}px` }}
>
Content
</div>Explanation: Use inline styles when values are computed or dynamic. CSS Modules for static styles.
Load environment variables differently in development vs production.
File: src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { setLocalStorageConfigs } from './App/config';
async function render() {
// Load config before rendering app
await setLocalStorageConfigs();
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
}
render();Explanation: Call config loader before rendering React. Ensures all components have access to configuration from localStorage.
File: src/App/config.js
function isDefined(param) {
return typeof param !== 'undefined';
}
// Development: Use Vite environment variables
function getViteVars() {
return {
VITE_API_BASE_URL: import.meta.env.VITE_API_BASE_URL,
VITE_BUILD_VERSION: import.meta.env.VITE_BUILD_VERSION,
};
}
// Production: Fetch from server endpoint
async function getNodeVars() {
const response = await fetch('/mw/config');
return await response.json();
}
async function getVars() {
// Check if running in Vite dev mode
if (import.meta.env && import.meta.env.DEV) {
return getViteVars();
}
// Production: fetch from server
return await getNodeVars();
}
export async function setLocalStorageConfigs() {
const {
VITE_API_BASE_URL,
VITE_BUILD_VERSION,
} = await getVars();
localStorage.clear();
if (isDefined(VITE_API_BASE_URL)) {
localStorage.setItem('apiBaseUrl', VITE_API_BASE_URL);
}
if (isDefined(VITE_BUILD_VERSION)) {
localStorage.setItem('buildVersion', VITE_BUILD_VERSION);
}
}Explanation:
- Development: Vite reads
.envfile and exposes vars viaimport.meta.env - Production: Server provides endpoint (
/mw/config) with environment variables - localStorage: All config stored in localStorage for easy access throughout app
- Why this pattern: Same Docker image works in multiple environments (dev, staging, prod) with different configs
Usage in Components:
const apiUrl = localStorage.getItem('apiBaseUrl');
const buildVersion = localStorage.getItem('buildVersion');Show/hide UI based on state.
// Show loading, then content
{loading && <p>Loading...</p>}
{!loading && data && <DataDisplay data={data} />}Explanation: && operator short-circuits. If left side is falsy, right side doesn't render.
// Only render if data exists
{user && <UserProfile user={user} />}
{!user && <p>No user found</p>}// Choose between two options
{loading ? <Spinner /> : <Content />}
{isAdmin ? <AdminPanel /> : <UserPanel />}Explanation: Ternary for binary choices (show this OR that).
{loading ? (
<LoadingSpinner />
) : error ? (
<ErrorMessage error={error} />
) : data ? (
<DataDisplay data={data} />
) : (
<NoData />
)}Explanation: Chain ternaries for multiple states. Check loading first, then error, then data, finally fallback.
{isAuthenticated && <Dashboard />}
{!isAuthenticated && <LoginPrompt />}
{items.length === 0 && <EmptyState />}
{items.length > 0 && <ItemList items={items} />}Explanation: Use boolean expressions with && for simple show/hide logic.
Login:
async function handleLogin(email, password) {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const { token, user } = await response.json();
// Store token in sessionStorage
sessionStorage.setItem('auth-token', token);
// Navigate to dashboard
navigate('/dashboard');
} catch (error) {
setError('Login failed');
}
}Explanation: Store auth token in sessionStorage (cleared when browser closes). Navigate to protected route after successful login.
Logout:
function handleLogout() {
// Clear session data
sessionStorage.clear();
// Navigate to home
navigate('/');
}Explanation: Remove all session data and redirect to public page.
Protected Routes:
import { Navigate } from 'react-router-dom';
function ProtectedRoute({ children }) {
const token = sessionStorage.getItem('auth-token');
if (!token) {
// Redirect to login if not authenticated
return <Navigate to="/login" replace />;
}
return children;
}
// Usage in App
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>Explanation: Wrapper component checks for token. If missing, redirects to login. If present, renders protected component. replace prevents back button to protected route.
File: vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: true, // Expose to network (for Docker)
},
build: {
outDir: 'dist',
sourcemap: true,
},
})Explanation:
- plugins: Enables React with JSX and Fast Refresh
- host: true: Makes dev server accessible from network (needed for Docker containers)
- outDir: Where production build files go
- sourcemap: Generate source maps for debugging
File: package.json
{
"name": "my-react-app",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src --ext js,jsx"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.11.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.0.0",
"vite": "^4.3.0",
"eslint": "^8.38.0"
}
}Explanation:
- type: "module": Enables ES6 modules in Node.js
- dependencies: Runtime packages included in bundle
- devDependencies: Build tools not included in bundle
Ideal for:
- Small to medium SPAs (5-50 components)
- Apps with clear feature boundaries
- Teams preferring minimal dependencies
- Projects with straightforward state needs
- Rapid prototyping and MVPs
Consider alternatives for:
- Large apps with complex state (use Redux, Zustand)
- Heavy caching requirements (use TanStack Query)
- Real-time data synchronization (use WebSockets, GraphQL subscriptions)
- Complex data normalization needs (use Redux Toolkit, Normalize)