React Best Practices 2025: Patterns for Scalable, Maintainable Apps
Production-tested patterns for React and Next.js applications — component design, state management, performance, and code organisation that actually scales.
Why Patterns Matter
React gives you maximum flexibility — which means it's easy to build something that works today and becomes a maintenance nightmare in six months. These are the patterns I've adopted after building 30+ production applications.
1. Component Design
Single Responsibility
Each component should do one thing. If you're doing data fetching, transformation, AND rendering in one component, split it.
// ❌ God component
function UserDashboard() {
const [users, setUsers] = useState([]);
useEffect(() => { fetch('/api/users').then(r => r.json()).then(setUsers) }, []);
const filtered = users.filter(u => u.active);
return <div>{filtered.map(u => <div key={u.id}>{u.name} ...</div>)}</div>;
}
// ✅ Separated concerns
function useUsers() {
const [users, setUsers] = useState<User[]>([]);
useEffect(() => { fetch('/api/users').then(r => r.json()).then(setUsers) }, []);
return users.filter(u => u.active);
}
function UserCard({ user }: { user: User }) {
return <div className="card">{user.name}</div>;
}
function UserDashboard() {
const users = useUsers();
return <div>{users.map(u => <UserCard key={u.id} user={u} />)}</div>;
}
Props Interface First
Define your component's interface before writing JSX. It forces you to think about the API.
interface ButtonProps {
label: string;
onClick: () => void;
variant?: 'primary' | 'ghost' | 'danger';
disabled?: boolean;
loading?: boolean;
}
export function Button({ label, onClick, variant = 'primary', disabled, loading }: ButtonProps) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled || loading}
className={cn(baseStyles, variants[variant])}
>
{loading ? <Spinner /> : label}
</button>
);
}
2. Custom Hooks — Extract Everything
If logic is reusable, it belongs in a hook.
// useLocalStorage.ts
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
if (typeof window === 'undefined') return initialValue;
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setStoredValue = (newValue: T) => {
setValue(newValue);
window.localStorage.setItem(key, JSON.stringify(newValue));
};
return [value, setStoredValue] as const;
}
// Usage
const [theme, setTheme] = useLocalStorage('theme', 'dark');
// useDebounce.ts — useful for search inputs
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// Usage in search
const debouncedQuery = useDebounce(searchQuery, 300);
useEffect(() => { fetchResults(debouncedQuery) }, [debouncedQuery]);
3. State Management — Don't Over-Engineer
Most apps don't need Redux. Here's the decision tree:
Local UI state (toggle, form input) → useState
Shared within a feature → useContext + useReducer
Server state (API data) → SWR or TanStack Query
Global app state (auth, cart) → Zustand (lightweight)
Zustand (best lightweight global store)
import { create } from 'zustand';
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
total: () => number;
}
const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (id) => set((state) => ({ items: state.items.filter(i => i.id !== id) })),
total: () => get().items.reduce((sum, i) => sum + i.price * i.qty, 0),
}));
4. Next.js App Router Patterns
Server vs Client Components
// app/blog/page.tsx — Server Component (default)
// Fetches on the server, no JS bundle impact
async function BlogPage() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return <PostList posts={posts} />;
}
// components/LikeButton.tsx — needs interactivity → Client Component
'use client';
function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return <button onClick={() => setLiked(l => !l)}>{liked ? '❤️' : '🤍'}</button>;
}
Rule: Push "use client" as far down the tree as possible.
Data Fetching with Caching
// Cache for 1 hour
const data = await fetch('/api/posts', { next: { revalidate: 3600 } });
// Never cache (always fresh)
const data = await fetch('/api/user', { cache: 'no-store' });
// Revalidate on demand (ISR)
import { revalidatePath } from 'next/cache';
revalidatePath('/blog');
5. Performance
Memoisation — use sparingly
// Only memo when renders are genuinely expensive
const ExpensiveList = memo(function ExpensiveList({ items }: { items: Item[] }) {
return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
});
// useMemo for expensive calculations
const sorted = useMemo(
() => [...items].sort((a, b) => b.score - a.score),
[items]
);
Code Splitting
import dynamic from 'next/dynamic';
// Don't load Three.js on initial page load
const ThreeScene = dynamic(() => import('@/components/ThreeScene'), {
ssr: false,
loading: () => <div className="skeleton h-96" />,
});
Image Optimisation
import Image from 'next/image';
// Always provide width/height or fill + sizes
<Image
src="/hero.png"
alt="Hero"
fill
sizes="(max-width: 768px) 100vw, 50vw"
priority // above-the-fold images
/>
6. TypeScript Patterns
// Discriminated unions — exhaustive type checks
type ApiState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
function DataView<T>({ state }: { state: ApiState<T> }) {
switch (state.status) {
case 'idle': return null;
case 'loading': return <Spinner />;
case 'success': return <pre>{JSON.stringify(state.data, null, 2)}</pre>;
case 'error': return <p className="text-red-500">{state.error}</p>;
}
}
Project Structure
src/
├── app/ # Next.js App Router pages
├── components/
│ ├── ui/ # Primitive components (Button, Input, Modal)
│ └── features/ # Feature-specific components
├── hooks/ # Custom hooks
├── lib/ # Utilities, constants, API clients
├── types/ # TypeScript types
└── data/ # Static data, fixtures
These patterns have served me well across restaurant e-commerce, real-time chat apps, and admin dashboards. Start with the ones that solve your current pain, not all of them at once.
View my projects to see these patterns in action, or reach out if you want to collaborate.
if (helpful) {
}
Found this useful? Follow me on GitHub · Hire me for your project