React Hooks Deep Dive: Beyond useState and useEffect
Most React developers know useState and useEffect. But React offers several other hooks that can make your code cleaner, faster, and more maintainable. Let’s explore the hooks you might be missing.
useCallback: Memoizing Functions
Every time a component re-renders, all functions inside it are recreated. Usually this is fine, but it becomes a problem when you pass functions to child components or use them in dependency arrays.
The Problem
function Parent() {
const [count, setCount] = useState(0);
// This function is recreated on every render
const handleClick = () => {
console.log('clicked');
};
return (
<>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<ExpensiveChild onClick={handleClick} />
</>
);
}
Every time count changes, handleClick is a new function, causing ExpensiveChild to re-render even though the function does the same thing.
The Solution
function Parent() {
const [count, setCount] = useState(0);
// This function is only created once
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return (
<>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<ExpensiveChild onClick={handleClick} />
</>
);
}
When Dependencies Matter
If your callback needs access to state or props, include them in the dependency array:
function SearchForm({ onSearch }) {
const [query, setQuery] = useState('');
const handleSubmit = useCallback(() => {
onSearch(query);
}, [query, onSearch]); // Recreated when query or onSearch changes
return (
<form onSubmit={handleSubmit}>
<input value={query} onChange={e => setQuery(e.target.value)} />
<button type="submit">Search</button>
</form>
);
}
useMemo: Memoizing Values
While useCallback memoizes functions, useMemo memoizes the result of a computation.
The Problem
function ProductList({ products, filter }) {
// This runs on EVERY render, even if products and filter haven't changed
const filteredProducts = products.filter(p =>
p.category === filter && p.inStock
);
return (
<ul>
{filteredProducts.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
The Solution
function ProductList({ products, filter }) {
// Only recalculates when products or filter change
const filteredProducts = useMemo(() => {
return products.filter(p => p.category === filter && p.inStock);
}, [products, filter]);
return (
<ul>
{filteredProducts.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
Real-World Example: Sorting and Filtering
function DataTable({ data, sortKey, sortDirection, searchTerm }) {
const processedData = useMemo(() => {
// Filter first
let result = data.filter(item =>
Object.values(item).some(val =>
String(val).toLowerCase().includes(searchTerm.toLowerCase())
)
);
// Then sort
result.sort((a, b) => {
const aVal = a[sortKey];
const bVal = b[sortKey];
const modifier = sortDirection === 'asc' ? 1 : -1;
if (aVal < bVal) return -1 * modifier;
if (aVal > bVal) return 1 * modifier;
return 0;
});
return result;
}, [data, sortKey, sortDirection, searchTerm]);
return <Table data={processedData} />;
}
useRef: Beyond DOM References
Most people know useRef for accessing DOM elements, but it’s also perfect for storing mutable values that don’t trigger re-renders.
Accessing DOM Elements
function TextInput() {
const inputRef = useRef(null);
const focusInput = () => {
inputRef.current.focus();
};
return (
<>
<input ref={inputRef} />
<button onClick={focusInput}>Focus</button>
</>
);
}
Storing Previous Values
function Counter() {
const [count, setCount] = useState(0);
const prevCountRef = useRef();
useEffect(() => {
prevCountRef.current = count;
});
const prevCount = prevCountRef.current;
return (
<div>
<p>Current: {count}, Previous: {prevCount}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
);
}
Storing Interval IDs
function Timer() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null);
const start = () => {
if (intervalRef.current) return; // Already running
intervalRef.current = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
};
const stop = () => {
clearInterval(intervalRef.current);
intervalRef.current = null;
};
useEffect(() => {
return () => clearInterval(intervalRef.current);
}, []);
return (
<div>
<p>Seconds: {seconds}</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
useReducer: Complex State Logic
When state logic gets complex, useReducer can be cleaner than multiple useState calls.
The useState Approach
function ShoppingCart() {
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
const [discount, setDiscount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const addItem = (item) => {
setItems([...items, item]);
setTotal(total + item.price);
};
const removeItem = (id) => {
const item = items.find(i => i.id === id);
setItems(items.filter(i => i.id !== id));
setTotal(total - item.price);
};
// ... more functions updating multiple state values
}
The useReducer Approach
const initialState = {
items: [],
total: 0,
discount: 0,
isLoading: false,
};
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM':
return {
...state,
items: [...state.items, action.payload],
total: state.total + action.payload.price,
};
case 'REMOVE_ITEM': {
const item = state.items.find(i => i.id === action.payload);
return {
...state,
items: state.items.filter(i => i.id !== action.payload),
total: state.total - item.price,
};
}
case 'APPLY_DISCOUNT':
return {
...state,
discount: action.payload,
total: state.total * (1 - action.payload / 100),
};
case 'SET_LOADING':
return { ...state, isLoading: action.payload };
default:
return state;
}
}
function ShoppingCart() {
const [state, dispatch] = useReducer(cartReducer, initialState);
const addItem = (item) => dispatch({ type: 'ADD_ITEM', payload: item });
const removeItem = (id) => dispatch({ type: 'REMOVE_ITEM', payload: id });
const applyDiscount = (percent) => dispatch({ type: 'APPLY_DISCOUNT', payload: percent });
return (
// JSX using state.items, state.total, etc.
);
}
Benefits of useReducer
- Single source of truth: All state updates in one place
- Predictable: Given the same state and action, you always get the same result
- Testable: Reducers are pure functions, easy to unit test
- Debuggable: You can log every action to see state changes
Building Custom Hooks
Combine these hooks to create reusable logic:
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
const setValue = useCallback((value) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(error);
}
}, [key, storedValue]);
return [storedValue, setValue];
}
// Usage
function App() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Current theme: {theme}
</button>
);
}
When to Optimize
A word of caution: don’t optimize prematurely. These hooks add complexity. Use them when:
- useCallback: Passing callbacks to memoized child components, or using callbacks in dependency arrays
- useMemo: Computing derived data from large datasets, or creating objects used in dependency arrays
- useReducer: State updates depend on multiple values, or you have complex state transitions
For simple cases, the overhead of memoization might be worse than just letting React re-run the code.
Final Thoughts
These hooks are tools, not rules. Start simple with useState and useEffect. When you notice performance issues or complex state logic, reach for these advanced hooks.
The key insight is understanding what triggers re-renders and when that matters. Most of the time, React is fast enough without optimization. When it’s not, these hooks give you precise control over what gets recalculated and when.