React & Modern Frontend Frameworks
Hooks Deep Dive & State Management
React hooks are the backbone of modern React development. Interviewers test not just your ability to use hooks, but your understanding of their rules, pitfalls, and when to choose alternatives. Modern React codebases (React 18+/19) rarely use class components or legacy lifecycle methods (componentDidMount, componentDidUpdate) — expect interviews to focus almost entirely on hook-based code.
useEffect Cleanup Pitfalls
The most common source of bugs in React interviews:
// WRONG — memory leak, no cleanup
useEffect(() => {
const interval = setInterval(() => {
setCount(c => c + 1);
}, 1000);
// Missing cleanup! The interval runs forever
}, []);
// CORRECT — cleanup on unmount
useEffect(() => {
const interval = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
// WRONG — stale closure over state
useEffect(() => {
const handler = () => {
console.log(count); // Always logs the initial value
};
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler);
}, []); // Empty deps = stale closure
// CORRECT — use ref for latest value
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
const handler = () => {
console.log(countRef.current); // Always current
};
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler);
}, []);
useRef vs. useState
Know when to use each:
| Feature | useState | useRef |
|---|---|---|
| Triggers re-render | Yes | No |
| Persists across renders | Yes | Yes |
| Available in render | Current value | .current |
| Use for | UI state, displayed values | Timers, DOM refs, previous values, mutable flags |
// Classic interview question: implement usePrevious
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<p>Now: {count}, Before: {prevCount}</p>
);
}
Custom Hooks
Designing custom hooks demonstrates senior-level React skills:
// useDebounce: debounce a rapidly changing value
function useDebounce(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// useLocalStorage: persist state to localStorage
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
// Usage
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
const [history, setHistory] = useLocalStorage('search-history', []);
useEffect(() => {
if (debouncedQuery) {
fetchResults(debouncedQuery);
setHistory(prev => [...prev, debouncedQuery].slice(-10));
}
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
State Management: When to Use What
This is a common interview discussion topic:
React Context
// Good for: theme, locale, auth state (infrequent updates)
const ThemeContext = createContext('light');
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Page />
</ThemeContext.Provider>
);
}
// BAD for: frequently updating state (causes all consumers to re-render)
Zustand (lightweight external store)
// Good for: shared state that updates frequently, simple API
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
reset: () => set({ count: 0 }),
}));
function Counter() {
// Only re-renders when count changes, not other store values
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
return <button onClick={increment}>{count}</button>;
}
TanStack Query (server state)
// Good for: API data fetching, caching, synchronization
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
});
if (isLoading) return <Skeleton />;
if (error) return <Error message={error.message} />;
return <div>{data.name}</div>;
}
The follow-up you should expect after describing Context: "why not just use Context for everything?" The answer is a specific mechanism, not a preference.
Context vs. an external store — the re-render mechanism
React Context
- No dependency, no store to set up, and it is the answer interviewers expect for theme, locale, and auth
- The provider boundary is visible in the tree, so where a value comes from is obvious when reading the code
- Works unchanged in Server Component trees as long as the provider itself is a Client Component
- A new context value re-renders every consumer, regardless of which field they read — there is no selector, so `{user, theme, setTheme}` re-renders the user badge when the theme toggles
- The usual workaround is splitting into several contexts, which multiplies providers and moves the problem rather than solving it
- An object or arrow function passed inline as `value` is a new reference on every parent render, so the provider re-renders consumers even when nothing changed
External store (Zustand, Jotai, Redux Toolkit)
- Subscribing with a selector means a component re-renders only when the slice it reads changes — this is the actual reason to reach for one
- State can be read and written outside the React tree, which makes it usable from event handlers, workers, and tests without rendering anything
- No provider nesting, so the tree stays flat as the number of state domains grows
- Global mutable state is easy to write from anywhere, which is exactly how it becomes hard to reason about
- A selector returning a fresh object each call defeats the whole mechanism — this is the most common bug and it looks like the library is broken
- Teams routinely put server data in it and then hand-roll caching, invalidation, and refetching that TanStack Query already does properly
Decision Matrix
| Need | Solution |
|---|---|
| Theme, locale, auth | React Context |
| Form state | useState / useReducer (local) |
| Complex local state | useReducer |
| Shared client state | Zustand or Jotai |
| Server data | TanStack Query |
| Large-scale global state | Redux Toolkit (if already in stack) |
Interview tip: When asked about state management, demonstrate that you choose the right tool for the specific need rather than defaulting to one solution for everything. This shows architectural thinking.
Next, we'll explore Server Components and the Next.js architecture that's reshaping frontend development. :::
Sign in to rate