Understand useState, useEffect, useRef, useContext and custom hooks with practical examples that show you exactly when and why to use each one.
Hooks are functions that let you "hook into" React state and lifecycle features from function components. Before hooks (introduced in React 16.8), you needed class components for state and lifecycle logic. Hooks made function components just as powerful — and much cleaner.
useState gives a component its own memory.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
Key rules:
useState returns a tuple: [currentValue, setter]For objects, spread to avoid mutation:
const [user, setUser] = useState({ name: '', email: '' });
setUser(prev => ({ ...prev, name: 'Alex' }));
useEffect runs code after render. It replaces componentDidMount, componentDidUpdate, and componentWillUnmount.
import { useState, useEffect } from 'react';
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]); // re-runs when userId changes
return <div>{user?.name}</div>;
}
The dependency array:
[] — runs once on mount (like componentDidMount)[value] — runs on mount and whenever value changesCleanup:
useEffect(() => {
const timer = setInterval(() => tick(), 1000);
return () => clearInterval(timer); // cleanup on unmount
}, []);
useRef gives you a mutable container that doesn't trigger re-renders.
DOM access:
function SearchInput() {
const inputRef = useRef<HTMLInputElement>(null);
const focus = () => inputRef.current?.focus();
return (
<>
<input ref={inputRef} />
<button onClick={focus}>Focus</button>
</>
);
}
Persisting values across renders (without re-rendering):
const renderCount = useRef(0);
renderCount.current += 1;
useContext lets any component read from a context without passing props down manually.
const ThemeContext = createContext<'light' | 'dark'>('light');
function App() {
return (
<ThemeContext.Provider value="dark">
<Toolbar />
</ThemeContext.Provider>
);
}
function Toolbar() {
return <ThemedButton />;
}
function ThemedButton() {
const theme = useContext(ThemeContext);
return <button className={theme}>Click</button>;
}
Any function that starts with use and calls other hooks is a custom hook. They're the best way to share logic across components.
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setStoredValue = (newValue: T) => {
setValue(newValue);
localStorage.setItem(key, JSON.stringify(newValue));
};
return [value, setStoredValue] as const;
}
// Usage
const [theme, setTheme] = useLocalStorage('theme', 'light');
The ESLint plugin eslint-plugin-react-hooks enforces these automatically.
Hooks completely changed how React apps are written. Start with useState and useEffect — they cover 80% of use cases. Add useRef when you need DOM access or stable values, and useContext when prop drilling becomes painful. Once those feel natural, build custom hooks to encapsulate and share your logic cleanly.