React Hooks - Advanced useRef and Custom Hooks with Refs
Advanced patterns combining useRef with custom hooks
Question 1: How do you create production-ready custom hooks that use useRef?β
Difficulty: π‘ Medium Frequency: ββββ Time: 8-10 minutes Companies: Google, Meta, Amazon, Microsoft, Netflix, Uber
Questionβ
Explain how to build custom hooks that leverage useRef for non-reactive state management, DOM manipulation, and performance optimization. What are the common patterns and pitfalls?
Answerβ
Custom hooks using useRef solve critical problems where state updates would cause unnecessary re-renders or where you need to maintain stable references across renders. The key principle is using refs for values that need persistence but shouldn't trigger UI updates.
Core Patterns:
- Previous Value Tracking: Store previous props/state for comparison
- Mutable Callbacks: Maintain latest callback without changing reference
- Instance Variables: Store component-scoped values like timers, subscriptions
- DOM Measurements: Access and measure DOM elements
- Cleanup Coordination: Track cleanup state across async operations
Common Use Cases:
usePrevious(value): Track previous value for change detectionuseInterval(callback, delay): Manage intervals with auto-cleanupuseDebounce(value, delay): Delay updates using refs for timinguseEventListener(event, handler, element): Add/remove listeners safelyuseIntersectionObserver(ref, options): Detect element visibilityuseMeasure(): Measure element dimensions with ResizeObserver
Key Implementation Principles:
Ref for Mutable Values: Store timer IDs, WebSocket connections, or any value that changes but doesn't affect rendering. Mutating ref.current doesn't schedule re-renders.
Ref Callbacks for Latest Values: When callbacks depend on props/state, store them in refs to avoid stale closures in effects with empty dependencies.
Cleanup Flags: Use boolean refs to track if component is mounted, preventing state updates after unmount in async operations.
Combination with State: Often combine refs (for internal tracking) with state (for UI updates). Refs handle behind-the-scenes logic while state drives visual changes.
// Pattern: Latest callback without changing effect dependencies
function useLatestCallback(callback) {
const callbackRef = useRef(callback);
useLayoutEffect(() => {
callbackRef.current = callback;
});
return useCallback((...args) => callbackRef.current(...args), []);
}
// Usage: Interval with dynamic callback
function useInterval(callback, delay) {
const savedCallback = useLatestCallback(callback);
useEffect(() => {
if (delay === null) return;
const id = setInterval(savedCallback, delay);
return () => clearInterval(id);
}, [delay, savedCallback]);
}
The power of custom hooks with refs lies in encapsulating complex ref-based patterns into reusable, testable units that hide implementation details from consumers.
π Deep Dive: Advanced Custom Hook Patterns with useRefβ
Understanding how professional React libraries implement custom hooks reveals advanced patterns for performance, correctness, and developer experience.
Pattern 1: usePrevious - Comparing Across Rendersβ
The usePrevious hook tracks previous values for change detection:
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
// Usage: Detect prop changes
function UserProfile({ userId }) {
const previousUserId = usePrevious(userId);
const [user, setUser] = useState(null);
useEffect(() => {
if (userId !== previousUserId) {
console.log(`User changed from ${previousUserId} to ${userId}`);
fetchUser(userId).then(setUser);
}
}, [userId, previousUserId]);
return <div>{user?.name}</div>;
}
Why useEffect not useLayoutEffect? Using useEffect means ref.current holds the value from the previous render during the current render, which is exactly what we want. useLayoutEffect would update the ref before effects run, defeating the purpose.
Internal Execution Flow:
Render 1 (userId=1):
ββ usePrevious(1) called
β ββ ref.current = undefined (not set yet)
ββ Component renders, previousUserId = undefined
ββ useEffect runs after paint
β ββ ref.current = 1 (saved for next render)
Render 2 (userId=2):
ββ usePrevious(2) called
β ββ ref.current = 1 (from previous render) β
ββ Component renders, previousUserId = 1
ββ useEffect runs
β ββ ref.current = 2 (saved for next render)
Advanced Variant with Comparator:
function usePrevious(value, compare = Object.is) {
const ref = useRef({ value, prev: undefined });
const current = ref.current.value;
if (!compare(current, value)) {
ref.current = {
value,
prev: current
};
}
return ref.current.prev;
}
// Usage with deep comparison
function DataTable({ data }) {
const prevData = usePrevious(data, isEqual); // lodash isEqual
useEffect(() => {
if (prevData && !isEqual(data, prevData)) {
console.log('Data changed:', { old: prevData, new: data });
}
}, [data, prevData]);
}
Pattern 2: useLatestRef - Escaping Stale Closuresβ
Critical for effects and callbacks that reference changing values:
function useLatestRef(value) {
const ref = useRef(value);
useLayoutEffect(() => {
ref.current = value;
});
return ref;
}
// Usage: Event handler with latest state
function ChatComponent({ onMessage }) {
const [messages, setMessages] = useState([]);
const messagesRef = useLatestRef(messages);
useEffect(() => {
const ws = new WebSocket('wss://chat.example.com');
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
// Always has latest messages, no stale closure!
const currentMessages = messagesRef.current;
onMessage(message, currentMessages);
setMessages([...currentMessages, message]);
};
return () => ws.close();
}, []); // Empty deps - no stale closures!
return <MessageList messages={messages} />;
}
Why useLayoutEffect? Synchronous update before browser paint ensures ref is always current before any effects or event handlers run.
Performance Characteristics:
useLayoutEffectblocks painting (~0.1ms overhead)- Prevents one re-render compared to
useEffectapproach - Critical for refs used in layout measurements or animations
Pattern 3: useTimeout/useInterval - Declarative Timersβ
Managing timers safely requires coordinating refs, state, and cleanup:
function useTimeout(callback, delay) {
const savedCallback = useLatestRef(callback);
useEffect(() => {
if (delay === null || delay === undefined) return;
const id = setTimeout(() => savedCallback.current(), delay);
return () => clearTimeout(id);
}, [delay]);
}
function useInterval(callback, delay) {
const savedCallback = useLatestRef(callback);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
// Usage: Auto-save with dynamic delay
function AutosaveEditor({ content, saveDelay = 2000 }) {
const [isDirty, setIsDirty] = useState(false);
useInterval(() => {
if (isDirty) {
saveContent(content);
setIsDirty(false);
}
}, isDirty ? saveDelay : null); // null pauses interval
return (
<textarea
value={content}
onChange={() => setIsDirty(true)}
/>
);
}
Advanced Variant with Pause/Resume:
function useControllableInterval(callback, delay, options = {}) {
const { immediate = false } = options;
const savedCallback = useLatestRef(callback);
const intervalRef = useRef(null);
const [isRunning, setIsRunning] = useState(immediate);
const start = useCallback(() => {
if (intervalRef.current) return; // Already running
savedCallback.current(); // Immediate first call
intervalRef.current = setInterval(() => {
savedCallback.current();
}, delay);
setIsRunning(true);
}, [delay]);
const stop = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setIsRunning(false);
}, []);
const reset = useCallback(() => {
stop();
start();
}, [start, stop]);
useEffect(() => {
if (immediate) start();
return stop;
}, [immediate, start, stop]);
return { start, stop, reset, isRunning };
}
// Usage: Controllable polling
function LiveMetrics() {
const [metrics, setMetrics] = useState(null);
const { start, stop, isRunning } = useControllableInterval(
async () => {
const data = await fetchMetrics();
setMetrics(data);
},
5000,
{ immediate: true }
);
return (
<div>
<button onClick={isRunning ? stop : start}>
{isRunning ? 'Pause' : 'Resume'} Updates
</button>
<MetricsDisplay data={metrics} />
</div>
);
}
Pattern 4: useEventListener - Safe Event Handlingβ
Encapsulates add/remove listener logic with proper cleanup:
function useEventListener(
eventName,
handler,
element = window,
options = {}
) {
const savedHandler = useLatestRef(handler);
useEffect(() => {
const targetElement = element?.current ?? element;
if (!(targetElement && targetElement.addEventListener)) return;
const eventListener = (event) => savedHandler.current(event);
targetElement.addEventListener(eventName, eventListener, options);
return () => {
targetElement.removeEventListener(eventName, eventListener, options);
};
}, [eventName, element, JSON.stringify(options)]);
}
// Usage: Window resize handler
function ResponsiveComponent() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEventListener('resize', () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
});
return <div>Window: {windowSize.width} x {windowSize.height}</div>;
}
// Usage: Click outside detection
function useClickOutside(ref, handler) {
useEventListener('mousedown', (event) => {
if (ref.current && !ref.current.contains(event.target)) {
handler(event);
}
}, document);
}
function Dropdown() {
const dropdownRef = useRef(null);
const [isOpen, setIsOpen] = useState(false);
useClickOutside(dropdownRef, () => setIsOpen(false));
return (
<div ref={dropdownRef}>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && <DropdownMenu />}
</div>
);
}
Pattern 5: useMeasure - DOM Dimension Trackingβ
Uses ResizeObserver with refs for efficient measurement:
function useMeasure() {
const [bounds, setBounds] = useState({
left: 0,
top: 0,
width: 0,
height: 0,
bottom: 0,
right: 0,
x: 0,
y: 0
});
const ref = useRef(null);
const observerRef = useRef(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
observerRef.current = new ResizeObserver(([entry]) => {
setBounds(entry.contentRect);
});
observerRef.current.observe(element);
return () => {
observerRef.current?.disconnect();
};
}, []);
return [ref, bounds];
}
// Usage: Responsive canvas
function ResponsiveCanvas() {
const [ref, { width, height }] = useMeasure();
useEffect(() => {
if (width && height) {
drawCanvas(width, height);
}
}, [width, height]);
return <canvas ref={ref} width={width} height={height} />;
}
Advanced Variant with Debouncing:
function useMeasureDebounced(delay = 100) {
const [bounds, setBounds] = useState({});
const [ref, rawBounds] = useMeasure();
const debouncedBounds = useDebounce(rawBounds, delay);
useEffect(() => {
setBounds(debouncedBounds);
}, [debouncedBounds]);
return [ref, bounds];
}
Pattern 6: useIsMounted - Prevent State Updates After Unmountβ
Critical for async operations:
function useIsMounted() {
const isMountedRef = useRef(true);
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
return useCallback(() => isMountedRef.current, []);
}
// Usage: Safe async updates
function DataFetcher({ url }) {
const [data, setData] = useState(null);
const isMounted = useIsMounted();
useEffect(() => {
let cancelled = false;
async function fetchData() {
const response = await fetch(url);
const json = await response.json();
// Double protection against state updates
if (!cancelled && isMounted()) {
setData(json);
}
}
fetchData();
return () => {
cancelled = true;
};
}, [url, isMounted]);
return <div>{data?.title}</div>;
}
Performance Optimization Patternsβ
Ref-based Memoization:
function useStableMemo(factory, deps) {
const ref = useRef({ value: undefined, deps: undefined });
if (!ref.current.deps || !shallowEqual(deps, ref.current.deps)) {
ref.current = {
value: factory(),
deps
};
}
return ref.current.value;
}
// More control than useMemo (doesn't rely on React's cache)
function ExpensiveComponent({ data }) {
const processed = useStableMemo(
() => expensiveProcessing(data),
[data]
);
return <div>{processed}</div>;
}
These patterns form the foundation of production-grade custom hooks, enabling developers to build robust, performant React applications with clean, reusable logic.
π Real-World Scenario: Memory Leak in useInterval Hookβ
Context: Task management app with auto-refresh functionality. Users reported browser tabs crashing after leaving the app open for extended periods. Memory profiling revealed a severe leak in the interval management system.
The Buggy Implementationβ
// β CATASTROPHIC MEMORY LEAK
function useInterval(callback, delay) {
useEffect(() => {
const id = setInterval(callback, delay);
return () => clearInterval(id);
}, [callback, delay]);
}
function TaskDashboard() {
const [tasks, setTasks] = useState([]);
const [filter, setFilter] = useState('all');
// Callback recreated on EVERY render
useInterval(() => {
fetchTasks(filter).then(setTasks);
}, 5000);
return (
<div>
<FilterButtons filter={filter} onChange={setFilter} />
<TaskList tasks={tasks} />
</div>
);
}
Impact Metrics (2 weeks in production)β
Memory Leaks:
- Baseline memory: 45MB
- After 1 hour: 380MB (+844%)
- After 4 hours: 1.2GB (tab crash on most systems)
- Leaked intervals per hour: 720 intervals (one every 5 seconds)
User Impact:
- Browser crashes: 1,847 incidents
- User complaints: 432 support tickets
- Session duration before crash: Average 3.2 hours
- Users affected: 15,234 (23% of active users)
- Churn rate during bug period: +18%
Business Impact:
- Lost productivity: ~12,000 hours
- Support costs: $8,900 (extra tickets)
- Reputation damage: -0.8 point drop in App Store rating
Root Cause Analysisβ
Problem 1: Closure Capture
useEffect(() => {
const id = setInterval(callback, delay);
return () => clearInterval(id);
}, [callback, delay]);
- Every time
callbackchanges, effect re-runs - New interval started BEFORE old one cleaned up
- In React 17, cleanup runs after new effect starts (race condition)
Problem 2: Callback Recreated Every Render
useInterval(() => {
fetchTasks(filter).then(setTasks);
}, 5000);
- Anonymous function recreated on every render
filtercaptured in closure, changingfilterchanges callback- User changing filter 10 times = 10 intervals running simultaneously
Problem 3: React 17 Effect Timing
Component re-renders (filter changes):
ββ Old effect cleanup scheduled (but NOT run yet)
ββ New effect runs β creates interval #2
ββ Old cleanup finally runs β clears interval #1
ββ Both intervals now running!
Debugging Processβ
Step 1: Chrome DevTools Memory Profiler (15 minutes)
// Add tracking to measure leak
const activeIntervals = new Set();
function useInterval(callback, delay) {
useEffect(() => {
const id = setInterval(callback, delay);
activeIntervals.add(id);
console.log('[useInterval] Active intervals:', activeIntervals.size);
return () => {
clearInterval(id);
activeIntervals.delete(id);
console.log('[useInterval] Cleaned up, remaining:', activeIntervals.size);
};
}, [callback, delay]);
}
Console output revealed the smoking gun:
[useInterval] Active intervals: 1
[useInterval] Active intervals: 2
[useInterval] Cleaned up, remaining: 1 β Should be 0!
[useInterval] Active intervals: 2
[useInterval] Cleaned up, remaining: 1
[useInterval] Active intervals: 2
...pattern continues forever
Step 2: React DevTools Profiler (10 minutes)
Profiler showed component re-rendering every time filter changed, and the callback dependency was causing effect re-runs.
Step 3: Heap Snapshot Comparison (20 minutes)
- Take heap snapshot at start
- Wait 10 minutes
- Take second snapshot
- Compare: Found 120 orphaned
setIntervaltimers
The Fix: Ref-Based Stable Callbackβ
// β
FIXED: Using ref to maintain latest callback
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
// Update ref when callback changes (doesn't trigger effect re-run)
useLayoutEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
if (delay === null) return;
// Interval calls latest callback via ref
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]); // Only re-run if delay changes
}
function TaskDashboard() {
const [tasks, setTasks] = useState([]);
const [filter, setFilter] = useState('all');
// Callback can change freely without recreating interval
useInterval(() => {
fetchTasks(filter).then(setTasks);
}, 5000);
return (
<div>
<FilterButtons filter={filter} onChange={setFilter} />
<TaskList tasks={tasks} />
</div>
);
}
How the Fix Worksβ
Execution Flow:
Initial Render:
ββ useInterval called with callback #1
ββ savedCallback.current = callback #1 (useLayoutEffect)
ββ setInterval(() => savedCallback.current(), 5000) (useEffect)
ββ Interval ID: 123
Filter Changes (re-render):
ββ useInterval called with callback #2
ββ savedCallback.current = callback #2 (useLayoutEffect updates ref)
ββ useEffect does NOT re-run (delay unchanged)
ββ Interval 123 continues running, but now calls callback #2 via ref
Key Insights:
- Ref stores callback:
savedCallback.currentalways points to latest callback - Effect only depends on delay: Changing callback doesn't recreate interval
- useLayoutEffect for sync update: Ensures ref updated before next interval fires
- One interval for lifetime: Interval created once, reused forever (unless delay changes)
Production-Ready Implementationβ
// β
ENTERPRISE-GRADE: With pause, resume, reset
function useInterval(callback, delay, options = {}) {
const { immediate = false, enabled = true } = options;
const savedCallback = useRef(callback);
const intervalRef = useRef(null);
const [isActive, setIsActive] = useState(enabled && delay !== null);
// Keep callback fresh
useLayoutEffect(() => {
savedCallback.current = callback;
});
const clear = useCallback(() => {
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
setIsActive(false);
}
}, []);
const start = useCallback(() => {
if (delay === null || !enabled) return;
clear(); // Clear existing first
if (immediate) {
savedCallback.current();
}
intervalRef.current = setInterval(() => {
savedCallback.current();
}, delay);
setIsActive(true);
}, [delay, immediate, enabled, clear]);
const reset = useCallback(() => {
clear();
start();
}, [clear, start]);
useEffect(() => {
if (enabled && delay !== null) {
start();
} else {
clear();
}
return clear;
}, [delay, enabled, start, clear]);
return { clear, reset, isActive };
}
// Usage: With controls
function AutoRefreshDashboard() {
const [data, setData] = useState(null);
const [isPaused, setIsPaused] = useState(false);
const [refreshRate, setRefreshRate] = useState(5000);
const { clear, reset, isActive } = useInterval(
async () => {
const newData = await fetchData();
setData(newData);
},
refreshRate,
{ enabled: !isPaused, immediate: true }
);
return (
<div>
<Controls
onPause={() => setIsPaused(true)}
onResume={() => setIsPaused(false)}
onRefreshNow={reset}
refreshRate={refreshRate}
onRateChange={setRefreshRate}
/>
<StatusIndicator active={isActive} rate={refreshRate} />
<DataDisplay data={data} />
</div>
);
}
Post-Fix Metrics (2 weeks monitoring)β
Memory Performance:
- Baseline memory: 45MB β
- After 1 hour: 52MB (+15%, normal growth) β
- After 4 hours: 58MB (+28%, stable) β
- After 24 hours: 67MB (no crash!) β
- Leaked intervals: 0 β
User Experience:
- Browser crashes: 1,847 β 0 (100% elimination) β
- User complaints: 432 β 7 (98% reduction) β
- Average session duration: 6.8 hours (no crashes observed) β
- Users affected: 0 β
- Churn rate: Recovered +11% β
Business Recovery:
- Support tickets: -96%
- App Store rating: Recovered +0.9 points
- User trust: Regained within 3 weeks
- Cost savings: $8,400/month (reduced support load)
Key Learningsβ
- Never depend on callback in useEffect: Use ref to store latest callback
- Use useLayoutEffect for ref updates: Ensures sync update before effects run
- Monitor interval count in development: Add logging to detect leaks early
- Test with React StrictMode: Catches effect cleanup issues
- Use Chrome DevTools Memory Profiler: Essential for debugging leaks
- Provide pause/resume controls: Users should control polling
- Implement cleanup on unmount: Always clear intervals in effect cleanup
- Test long-running sessions: Automated tests rarely catch slow leaks
This bug pattern is extremely common in React applications and understanding it is critical for any developer working with intervals, timeouts, or subscriptions.
<details> <summary><strong>βοΈ Trade-offs: Custom Hook Design Decisions with Refs</strong></summary>
Building custom hooks involves architectural choices that impact API ergonomics, performance, and maintainability. Understanding trade-offs helps make informed decisions.
Trade-off 1: Ref in Hook vs. Ref from Consumerβ
Pattern A: Hook Manages Ref Internally
// β
Hook creates and returns ref
function useClickOutside(handler) {
const ref = useRef(null);
useEffect(() => {
const handleClick = (e) => {
if (ref.current && !ref.current.contains(e.target)) {
handler(e);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [handler]);
return ref; // Consumer attaches this
}
// Usage
function Dropdown() {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useClickOutside(() => setIsOpen(false));
return <div ref={dropdownRef}>{/* ... */}</div>;
}
Pros:
- β Simpler API (one line to use)
- β Hook controls ref lifecycle
- β Less boilerplate for consumer
Cons:
- β Consumer can't use existing ref
- β Difficult to compose with other ref-using hooks
- β Can't work with multiple elements
Pattern B: Consumer Provides Ref
// β
Hook accepts ref as parameter
function useClickOutside(ref, handler) {
useEffect(() => {
const handleClick = (e) => {
if (ref.current && !ref.current.contains(e.target)) {
handler(e);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [ref, handler]);
}
// Usage
function Dropdown() {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
useClickOutside(dropdownRef, () => setIsOpen(false));
return <div ref={dropdownRef}>{/* ... */}</div>;
}
Pros:
- β Consumer controls ref
- β Easy to compose multiple hooks using same ref
- β Works with forwarded refs
- β Can use with multiple elements
Cons:
- β More verbose (consumer creates ref)
- β Easier to misuse (forgetting to create ref)
Decision Guide:
- Hook creates ref: Single-purpose hooks where composition isn't needed (
useHover,useFocus) - Consumer provides ref: General-purpose hooks that might compose with others (
useClickOutside,useIntersectionObserver)
Best of Both Worlds:
// β
Hybrid: Optional ref parameter
function useClickOutside(handler, providedRef = null) {
const internalRef = useRef(null);
const ref = providedRef || internalRef;
useEffect(() => {
const handleClick = (e) => {
if (ref.current && !ref.current.contains(e.target)) {
handler(e);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [ref, handler]);
return ref;
}
// Usage 1: Hook provides ref
const ref = useClickOutside(handleClose);
// Usage 2: Consumer provides ref
const myRef = useRef();
useClickOutside(handleClose, myRef);
Trade-off 2: useLayoutEffect vs. useEffect for Ref Updatesβ
useLayoutEffect - Synchronous Before Paint:
function useLatestRef(value) {
const ref = useRef(value);
useLayoutEffect(() => {
ref.current = value;
});
return ref;
}
Pros:
- β Ref updated before DOM paint
- β Prevents one frame of stale data
- β Critical for layout measurements
- β Necessary for animations
Cons:
- β Blocks browser paint (~0.1-0.5ms)
- β Can cause jank if doing heavy work
- β SSR warning (no layout phase on server)
useEffect - Asynchronous After Paint:
function useLatestRef(value) {
const ref = useRef(value);
useEffect(() => {
ref.current = value;
});
return ref;
}
Pros:
- β Doesn't block paint
- β Better performance for non-critical updates
- β No SSR warnings
- β Safer default choice
Cons:
- β One frame delay before ref updated
- β Can cause visual glitches in animations
- β Wrong for DOM measurements
Decision Matrix:
| Use Case | useLayoutEffect | useEffect |
|---|---|---|
| Storing latest callback | β Preferred | β οΈ Usually fine |
| DOM measurements | β Required | β Wrong values |
| Animation frame callbacks | β Required | β Frame delay |
| Event listeners | β οΈ Overkill | β Preferred |
| Non-visual tracking | β Overkill | β Preferred |
| SSR compatibility | β Warns | β Safe |
Rule of Thumb: Use useLayoutEffect when ref correctness before paint matters (DOM measurements, animations). Use useEffect for everything else (callbacks, tracking, cleanup).
Trade-off 3: Single-Value Ref vs. Object Refβ
Single-Value Pattern:
function useInterval(callback, delay) {
const callbackRef = useRef(callback);
const intervalRef = useRef(null);
// Two separate refs
useLayoutEffect(() => {
callbackRef.current = callback;
});
useEffect(() => {
intervalRef.current = setInterval(() => {
callbackRef.current();
}, delay);
return () => clearInterval(intervalRef.current);
}, [delay]);
}
Pros:
- β Clear single responsibility
- β Easy to understand
- β Simple to test
Cons:
- β Multiple refs for related data
- β No atomic updates
- β More memory overhead (2+ ref objects)
Object Pattern:
function useInterval(callback, delay) {
const ref = useRef({
callback,
intervalId: null,
delay
});
useLayoutEffect(() => {
ref.current.callback = callback;
ref.current.delay = delay;
});
useEffect(() => {
ref.current.intervalId = setInterval(() => {
ref.current.callback();
}, ref.current.delay);
return () => clearInterval(ref.current.intervalId);
}, [delay]);
}
Pros:
- β Related data grouped
- β Single ref object (less memory)
- β Atomic-ish updates (single object mutation)
Cons:
- β More complex to understand
- β Harder to track what changed
- β Debugging shows entire object
Decision Guide:
- Single-value refs: Independent values (
callbackRef,elementRef) - Object ref: Tightly coupled state (
{ isLoading, data, error })
Performance Comparison:
// Memory: 100 components with interval hook
// Single-value (2 refs each): ~6.4KB
// Object ref (1 ref each): ~3.2KB
// Difference: Negligible for most apps
Trade-off 4: Exposing Imperative Handlesβ
Pattern A: No Imperative API
function useInterval(callback, delay) {
// Hook handles everything internally
// Consumer has no control
const savedCallback = useRef(callback);
useLayoutEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
// Returns nothing
}
// Usage: Declarative only
function Component() {
const [enabled, setEnabled] = useState(true);
const [delay, setDelay] = useState(1000);
useInterval(
() => console.log('Tick'),
enabled ? delay : null
);
// To pause: Set delay to null
// To change rate: Update delay
}
Pros:
- β Fully declarative
- β No manual management
- β Can't be misused
- β Simpler mental model
Cons:
- β No imperative control
- β Can't manually trigger
- β Can't query state
- β Less flexible
Pattern B: Imperative API Exposed
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
const intervalRef = useRef(null);
const [isActive, setIsActive] = useState(false);
useLayoutEffect(() => {
savedCallback.current = callback;
});
const start = useCallback(() => {
if (intervalRef.current) return;
intervalRef.current = setInterval(() => {
savedCallback.current();
}, delay);
setIsActive(true);
}, [delay]);
const stop = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
setIsActive(false);
}
}, []);
const reset = useCallback(() => {
stop();
start();
}, [start, stop]);
return { start, stop, reset, isActive };
}
// Usage: Imperative control
function Component() {
const { start, stop, reset, isActive } = useInterval(
() => console.log('Tick'),
1000
);
return (
<div>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
<button onClick={reset}>Reset</button>
<div>Status: {isActive ? 'Running' : 'Stopped'}</div>
</div>
);
}
Pros:
- β Full control
- β Can manually trigger
- β Query current state
- β Maximum flexibility
Cons:
- β More complex API
- β Easier to misuse
- β Manual lifecycle management
- β Larger bundle size
Hybrid Approach:
function useInterval(callback, delay, options = {}) {
const { autoStart = true, mode = 'declarative' } = options;
// Internal implementation...
if (mode === 'imperative') {
return { start, stop, reset, isActive };
}
// Auto-start for declarative mode
useEffect(() => {
if (autoStart && delay !== null) {
start();
}
return stop;
}, [autoStart, delay, start, stop]);
return isActive;
}
// Declarative usage
const isActive = useInterval(tick, 1000);
// Imperative usage
const controls = useInterval(tick, 1000, { mode: 'imperative', autoStart: false });
controls.start();
Trade-off 5: Error Handling Strategiesβ
Pattern A: Silent Failure (Ref Nullability)
function useClickOutside(ref, handler) {
useEffect(() => {
const handleClick = (e) => {
// Silent: Just doesn't work if ref not attached
if (ref.current && !ref.current.contains(e.target)) {
handler(e);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [ref, handler]);
}
Pros:
- β No runtime errors
- β Graceful degradation
- β Works with conditional rendering
Cons:
- β Silent failures hard to debug
- β Hook "does nothing" without clear reason
- β No developer feedback
Pattern B: Defensive Validation
function useClickOutside(ref, handler) {
useEffect(() => {
if (!ref || !ref.current) {
console.warn('useClickOutside: ref is not attached to an element');
return;
}
const handleClick = (e) => {
if (!ref.current.contains(e.target)) {
handler(e);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [ref, handler]);
}
Pros:
- β Clear developer feedback
- β Easier debugging
- β Prevents silent failures
Cons:
- β Console noise in development
- β Still doesn't fail hard
- β Warning fatigue
Pattern C: Strict Validation (Development Only)
function useClickOutside(ref, handler) {
if (process.env.NODE_ENV !== 'production') {
if (!ref) {
throw new Error('useClickOutside: ref is required');
}
}
useEffect(() => {
if (!ref.current) {
if (process.env.NODE_ENV !== 'production') {
console.error('useClickOutside: ref.current is null. Did you forget to attach the ref?');
}
return;
}
const handleClick = (e) => {
if (!ref.current.contains(e.target)) {
handler(e);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [ref, handler]);
}
Pros:
- β Strict in dev, forgiving in prod
- β Catches mistakes early
- β No production overhead
Cons:
- β Different behavior dev vs. prod
- β Might miss edge cases in production
Decision Guide:
- Silent failure: Utility hooks used optionally
- Defensive validation: Critical hooks (accessibility, security)
- Strict validation: Library hooks with clear contracts
Understanding these trade-offs enables designing custom hooks that balance API ergonomics, performance, safety, and developer experience for your specific use case.
π¬ Explain to Junior: Building Custom Hooks with useRefβ
Simple Analogy:
Think of building custom hooks like creating specialized power tools. React gives you basic tools (useState, useEffect, useRef), and you combine them into specialized tools for specific jobs.
useRef is like having a toolbox with secret compartments - you can store things (timer IDs, DOM elements, latest callbacks) without anyone noticing. The component doesn't re-render when you put stuff in or take stuff out of these compartments.
Why Combine useRef with Custom Hooks?
Imagine you're building a useInterval hook. Without refs, you hit problems:
// β BROKEN: Callback changes cause interval to restart
function useInterval(callback, delay) {
useEffect(() => {
const id = setInterval(callback, delay);
return () => clearInterval(id);
}, [callback, delay]); // callback changes = new interval!
}
function Component() {
const [count, setCount] = useState(0);
// Every render, this function is NEW (different reference)
useInterval(() => {
setCount(c => c + 1);
}, 1000);
// Problem: Interval restarts on EVERY render!
// Expected: Tick once per second
// Reality: Thousands of intervals created!
}
The Fix: Use Ref to Store Callback
// β
WORKING: Ref stores latest callback, interval stable
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
// Update ref when callback changes (no re-render)
useLayoutEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
const id = setInterval(() => {
savedCallback.current(); // Call latest callback
}, delay);
return () => clearInterval(id);
}, [delay]); // Only recreate if delay changes
}
// Now it works perfectly!
function Component() {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(c => c + 1); // Callback can change freely
}, 1000);
// Interval created once, runs forever
// Callback updated via ref without restarting interval
}
How It Works - Step by Step:
Initial Render:
1. useInterval called with callback #1
2. savedCallback.current = callback #1
3. setInterval created (calls savedCallback.current)
4. Interval ID: 123 (will run forever)
Component Re-renders (count changes):
1. useInterval called with callback #2
2. savedCallback.current = callback #2 (ref updated)
3. useEffect does NOT run (delay unchanged)
4. Interval 123 still running, but NOW calls callback #2
Magic: Interval never restarts, but always calls latest callback!
Common Patterns for Juniors
Pattern 1: usePrevious - Compare Old vs New
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value; // Save for next render
});
return ref.current; // Return previous value
}
// Usage: Detect changes
function UserProfile({ userId }) {
const previousUserId = usePrevious(userId);
useEffect(() => {
if (userId !== previousUserId) {
console.log(`User changed from ${previousUserId} to ${userId}`);
loadNewUser(userId);
}
}, [userId, previousUserId]);
}
Real-world analogy: Like looking at your old ID photo to see how much you've changed. The ref keeps the "old photo" while your state has the "new photo".
Pattern 2: useTimeout - Auto-Cleanup Timer
function useTimeout(callback, delay) {
const savedCallback = useRef(callback);
useLayoutEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
if (delay === null) return;
const id = setTimeout(() => savedCallback.current(), delay);
return () => clearTimeout(id); // Auto-cleanup!
}, [delay]);
}
// Usage: Show notification for 3 seconds
function Notification({ message }) {
const [visible, setVisible] = useState(true);
useTimeout(() => {
setVisible(false);
}, 3000);
if (!visible) return null;
return <div>{message}</div>;
}
Why this is better than raw setTimeout:
- Auto-cleanup when component unmounts
- Won't update state after unmount (memory leak prevention)
- Latest callback always called
Pattern 3: useClickOutside - Detect Clicks Outside Element
function useClickOutside(handler) {
const ref = useRef(null);
useEffect(() => {
const handleClick = (e) => {
// If click is outside our element
if (ref.current && !ref.current.contains(e.target)) {
handler();
}
};
document.addEventListener('mousedown', handleClick);
return () => {
document.removeEventListener('mousedown', handleClick);
};
}, [handler]);
return ref;
}
// Usage: Close dropdown when clicking outside
function Dropdown() {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useClickOutside(() => setIsOpen(false));
return (
<div ref={dropdownRef}>
<button onClick={() => setIsOpen(!isOpen)}>
Toggle
</button>
{isOpen && <DropdownMenu />}
</div>
);
}
Real-world analogy: Like a motion sensor for a building - it detects when activity happens outside the building's boundary.
Interview Question Templates
Q: "Why use useRef in custom hooks instead of useState?"
Answer Template:
"I use useRef in custom hooks when I need to store values that don't affect rendering. For example, in useInterval, I store the latest callback in a ref because:
- The callback might change every render (new function reference)
- Changing the callback shouldn't restart the interval
- The interval needs to call the LATEST callback
With useState, every callback change would cause a re-render and restart the interval. With useRef, I can update the callback silently. The interval keeps running, but calls the updated callback via the ref.
Rule of thumb: State for values that affect UI, refs for values that don't."
Q: "What's the purpose of useLayoutEffect in ref-based hooks?"
Answer Template:
"I use useLayoutEffect when updating refs that will be read synchronously before the next paint. For example, when storing the latest callback in useInterval, I want the ref updated before any effects or timers fire.
useLayoutEffect runs synchronously after render but before browser paint, ensuring the ref is current. If I used useEffect, there'd be one frame where the ref is stale, which could cause the interval to call an old callback.
However, useLayoutEffect blocks painting, so I only use it when timing matters. For non-critical ref updates, useEffect is fine and more performant."
Q: "How do you prevent memory leaks in custom hooks with refs?"
Answer Template:
"Memory leaks happen when timers, subscriptions, or event listeners aren't cleaned up. In custom hooks, I prevent leaks by:
- Always return cleanup functions from useEffect
- Store timer/subscription IDs in refs (not state, to avoid re-renders)
- Use a 'cancelled' flag to prevent state updates after unmount
- Clear refs in cleanup (set to null to help garbage collection)
For example, in useInterval, I store the interval ID in a ref and clear it in the cleanup function. If the component unmounts, the cleanup runs and clears the interval, preventing it from running forever."
Common Mistakes to Avoid
// β MISTAKE 1: Not cleaning up intervals
function useInterval(callback, delay) {
useEffect(() => {
const id = setInterval(callback, delay);
// Missing return statement! Interval never cleared!
}, [callback, delay]);
}
// β
FIX: Always return cleanup
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
useLayoutEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id); // β
Cleanup!
}, [delay]);
}
// β MISTAKE 2: Depending on callback directly
function useInterval(callback, delay) {
useEffect(() => {
const id = setInterval(callback, delay);
return () => clearInterval(id);
}, [callback, delay]); // β callback changes = new interval!
}
// β
FIX: Store callback in ref
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
useLayoutEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]); // β
Only depends on delay
}
// β MISTAKE 3: Mutating refs during render
function useCounter() {
const renderCount = useRef(0);
renderCount.current++; // β Side effect during render!
return renderCount.current;
}
// β
FIX: Mutate refs in effects
function useCounter() {
const renderCount = useRef(0);
useEffect(() => {
renderCount.current++; // β
Safe in effect
});
return renderCount.current;
}
Mental Model Summary
Custom Hook Design Checklist:
ββ Does it need to remember something across renders?
β ββ Affects UI? β useState
β ββ Doesn't affect UI? β useRef
β
ββ Does it set up timers/listeners?
β ββ Store ID in ref
β ββ Clear in cleanup function
β ββ Use latest callback pattern
β
ββ Does it interact with DOM?
β ββ Return ref for consumer to attach
β ββ Access DOM in useEffect (after render)
β
ββ Does callback capture props/state?
ββ Store callback in ref
ββ Update ref with useLayoutEffect
ββ Only depend on stable values in effect
Understanding these patterns prepares you to build production-quality custom hooks that are performant, correct, and maintainable.
Question 2: How do you compose multiple custom hooks using refs for complex state management?β
Difficulty: π΄ Hard Frequency: βββ Time: 10-12 minutes Companies: Meta, Google, Uber, Airbnb, Netflix
Questionβ
Explain advanced patterns for composing custom hooks that use refs together. How do you handle hook interdependencies, shared refs, and coordination between multiple ref-based hooks? What are the architectural considerations?
Answerβ
Hook composition with refs enables building sophisticated behaviors from simple, reusable pieces. The key is managing dependencies and ref lifecycles across multiple hooks while maintaining clear contracts and avoiding subtle bugs.
Composition Patterns:
- Sequential Composition: One hook's output feeds another's input
- Parallel Composition: Multiple independent hooks working together
- Hierarchical Composition: Parent hook delegates to child hooks
- Shared Ref Pattern: Multiple hooks operate on same DOM element
- Coordination Pattern: Hooks communicate through shared state/refs
Core Principles:
Ref Ownership: Establish clear ownership - one hook creates the ref, others consume it. Prevents conflicts and makes cleanup responsibility clear.
Dependency Ordering: When hooks depend on each other, ensure proper execution order through careful dependency array management and execution flow.
Lifecycle Coordination: When multiple hooks manage timers/subscriptions, coordinate cleanup to prevent race conditions and ensure resources freed in correct order.
Type Safety: With TypeScript, use generics to ensure ref types match across hook composition boundaries.
Example: Composing Animation Hooks
// Base hooks
function useAnimationFrame(callback) {
const savedCallback = useRef(callback);
const frameRef = useRef(null);
useLayoutEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
function tick() {
savedCallback.current();
frameRef.current = requestAnimationFrame(tick);
}
frameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameRef.current);
}, []);
}
function useBoundedValue(initialValue, min, max) {
const [value, setValue] = useState(initialValue);
const setBounded = useCallback((newValue) => {
setValue(Math.max(min, Math.min(max, newValue)));
}, [min, max]);
return [value, setBounded];
}
// Composed hook
function useAnimatedCounter(target, duration = 1000) {
const [current, setCurrent] = useBoundedValue(0, 0, target);
const startTimeRef = useRef(null);
const isAnimating = useRef(false);
useAnimationFrame(() => {
if (!isAnimating.current) return;
const elapsed = Date.now() - startTimeRef.current;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3); // easeOut
const next = Math.floor(target * eased);
setCurrent(next);
if (progress >= 1) {
isAnimating.current = false;
}
});
useEffect(() => {
startTimeRef.current = Date.now();
isAnimating.current = true;
}, [target]);
return current;
}
Shared Ref Pattern:
function useHover(ref) {
const [isHovered, setIsHovered] = useState(false);
useEventListener('mouseenter', () => setIsHovered(true), ref);
useEventListener('mouseleave', () => setIsHovered(false), ref);
return isHovered;
}
function useFocus(ref) {
const [isFocused, setIsFocused] = useState(false);
useEventListener('focus', () => setIsFocused(true), ref);
useEventListener('blur', () => setIsFocused(false), ref);
return isFocused;
}
// Composing with shared ref
function InteractiveButton() {
const buttonRef = useRef(null);
const isHovered = useHover(buttonRef);
const isFocused = useFocus(buttonRef);
const className = [
isHovered && 'hovered',
isFocused && 'focused'
].filter(Boolean).join(' ');
return <button ref={buttonRef} className={className}>Click me</button>;
}
Successfully composing ref-based hooks requires understanding execution order, dependency management, and lifecycle coordination to build complex behaviors from simple, testable units.
</details>
π Deep Dive: Advanced Hook Composition Architectureβ
(Continue with 500-800 word deep dive section...)
[Content continues with detailed implementation examples, architectural patterns, performance considerations, and advanced composition techniques]
π Real-World Scenario: [Specific production bug]β
(Continue with 500-800 word real-world scenario...)
βοΈ Trade-offs: [Specific trade-off analysis]β
(Continue with 500-800 word trade-off analysis...)
π¬ Explain to Junior: [Junior-friendly explanation]β
(Continue with 500-800 word beginner-friendly section...)
Source: hooks-04-useRef-custom.md