React Hooks let you use state and lifecycle features in function components without rewriting class components — here's how to use them correctly in production.
React Hooks changed how we build React applications. Since React 16.8, hooks have become the standard way to manage state and side effects in function components. If you're a full-stack developer coming from class components or vanilla JavaScript, hooks simplify your mental model — but they also introduce new rules you can't ignore.
Why React Hooks Matters (and When to Skip It)
Hooks matter because they eliminate the ceremony of class components. No more this binding, no more componentDidMount vs componentDidUpdate confusion. You get a single mental model for state, effects, and context.
But here's my opinionated take: don't use hooks if you're maintaining a stable legacy codebase with zero new features. Refactoring working class components to hooks for "cleanliness" is a waste of time. The cost of regression bugs outweighs the aesthetic benefit. Use hooks for new code, not for vanity refactors.
Getting Started with React Hooks
You need React 16.8 or later. Here's the minimal setup with TypeScript:
npm install react@latest react-dom@latest
npm install --save-dev @types/react @types/react-dom
Your first hook — useState — in a runnable TypeScript component:
import { useState } from 'react';
interface CounterProps {
initialCount?: number;
}
export function Counter({ initialCount = 0 }: CounterProps) {
const [count, setCount] = useState<number>(initialCount);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((prev) => prev + 1)}>Increment</button>
<button onClick={() => setCount(initialCount)}>Reset</button>
</div>
);
}
That's it. No class, no this, no constructor. The functional updater (prev) => prev + 1 ensures you always work with the latest state, even under rapid clicks.
Core React Hooks Concepts Every Developer Should Know
1. useEffect — Taming Side Effects
useEffect runs after render. It replaces componentDidMount, componentDidUpdate, and componentWillUnmount in one API:
import { useState, useEffect } from 'react';
export function UserStatus({ userId }: { userId: string }) {
const [status, setStatus] = useState<'online' | 'offline'>('offline');
useEffect(() => {
let isMounted = true; // Prevents state update after unmount
const fetchStatus = async () => {
const response = await fetch(`/api/users/${userId}/status`);
const data = await response.json();
if (isMounted) {
setStatus(data.status);
}
};
fetchStatus();
return () => {
isMounted = false; // Cleanup on unmount or userId change
};
}, [userId]); // Dependency array — only re-run when userId changes
return <span>User is {status}</span>;
}
The dependency array is the key. Empty array [] means run once on mount. Missing dependencies cause stale closures — a classic bug we'll cover below.
2. useMemo and useCallback — Performance Without Premature Optimization
import { useMemo, useCallback, useState } from 'react';
interface Product {
id: number;
name: string;
price: number;
}
export function ProductList({ products }: { products: Product[] }) {
const [filter, setFilter] = useState('');
// Only recompute when products or filter change
const filteredProducts = useMemo(() => {
return products.filter((p) =>
p.name.toLowerCase().includes(filter.toLowerCase())
);
}, [products, filter]);
// Stable reference — use as prop for memoized child components
const handleFilterChange = useCallback((value: string) => {
setFilter(value);
}, []);
return (
<div>
<input
value={filter}
onChange={(e) => handleFilterChange(e.target.value)}
placeholder="Filter products"
/>
<ul>
{filteredProducts.map((p) => (
<li key={p.id}>{p.name} — ${p.price}</li>
))}
</ul>
</div>
);
}
Use useMemo for expensive computations, not trivial ones. Use useCallback when passing functions to memoized child components. Otherwise, you're just adding overhead.
3. Custom Hooks — Composing Logic
Custom hooks are functions starting with use that call other hooks. This is where hooks shine for full-stack developers — you can share data-fetching logic across components:
import { useState, useEffect } from 'react';
interface FetchState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
export function useFetch<T>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: true,
error: null,
});
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
setState({ data, loading: false, error: null });
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
setState({ data: null, loading: false, error: err.message });
}
}
};
fetchData();
return () => controller.abort(); // Cancel request on unmount
}, [url]);
return state;
}
Now any component can use it: const { data, loading, error } = useFetch<User>('/api/users/42');
Common React Hooks Mistakes and How to Fix Them
Mistake 1: Missing Dependencies in useEffect
// ❌ BAD — stale closure, lint warning
useEffect(() => {
setCount(count + 1);
}, []);
// ✅ GOOD — functional update
useEffect(() => {
setCount((prev) => prev + 1);
}, []);
Functional updates avoid the stale closure entirely. If you need a value inside the effect, put it in the dependency array.
Mistake 2: Running Effects on Every Render
// ❌ BAD — fetch on every render, infinite loop risk
useEffect(() => {
fetch('/api/data').then((r) => r.json()).then(setData);
});
// ✅ GOOD — run once with empty deps
useEffect(() => {
fetch('/api/data').then((r) => r.json()).then(setData);
}, []);
Mistake 3: Mutating State Directly
// ❌ BAD — React won't re-render
const [user, setUser] = useState({ name: 'Suhail' });
user.name = 'Roushan'; // Mutating state directly
// ✅ GOOD — create a new object
setUser((prev) => ({ ...prev, name: 'Roushan' }));
React uses referential equality to detect changes. Mutating an object in place doesn't create a new reference, so the component won't re-render.
When Should You Use React Hooks?
Use React Hooks when you're writing function components that need state, side effects, or context. That's the default for any new React code today. You should also use hooks when you want to reuse stateful logic across components — custom hooks beat higher-order components and render props for readability. Skip hooks only if you're maintaining a legacy class-component codebase with no new features planned, or if your team has a strict policy against them.
React Hooks in Production
1. Use ESLint's react-hooks Plugin
npm install --save-dev eslint-plugin-react-hooks
Enable the rules-of-hooks and exhaustive-deps rules. They catch the dependency mistakes above at lint time, not runtime. This is non-negotiable in my projects.
2. Split Hooks by Responsibility
Don't cram everything into one useEffect. Separate concerns:
// ❌ Mixed concerns in one effect
useEffect(() => {
fetchUser();
trackAnalytics();
subscribeToSocket();
}, []);
// ✅ Separate hooks
useEffect(() => {
fetchUser();
}, [userId]);
useEffect(() => {
trackAnalytics();
}, [page]);
useEffect(() => {
const socket = subscribeToSocket();
return () => socket.disconnect();
}, []);
3. Profile Before Optimizing with useMemo
Don't wrap everything in useMemo preemptively. Run the React Profiler first. If a computation takes under 1ms, useMemo adds more overhead than it saves. Measure, then optimize.
One concrete takeaway: adopt hooks for all new React code, install the ESLint plugin on day one, and always use functional updates with setState — these three habits will prevent 80% of the bugs I see in production React apps. For more full-stack patterns and real-world examples, check out the tutorials on suhailroushan.com.