React Hooks: Interview Guide for 2026
Everything you need to know about React hooks for your frontend interview. From basics to advanced patterns, with real-world examples.
Why Hooks Matter in Interviews
React hooks are fundamental to modern React development. Interviewers expect you to understand not just what each hook does, but when and why to use them. The questions range from basic ("What's the difference between useState and useRef?") to advanced ("How would you optimize this component?").
The Rules of Hooks
Before diving into individual hooks, understand the two rules that React enforces:
- Only call hooks at the top level - Never inside loops, conditions, or nested functions
- Only call hooks from React functions - Either function components or custom hooks
Why? React relies on the order of hook calls to maintain state between renders. If hooks are called conditionally, that order can change, breaking React's internal tracking.
useState: Managing State
The most basic hook. It returns a state value and a setter function.
const [count, setCount] = useState(0)
// Update with a new value
setCount(5)
// Update based on previous value (functional update)
setCount(prev => prev + 1)
// Lazy initialization (expensive computation only runs once)
const [data, setData] = useState(() => expensiveComputation())Common Interview Question
Q: Why use the functional update form?
A: When the new state depends on the previous state, especially in async operations or when multiple updates might be batched, the functional form ensures you're always working with the latest value.
useEffect: Side Effects
For operations that "reach outside" the component: API calls, subscriptions, DOM manipulation, timers.
// Runs after every render
useEffect(() => {
console.log('Component rendered')
})
// Runs only on mount (empty dependency array)
useEffect(() => {
fetchData()
}, [])
// Runs when dependencies change
useEffect(() => {
fetchUser(userId)
}, [userId])
// Cleanup function (runs before next effect and on unmount)
useEffect(() => {
const subscription = subscribe(id)
return () => subscription.unsubscribe()
}, [id])Common Interview Questions
Q: What's the difference between useEffect with [] vs no array?
A: Empty array runs once on mount. No array runs after every render. Missing the array is usually a bug.
Q: Why does my useEffect run twice in development?
A: React 18 Strict Mode intentionally double-invokes effects to help find bugs. Your cleanup function should make effects idempotent.
useCallback: Memoizing Functions
Returns a memoized version of a callback that only changes when dependencies change.
// Without useCallback: new function every render
const handleClick = () => doSomething(id)
// With useCallback: same function reference if id hasn't changed
const handleClick = useCallback(() => {
doSomething(id)
}, [id])When to Use useCallback
- Passing callbacks to memoized child components (React.memo)
- Callbacks used in dependency arrays of other hooks
- Performance optimization when profiler shows unnecessary re-renders
Common Interview Question
Q: Should I wrap every function in useCallback?
A: No. useCallback itself has a cost (comparing dependencies). Only use it when the function is passed to a memoized component or used in a dependency array. Premature optimization makes code harder to read.
useMemo: Memoizing Values
Returns a memoized value that only recomputes when dependencies change.
// Expensive computation only runs when items changes
const sortedItems = useMemo(() => {
return [...items].sort((a, b) => a.name.localeCompare(b.name))
}, [items])
// Creating objects for child components
const contextValue = useMemo(() => ({
user,
login,
logout
}), [user, login, logout])useMemo vs useCallback
// These are equivalent:
const memoizedFn = useCallback(() => doSomething(a, b), [a, b])
const memoizedFn = useMemo(() => () => doSomething(a, b), [a, b])useRef: Mutable References
Returns a mutable ref object that persists across renders. Changing .current doesn't trigger a re-render.
// DOM reference
const inputRef = useRef<HTMLInputElement>(null)
const focusInput = () => inputRef.current?.focus()
// Storing previous value
const prevCountRef = useRef(count)
useEffect(() => {
prevCountRef.current = count
}, [count])
// Storing mutable value (like instance variable)
const intervalRef = useRef<NodeJS.Timeout>()
useEffect(() => {
intervalRef.current = setInterval(() => tick(), 1000)
return () => clearInterval(intervalRef.current)
}, [])Common Interview Question
Q: When would you use useRef instead of useState?
A: When you need to store a value that shouldn't trigger re-renders: DOM references, interval IDs, previous values, or any mutable value that's not used in rendering.
useContext: Consuming Context
Subscribes to a React context and re-renders when the context value changes.
// Create context
const ThemeContext = createContext<'light' | 'dark'>('light')
// Provider (usually near the top of your app)
function App() {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
return (
<ThemeContext.Provider value={theme}>
<Main />
</ThemeContext.Provider>
)
}
// Consumer
function Button() {
const theme = useContext(ThemeContext)
return <button className={theme}>Click me</button>
}Performance Tip
Every component that uses useContext will re-render when the context value changes. Split contexts if different parts of the value change at different rates.
useReducer: Complex State
Alternative to useState for complex state logic or when next state depends on previous.
type State = { count: number; step: number }
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'setStep'; step: number }
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + state.step }
case 'decrement':
return { ...state, count: state.count - state.step }
case 'setStep':
return { ...state, step: action.step }
default:
return state
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 })
return (
<>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
</>
)
}Custom Hooks: Reusable Logic
Extract stateful logic into reusable functions. Must start with "use".
// Debounced value hook
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
// Usage
function SearchComponent() {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 300)
useEffect(() => {
if (debouncedQuery) {
fetchResults(debouncedQuery)
}
}, [debouncedQuery])
}More Custom Hook Examples
// Local storage sync
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key)
return stored ? JSON.parse(stored) : initialValue
})
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value))
}, [key, value])
return [value, setValue] as const
}
// Window size
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
})
useEffect(() => {
const handleResize = () => {
setSize({ width: window.innerWidth, height: window.innerHeight })
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
return size
}Common Interview Patterns
1. Data Fetching
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
const controller = new AbortController()
async function fetchData() {
try {
setLoading(true)
const res = await fetch(url, { signal: controller.signal })
if (!res.ok) throw new Error('Fetch failed')
const json = await res.json()
setData(json)
} catch (e) {
if (e instanceof Error && e.name !== 'AbortError') {
setError(e)
}
} finally {
setLoading(false)
}
}
fetchData()
return () => controller.abort()
}, [url])
return { data, loading, error }
}2. Form Handling
function useForm<T extends Record<string, string>>(initialValues: T) {
const [values, setValues] = useState(initialValues)
const [errors, setErrors] = useState<Partial<T>>({})
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
setValues(prev => ({ ...prev, [name]: value }))
}
const reset = () => setValues(initialValues)
return { values, errors, setErrors, handleChange, reset }
}Performance Optimization Checklist
- Profile first—don't optimize prematurely
- Use React.memo for components that render often with same props
- Wrap callbacks in useCallback when passed to memoized children
- Use useMemo for expensive computations
- Split context if parts change at different rates
- Consider virtualization for long lists
Key Takeaways
- useState for simple state, useReducer for complex
- useEffect for side effects, always include cleanup
- useCallback/useMemo for optimization, not by default
- useRef for values that shouldn't trigger re-renders
- Custom hooks extract and share stateful logic
- Follow the rules: top-level only, React functions only