Skip to main content

React Refs and DOM Access

Question 1: What are refs and when should you use them for DOM manipulation?​

Answer​

Refs (references) are React's escape hatch for directly accessing DOM nodes or React component instances. They allow you to imperatively interact with elements outside React's declarative paradigm.

Core Use Cases:

  1. Focus management - Programmatically focusing inputs, textareas
  2. Text selection - Selecting text content in form fields
  3. Triggering animations - Starting CSS/JS animations imperatively
  4. Integrating third-party DOM libraries - D3, jQuery, animation libraries
  5. Measuring elements - Getting dimensions, scroll position
  6. Media control - Playing/pausing video/audio elements

When to Use Refs:

  • When React's declarative approach is insufficient
  • When you need direct DOM measurements
  • For focus management after state updates
  • When integrating with imperative libraries
  • For managing media playback controls

When NOT to Use Refs:

  • Anything that can be done declaratively
  • Managing state (use useState instead)
  • Triggering re-renders (use state/props)
  • Passing data between components (use props/context)
  • Accessing child component data (use callbacks/lifting state)

Basic Syntax:

// Functional component with useRef
const inputRef = useRef(null);

// Attach to element
<input ref={inputRef} />

// Access DOM node
inputRef.current.focus();

Key Principle: Refs should be a last resort when declarative solutions don't work. React's philosophy is declarative - describe what the UI should look like, not how to manipulate it. Use refs sparingly and only when absolutely necessary.


πŸ” Deep Dive: Ref Implementation and DOM Access Mechanics​

Internal Implementation of useRef​

React's useRef hook is deceptively simple on the surface, but understanding its internal implementation reveals why it's so powerful for DOM access and mutable values. Unlike useState, which triggers re-renders when updated, useRef returns a stable object with a .current property that persists across renders without causing updates.

// Simplified React useRef implementation
function useRef(initialValue) {
// Create ref object once during initial render
const ref = {
current: initialValue
};

// Store in fiber's memoizedState
// Ref object identity remains stable across re-renders
return ref;
}

// Why .current? Mutable container pattern
// Changing .current doesn't trigger re-render
// React doesn't track ref mutations

Key characteristics:

  • Identity stability - Same object reference every render (critical for performance)
  • Mutation allowed - .current can change without re-render (breaks React's immutability)
  • Not reactive - Changing .current doesn't update UI (intentional design)
  • Preserved across renders - Value persists like instance variables (similar to class components)

Why the .current property? This design choice enables mutation without triggering React's reconciliation. When you write ref.current = newValue, you're mutating an object property, not replacing the ref object itself. React doesn't track property mutations, only state changes via setState. This is intentional - refs are an "escape hatch" from React's reactive system.

Memory implications: Each useRef call allocates a small object (~16-32 bytes) that persists for the component's lifetime. Unlike state, refs don't create fiber nodes or trigger reconciliation, making them extremely lightweight. In a component tree with 1,000 refs, total memory overhead is ~16-32KB, compared to hundreds of KB for equivalent state variables.

Fiber integration: React stores the ref object in the component's fiber node's memoizedState linked list. During the render phase, React checks if the ref already exists in the fiber. If it does, React returns the same object reference. If not (initial render), React creates a new ref object and stores it. This ensures referential stability across renders - Object.is(prevRef, nextRef) always returns true.

// React's fiber structure (simplified)
const fiber = {
memoizedState: {
baseState: null,
next: {
// useRef stores here
memoizedState: { current: null }, // The ref object
next: null
}
},
ref: null, // Separate field for element refs
stateNode: null // DOM node
};

Comparison with useState:

  • useState: Creates a state queue, tracks changes, triggers re-renders, immutable updates
  • useRef: Creates a mutable object, no change tracking, no re-renders, direct mutation

When to use useRef vs useState:

  • Use useRef for values that change but don't affect UI (timers, previous values, DOM nodes)
  • Use useState for values that should trigger re-renders when changed (UI state, form data)

Performance benchmark (1 million updates):

// useState: ~450ms (triggers 1M re-renders)
const [count, setCount] = useState(0);
for (let i = 0; i < 1_000_000; i++) {
setCount(i); // Each triggers reconciliation
}

// useRef: ~8ms (no re-renders)
const countRef = useRef(0);
for (let i = 0; i < 1_000_000; i++) {
countRef.current = i; // Direct mutation
}

This 56x performance difference highlights why refs are essential for high-frequency updates (scroll positions, animation frames, mouse coordinates) where re-rendering would be prohibitively expensive.

How React Attaches Refs to DOM Nodes​

Understanding React's ref attachment lifecycle is crucial for debugging timing issues. React attaches refs during the commit phase, not the render phase, which explains why ref.current is null during render but populated in useEffect.

// React's ref attachment process during commit phase

// 1. RENDER PHASE - React creates virtual DOM
function MyComponent() {
const divRef = useRef(null);
console.log('During render:', divRef.current); // null
return <div ref={divRef}>Content</div>;
}

// 2. COMMIT PHASE - React updates real DOM
// React calls ref callback or sets ref.current
function commitAttachRef(fiber) {
const ref = fiber.ref;
const instance = fiber.stateNode; // DOM node

if (typeof ref === 'function') {
// Callback ref
ref(instance);
} else if (ref !== null) {
// Object ref (from useRef)
ref.current = instance;
}
}

// 3. CLEANUP - Before unmount or ref change
function commitDetachRef(fiber) {
const ref = fiber.ref;

if (typeof ref === 'function') {
ref(null); // Cleanup callback
} else if (ref !== null) {
ref.current = null;
}
}

Lifecycle timeline (millisecond precision):

  1. t=0ms: Component renders, useRef returns { current: null }
  2. t=2ms: React finishes reconciliation (render phase complete)
  3. t=3ms: Commit phase begins, React creates/updates DOM nodes
  4. t=4ms: commitAttachRef runs, sets ref.current = <div> DOM node
  5. t=5ms: useLayoutEffect callbacks execute (ref available)
  6. t=6ms: Browser paints screen
  7. t=7ms: useEffect callbacks execute (ref available)

Why this matters: If you try to access ref.current during render or in an inline event handler attached during render, it will be null. You must wait for effects to run.

Callback refs vs Object refs:

Object refs (useRef):

  • Synchronous attachment during commit
  • No cleanup notifications
  • Simpler for most use cases
  • Performance: ~0.01ms per ref attachment

Callback refs:

  • Function called with DOM node on mount
  • Function called with null on unmount
  • Useful for cleanup (ResizeObserver, IntersectionObserver)
  • Performance: ~0.02ms per ref (function call overhead)
  • Can cause infinite loops if not memoized with useCallback

Common timing bug:

// ❌ BAD - ref not available yet
function Component() {
const divRef = useRef(null);

divRef.current?.focus(); // null! Doesn't work

return <div ref={divRef} tabIndex={-1} />;
}

// βœ… GOOD - wait for useLayoutEffect
function Component() {
const divRef = useRef(null);

useLayoutEffect(() => {
divRef.current?.focus(); // βœ… DOM node exists
}, []);

return <div ref={divRef} tabIndex={-1} />;
}

Advanced: Ref forwarding timing with forwardRef:

When using forwardRef, the ref attachment happens after all child refs are attached, ensuring bottom-up ref availability:

const Child = forwardRef((props, ref) => {
return <input ref={ref} />;
});

function Parent() {
const childRef = useRef(null);

useLayoutEffect(() => {
console.log(childRef.current); // <input> element βœ…
}, []);

return <Child ref={childRef} />;
}

Batching and ref updates:

React batches ref updates with DOM mutations, ensuring refs are always in sync with the DOM:

function Component() {
const [show, setShow] = useState(true);
const divRef = useRef(null);

useLayoutEffect(() => {
console.log(divRef.current); // Correct element or null
// Never stale - batched with DOM update
}, [show]);

return (
<div>
{show && <div ref={divRef}>Content</div>}
<button onClick={() => setShow(!show)}>Toggle</button>
</div>
);
}

Strict Mode behavior:

In React Strict Mode (development only), components render twice to detect side effects. Refs are still attached only once during commit, so you won't see double attachment:

// React Strict Mode (dev only)
function Component() {
const divRef = useRef(null);

console.log('Render'); // Logs twice in Strict Mode

useLayoutEffect(() => {
console.log('Ref attached'); // Logs ONCE
return () => console.log('Ref detached'); // Logs ONCE on unmount
}, []);

return <div ref={divRef}>Content</div>;
}

Performance optimization: React uses a single pass to attach all refs in a component tree, making ref attachment O(n) where n is the number of refs. This is highly efficient even with thousands of refs.

Callback Refs for Dynamic Measurements​

Callback refs are functions that receive the DOM node as an argument. They're called when the component mounts and unmounts.

// βœ… GOOD - Callback ref for dynamic measurement
function ResizableBox() {
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });

const measureRef = useCallback(node => {
if (node !== null) {
const { width, height } = node.getBoundingClientRect();
setDimensions({ width, height });

// Set up ResizeObserver
const observer = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect;
setDimensions({ width, height });
});

observer.observe(node);

// Cleanup function (called with null on unmount)
return () => observer.disconnect();
}
}, []);

return (
<div ref={measureRef}>
Size: {dimensions.width}x{dimensions.height}
</div>
);
}

// ❌ BAD - Object ref doesn't handle cleanup properly
function ResizableBoxBad() {
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const divRef = useRef(null);

useEffect(() => {
if (divRef.current) {
const observer = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect;
setDimensions({ width, height });
});

observer.observe(divRef.current);
return () => observer.disconnect();
}
}, []); // Missing dependency - might not reconnect if ref changes

return <div ref={divRef}>Size: {dimensions.width}x{dimensions.height}</div>;
}

Integrating with Third-Party Libraries​

// βœ… GOOD - D3 integration with proper lifecycle
import * as d3 from 'd3';

function D3Chart({ data }) {
const svgRef = useRef(null);

useEffect(() => {
if (!svgRef.current) return;

const svg = d3.select(svgRef.current);

// Clear previous content
svg.selectAll('*').remove();

// Create D3 visualization
const width = svgRef.current.clientWidth;
const height = svgRef.current.clientHeight;

const xScale = d3.scaleLinear()
.domain([0, data.length - 1])
.range([0, width]);

const yScale = d3.scaleLinear()
.domain([0, d3.max(data)])
.range([height, 0]);

const line = d3.line()
.x((d, i) => xScale(i))
.y(d => yScale(d));

svg.append('path')
.datum(data)
.attr('fill', 'none')
.attr('stroke', 'steelblue')
.attr('stroke-width', 2)
.attr('d', line);

}, [data]); // Re-render when data changes

return <svg ref={svgRef} width="100%" height="400px" />;
}

// ❌ BAD - Missing cleanup, memory leaks
function D3ChartBad({ data }) {
const svgRef = useRef(null);

useEffect(() => {
const svg = d3.select(svgRef.current);
// Appends new elements every render without clearing
// Creates memory leaks and duplicate elements
svg.append('path').datum(data).attr('d', line);
}, [data]);

return <svg ref={svgRef} />;
}

Multiple Refs on Same Element​

// βœ… GOOD - Combining multiple refs
function useCombinedRefs(...refs) {
return useCallback(
(element) => {
refs.forEach(ref => {
if (!ref) return;

if (typeof ref === 'function') {
ref(element);
} else {
ref.current = element;
}
});
},
[refs]
);
}

function Input({ forwardedRef }) {
const localRef = useRef(null);
const combinedRef = useCombinedRefs(localRef, forwardedRef);

useEffect(() => {
// Use local ref internally
console.log('Input width:', localRef.current?.offsetWidth);
}, []);

return <input ref={combinedRef} />;
}

Ref Timing and Lifecycle​

// Understanding when refs are available

function TimingDemo() {
const divRef = useRef(null);

// ❌ BAD - Ref is null during render
console.log('During render:', divRef.current); // null

useLayoutEffect(() => {
// βœ… GOOD - Ref available, but before paint
console.log('useLayoutEffect:', divRef.current); // <div>
// Best for DOM measurements that affect layout
const height = divRef.current.offsetHeight;
});

useEffect(() => {
// βœ… GOOD - Ref available, after paint
console.log('useEffect:', divRef.current); // <div>
// Best for side effects that don't affect layout
divRef.current.focus();
});

return <div ref={divRef}>Content</div>;
}

Performance Considerations​

// βœ… GOOD - Batch DOM reads and writes
function PerformantAnimation() {
const elementsRef = useRef([]);

const animateElements = useCallback(() => {
// Read phase - batch all measurements
const measurements = elementsRef.current.map(el => ({
top: el.offsetTop,
height: el.offsetHeight
}));

// Write phase - batch all mutations
requestAnimationFrame(() => {
elementsRef.current.forEach((el, i) => {
el.style.transform = `translateY(${measurements[i].top}px)`;
});
});
}, []);

return items.map((item, i) => (
<div key={item.id} ref={el => elementsRef.current[i] = el}>
{item.content}
</div>
));
}

// ❌ BAD - Interleaved reads and writes (layout thrashing)
function SlowAnimation() {
const elementsRef = useRef([]);

const animateElements = () => {
elementsRef.current.forEach(el => {
const top = el.offsetTop; // Read (forces reflow)
el.style.transform = `translateY(${top}px)`; // Write (invalidates layout)
// Next iteration forces another reflow - layout thrashing!
});
};

return items.map((item, i) => (
<div key={item.id} ref={el => elementsRef.current[i] = el}>
{item.content}
</div>
));
}

πŸ› Real-World Scenario: Auto-Focus Bug in Multi-Step Form​

The Problem​

An e-commerce checkout form had a critical UX issue: after proceeding to the next step, the first input wasn't auto-focused, forcing users to manually click. This increased form abandonment by 8%.

Symptoms:

  • Form step transitions successfully
  • Input exists in DOM
  • inputRef.current is not null
  • .focus() called but nothing happens
  • Works on second click but not on initial transition

Metrics:

  • Form abandonment: 23% β†’ 31% after feature release
  • Time to complete checkout: +12 seconds average
  • Mobile abandonment: 41% (users couldn't see keyboard)
  • Support tickets: +47 complaints about "broken form"

Initial Buggy Code​

// ❌ BAD - Focus timing issue
function MultiStepForm() {
const [step, setStep] = useState(1);
const inputRef = useRef(null);

const nextStep = () => {
setStep(prev => prev + 1);
// Bug: Input doesn't exist yet when this runs!
inputRef.current?.focus();
};

return (
<div>
{step === 1 && (
<div>
<input type="text" placeholder="Name" />
<button onClick={nextStep}>Next</button>
</div>
)}

{step === 2 && (
<div>
{/* This input isn't mounted when nextStep() calls .focus() */}
<input ref={inputRef} type="email" placeholder="Email" />
</div>
)}
</div>
);
}

Why It Failed:

  1. setStep() schedules re-render but doesn't execute immediately
  2. inputRef.current.focus() runs before step 2 input exists
  3. Ref is still pointing to null from previous render
  4. React batches state updates, so DOM isn't updated yet

Debugging Process​

// Step 1: Add logging to understand timing
const nextStep = () => {
console.log('Before setState:', inputRef.current); // null
setStep(prev => prev + 1);
console.log('After setState:', inputRef.current); // still null!
inputRef.current?.focus(); // Doesn't work - ref is null
};

// Step 2: Try useEffect (wrong approach)
useEffect(() => {
console.log('After render:', inputRef.current); // <input> exists
inputRef.current?.focus(); // Fires on EVERY render (wrong)
}, []); // Empty deps - only runs on mount, not step change

// Step 3: Correct approach - effect with step dependency
useEffect(() => {
if (step === 2 && inputRef.current) {
inputRef.current.focus(); // Works! Runs after step 2 renders
}
}, [step]); // Re-run when step changes

Solution 1: useEffect with Step Dependency​

// βœ… GOOD - useEffect ensures DOM is ready
function MultiStepForm() {
const [step, setStep] = useState(1);
const inputRef = useRef(null);

// Focus after DOM updates
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, [step]); // Re-run when step changes

return (
<div>
{step === 1 && (
<div>
<input type="text" placeholder="Name" />
<button onClick={() => setStep(2)}>Next</button>
</div>
)}

{step === 2 && (
<div>
<input ref={inputRef} type="email" placeholder="Email" />
<button onClick={() => setStep(3)}>Next</button>
</div>
)}

{step === 3 && (
<div>
<input ref={inputRef} type="tel" placeholder="Phone" />
</div>
)}
</div>
);
}

Solution 2: Callback Ref (More Robust)​

// βœ… BETTER - Callback ref guarantees element exists
function MultiStepForm() {
const [step, setStep] = useState(1);

// Callback ref - called when element mounts
const focusRef = useCallback((node) => {
if (node !== null) {
// Element just mounted - focus it
node.focus();
}
}, []); // Stable function

return (
<div>
{step === 1 && (
<div>
<input type="text" placeholder="Name" />
<button onClick={() => setStep(2)}>Next</button>
</div>
)}

{step === 2 && (
<div>
{/* Callback ref fires when this input mounts */}
<input ref={focusRef} type="email" placeholder="Email" />
<button onClick={() => setStep(3)}>Next</button>
</div>
)}

{step === 3 && (
<div>
{/* Callback ref fires again for this input */}
<input ref={focusRef} type="tel" placeholder="Phone" />
</div>
)}
</div>
);
}

Solution 3: AutoFocus Component (Reusable)​

// βœ… BEST - Reusable abstraction
function AutoFocusInput({ shouldFocus = true, ...props }) {
const inputRef = useCallback((node) => {
if (node !== null && shouldFocus) {
// Small delay ensures animations complete
setTimeout(() => node.focus(), 100);
}
}, [shouldFocus]);

return <input ref={inputRef} {...props} />;
}

function MultiStepForm() {
const [step, setStep] = useState(1);

return (
<div>
{step === 1 && (
<div>
<AutoFocusInput type="text" placeholder="Name" />
<button onClick={() => setStep(2)}>Next</button>
</div>
)}

{step === 2 && (
<div>
<AutoFocusInput type="email" placeholder="Email" />
<button onClick={() => setStep(3)}>Next</button>
</div>
)}

{step === 3 && (
<div>
<AutoFocusInput type="tel" placeholder="Phone" />
</div>
)}
</div>
);
}

Results After Fix​

BEFORE FIX:
- Form abandonment: 31%
- Time to complete: 87 seconds
- Mobile abandonment: 41%
- User complaints: 47

AFTER FIX (Callback Ref):
- Form abandonment: 21% (↓32% improvement)
- Time to complete: 71 seconds (↓18% improvement)
- Mobile abandonment: 28% (↓32% improvement)
- User complaints: 3 (↓94% improvement)

ROI:
- Conversion rate: +10% (1,200 extra orders/month)
- Revenue impact: +$240,000/month
- Development time: 2 hours

Key Lessons​

  1. Refs aren't synchronous - DOM updates happen after render
  2. useEffect is key - Ensures refs are populated before access
  3. Callback refs are cleaner - Guaranteed element existence
  4. Small UX issues matter - 8% abandonment = huge revenue loss
  5. Test focus behavior - Often overlooked in testing

<details> <summary><strong>βš–οΈ Trade-offs: Declarative vs Imperative DOM Access</strong></summary>

Decision Matrix: When to Use Refs​

ScenarioDeclarative (State)Imperative (Refs)Winner
Toggle visibility{show && <div>...</div>}ref.current.style.display = 'none'State - React manages lifecycle
Focus inputN/A (can't do declaratively)ref.current.focus()Refs - Only imperative option
Animate elementCSS classes + stateGSAP/D3 with refsDepends - Simple = state, Complex = refs
Scroll to elementN/Aref.current.scrollIntoView()Refs - Imperative API
Form validationControlled inputs + stateref.current.checkValidity()State - React way
Measure dimensionsN/Aref.current.getBoundingClientRect()Refs - Must read DOM
Integrate jQueryN/A$(ref.current).plugin()Refs - External library
Play video<video autoPlay> for initialref.current.play()Refs - Dynamic control

Anti-Pattern: Using Refs Instead of State​

// ❌ BAD - Manipulating DOM instead of using state
function Counter() {
const countRef = useRef(null);

const increment = () => {
const current = parseInt(countRef.current.textContent);
countRef.current.textContent = current + 1;
// Bypasses React - no re-render, no virtual DOM diffing
};

return (
<div>
<span ref={countRef}>0</span>
<button onClick={increment}>Increment</button>
</div>
);
}

// βœ… GOOD - Using state (declarative)
function Counter() {
const [count, setCount] = useState(0);

return (
<div>
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}

Why State Wins:

  • Predictable - Single source of truth
  • Testable - Easy to assert state values
  • Debuggable - React DevTools shows state
  • Composable - Can pass to child components
  • Reactive - Automatically updates UI

When Refs Are Necessary: Focus Management​

// ❌ Can't do this declaratively
function SearchBar() {
const [query, setQuery] = useState('');

// Need to focus after clearing (imperative operation)
const handleClear = () => {
setQuery('');
// How to focus input? Need ref!
};

return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<button onClick={handleClear}>Clear</button>
</div>
);
}

// βœ… GOOD - Refs for imperative API
function SearchBar() {
const [query, setQuery] = useState('');
const inputRef = useRef(null);

const handleClear = () => {
setQuery('');
inputRef.current?.focus(); // Imperative, but necessary
};

return (
<div>
<input
ref={inputRef}
value={query}
onChange={e => setQuery(e.target.value)}
/>
<button onClick={handleClear}>Clear</button>
</div>
);
}

Performance Comparison​

// Scenario: Toggle 1000 elements on/off

// ❌ SLOW - Manipulating DOM directly (looks fast but isn't)
function DirectDOMToggle() {
const itemsRef = useRef([]);

const toggle = () => {
// Directly mutate DOM - causes layout thrashing
itemsRef.current.forEach(el => {
el.style.display = el.style.display === 'none' ? 'block' : 'none';
});
};

return (
<>
{items.map((item, i) => (
<div key={i} ref={el => itemsRef.current[i] = el}>
{item}
</div>
))}
<button onClick={toggle}>Toggle</button>
</>
);
}

// βœ… FAST - React's virtual DOM batches updates efficiently
function StateToggle() {
const [visible, setVisible] = useState(true);

return (
<>
{visible && items.map((item, i) => (
<div key={i}>{item}</div>
))}
<button onClick={() => setVisible(!visible)}>Toggle</button>
</>
);
}

// Benchmark results:
// Direct DOM: 847ms (layout thrashing, forced reflows)
// React State: 23ms (batched updates, virtual DOM diffing)

Memory Management​

// ❌ BAD - Memory leak with third-party library
function LeakyChart({ data }) {
const chartRef = useRef(null);

useEffect(() => {
const chart = new ExpensiveChart(chartRef.current, data);
// Missing cleanup! Chart instance never destroyed
}, [data]);

return <canvas ref={chartRef} />;
}

// βœ… GOOD - Proper cleanup prevents leaks
function ProperChart({ data }) {
const chartRef = useRef(null);
const chartInstanceRef = useRef(null);

useEffect(() => {
if (!chartRef.current) return;

// Create chart instance
chartInstanceRef.current = new ExpensiveChart(chartRef.current, data);

// Cleanup function destroys instance
return () => {
chartInstanceRef.current?.destroy();
chartInstanceRef.current = null;
};
}, [data]);

return <canvas ref={chartRef} />;
}

Testing Complexity​

// ❌ HARD TO TEST - Refs require real DOM
import { render } from '@testing-library/react';

function RefComponent() {
const divRef = useRef(null);

useEffect(() => {
divRef.current.style.backgroundColor = 'red';
}, []);

return <div ref={divRef} />;
}

test('sets background color', () => {
const { container } = render(<RefComponent />);
const div = container.querySelector('div');
// Need to check actual DOM styles
expect(div.style.backgroundColor).toBe('red');
});

// βœ… EASY TO TEST - State is pure data
function StateComponent() {
const [color, setColor] = useState('red');
return <div style={{ backgroundColor: color }} />;
}

test('sets background color', () => {
const { container } = render(<StateComponent />);
const div = container.querySelector('div');
// Declarative style prop is easier to test
expect(div).toHaveStyle({ backgroundColor: 'red' });
});

When Refs Are The Right Choice​

Valid Use Cases:

  1. Focus management - inputRef.current.focus()
  2. Text selection - inputRef.current.select()
  3. Scroll position - divRef.current.scrollTop = 0
  4. Measurements - divRef.current.getBoundingClientRect()
  5. Media control - videoRef.current.play()
  6. Canvas drawing - canvasRef.current.getContext('2d')
  7. Third-party libraries - D3, GSAP, jQuery
  8. Imperative animations - Web Animations API

Invalid Use Cases:

  1. Managing component state - Use useState
  2. Conditional rendering - Use state + JSX
  3. Styling - Use state + className/style prop
  4. Event handling - Use event handlers
  5. Data fetching - Use useEffect + state
  6. Form values - Use controlled components

πŸ’¬ Explain to Junior: Understanding Refs Simply​

The Restaurant Analogy​

Imagine you're running a restaurant (React app):

Declarative Approach (State):

  • You tell the kitchen (React): "I need 3 burgers on Table 5"
  • Kitchen prepares food and delivers it
  • You don't touch the plates yourself
  • You just describe what you want, React handles it

Imperative Approach (Refs):

  • You go into the kitchen yourself
  • You grab a specific pan (DOM element)
  • You cook something custom that the menu doesn't cover
  • You have direct control, but bypass the normal system

When to use refs: When the kitchen can't make what you need through the normal menu (declarative system), you need direct access to the tools (DOM nodes).

Simple Examples​

Example 1: Auto-focusing an input

// Without ref - can't do this!
function SearchBox() {
const [query, setQuery] = useState('');

// How do I focus this input when component loads?
// I can't tell React "focus this" declaratively

return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

// With ref - direct access!
function SearchBox() {
const [query, setQuery] = useState('');
const inputRef = useRef(null); // Create a "handle" to the input

useEffect(() => {
// After component loads, grab the input and focus it
inputRef.current.focus(); // Direct imperative command
}, []);

return <input ref={inputRef} value={query} onChange={e => setQuery(e.target.value)} />;
}

Think of useRef like a box:

  • The box (ref) stays the same across renders
  • You can put things in the box (.current)
  • Changing what's in the box doesn't cause re-render
  • React puts the DOM element in the box for you

Example 2: Measuring an element

function ImageGallery() {
const imgRef = useRef(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });

useEffect(() => {
// After image loads, measure it
const rect = imgRef.current.getBoundingClientRect();
setDimensions({
width: rect.width,
height: rect.height
});
}, []);

return (
<div>
<img ref={imgRef} src="photo.jpg" alt="Gallery" />
<p>Size: {dimensions.width}x{dimensions.height}px</p>
</div>
);
}

Common Beginner Mistakes​

Mistake 1: Using ref before it's ready

// ❌ WRONG - ref is null during render
function Component() {
const divRef = useRef(null);

console.log(divRef.current); // null! Element doesn't exist yet
divRef.current.style.color = 'red'; // ERROR!

return <div ref={divRef}>Hello</div>;
}

// βœ… CORRECT - wait for useEffect
function Component() {
const divRef = useRef(null);

useEffect(() => {
console.log(divRef.current); // <div>Hello</div> βœ…
divRef.current.style.color = 'red'; // Works!
}, []);

return <div ref={divRef}>Hello</div>;
}

Why? React renders components in two phases:

  1. Render phase - Creates virtual DOM (refs are null)
  2. Commit phase - Updates real DOM (refs are filled)
  3. useEffect runs - After DOM is ready (refs available)

Mistake 2: Expecting refs to trigger re-renders

// ❌ WRONG - changing .current doesn't re-render
function Counter() {
const countRef = useRef(0);

const increment = () => {
countRef.current += 1;
console.log(countRef.current); // Updates!
// But UI doesn't update - no re-render!
};

return (
<div>
<p>Count: {countRef.current}</p> {/* Always shows 0 */}
<button onClick={increment}>Increment</button>
</div>
);
}

// βœ… CORRECT - use state for data that affects UI
function Counter() {
const [count, setCount] = useState(0);

const increment = () => {
setCount(count + 1); // Triggers re-render
};

return (
<div>
<p>Count: {count}</p> {/* Updates correctly */}
<button onClick={increment}>Increment</button>
</div>
);
}

Interview Answer Template​

Question: "What are refs and when would you use them?"

Answer Structure:

"Refs are React's escape hatch for directly accessing DOM nodes or component instances. You create a ref with useRef(), attach it to an element with the ref attribute, and access the DOM node via .current.

I'd use refs when I need to do something imperative that can't be done declaratively:

  • Focus management: calling .focus() on an input
  • Measuring elements: getting dimensions with .getBoundingClientRect()
  • Integrating third-party libraries: passing DOM nodes to D3 or animation libraries
  • Media control: calling .play() or .pause() on video elements

However, I avoid refs for things that should be done declaratively:

  • State management - use useState instead
  • Conditional rendering - use state and JSX
  • Styling - use state and className/style props

For example, if I need to auto-focus an input after a form step, I'd use a ref with useEffect:

const inputRef = useRef(null);

useEffect(() => {
inputRef.current.focus();
}, [step]);

return <input ref={inputRef} />;

The key principle is: refs are a last resort when the declarative approach doesn't work."

Mental Model Checklist​

Before using a ref, ask yourself:

  1. βœ… Can I do this with state? β†’ Use state instead
  2. βœ… Can I do this with props? β†’ Use props instead
  3. βœ… Can I do this with CSS? β†’ Use CSS instead
  4. βœ… Is this truly imperative? β†’ Ref might be appropriate
  5. βœ… Am I accessing in useEffect? β†’ Safe to use ref
  6. βœ… Do I need the DOM node itself? β†’ Ref is correct tool

Rule of thumb: If you're not sure whether to use a ref, you probably don't need it. React's declarative approach works for 95% of cases.


Question 2: How do forwardRef and useImperativeHandle work?​

Answer​

forwardRef allows a component to expose its DOM node (or custom imperative API) to parent components. Normally, refs only work on DOM elements (<div>, <input>), but forwardRef lets you pass refs through custom components.

useImperativeHandle customizes what the ref exposes. Instead of exposing the raw DOM node, you can expose a custom object with specific methods, giving parents controlled imperative access.

Why They Exist:

  • Encapsulation - Child controls what parent can access
  • Abstraction - Expose high-level API instead of raw DOM
  • Reusability - Create library components with imperative APIs
  • Safety - Prevent parent from accessing internal implementation

Basic forwardRef Syntax:

const MyInput = forwardRef((props, ref) => {
return <input ref={ref} {...props} />;
});

// Parent can now ref the input directly
function Parent() {
const inputRef = useRef();
return <MyInput ref={inputRef} />;
}

Basic useImperativeHandle Syntax:

const MyInput = forwardRef((props, ref) => {
const inputRef = useRef();

useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus(),
clear: () => inputRef.current.value = ''
}));

return <input ref={inputRef} {...props} />;
});

// Parent only gets .focus() and .clear() methods
function Parent() {
const inputRef = useRef();

const handleClick = () => {
inputRef.current.focus(); // βœ… Exposed
inputRef.current.clear(); // βœ… Exposed
inputRef.current.select(); // ❌ Not exposed
};

return <MyInput ref={inputRef} />;
}

Key Benefits:

  • Controlled exposure - Only expose safe methods
  • Implementation hiding - Parent doesn't know internals
  • Easier refactoring - Change internals without breaking parent
  • Better encapsulation - Follow principle of least privilege

When to Use:

  • Building reusable library components
  • Creating custom input wrappers
  • Complex components needing imperative API
  • When you want to hide implementation details

When NOT to Use:

  • Simple pass-through components (use forwardRef alone)
  • When declarative approach works
  • For internal component communication (use props/callbacks)

πŸ” Deep Dive: forwardRef and useImperativeHandle Internals​

Understanding forwardRef and useImperativeHandle internals is essential for building robust component libraries and debugging ref-related issues. These APIs enable controlled imperative access while maintaining React's declarative paradigm.

How forwardRef Works Internally​

forwardRef is a higher-order function that wraps your component and changes how React processes refs. Unlike normal function components which reject refs with a warning, forwardRef components accept refs as a second parameter and can forward them to child elements or use them with useImperativeHandle.

// Simplified React forwardRef implementation

function forwardRef(render) {
// Return special component type
return {
$$typeof: Symbol.for('react.forward_ref'),
render // The component function
};
}

// When React renders this component:
function renderForwardRef(workInProgress) {
const Component = workInProgress.type;
const props = workInProgress.pendingProps;
const ref = workInProgress.ref; // Extract ref from fiber

// Call render function with props AND ref
const children = Component.render(props, ref);

return children;
}

// Normal component function signature:
// function MyComponent(props) { ... }

// forwardRef component signature:
// function MyComponent(props, ref) { ... }
// ^^^ Second parameter!

Key differences from normal components:

  • Special React type - $$typeof: react.forward_ref (symbol prevents XSS attacks)
  • Ref passed as second parameter - Not in props (keeps props clean)
  • Can attach ref to child element - Pass it down to DOM node (enables composition)

Why refs aren't in props:

React intentionally excludes ref and key from props to prevent accidental overrides and maintain special handling. If refs were in props, child components could accidentally destructure them away:

// If ref was in props (hypothetical):
function Input({ ref, ...otherProps }) {
// ref would be lost if we spread otherProps!
return <input {...otherProps} />;
}

By keeping ref separate, React ensures it's always handled correctly during reconciliation.

Performance impact:

forwardRef adds minimal overhead (~0.005ms per component render). The wrapper function is called once per render, but the ref attachment itself happens during commit phase, not render phase, so it doesn't slow down reconciliation.

Benchmark (1000 components, 100 renders each):

// Normal function component: 847ms
function Normal() { return <div />; }

// forwardRef component: 852ms (+0.6% overhead)
const Forwarded = forwardRef((props, ref) => <div ref={ref} />);

React DevTools integration:

forwardRef components appear with a special "ForwardRef" wrapper in React DevTools, making them easy to identify:

<ForwardRef(MyInput)>
<input />
</ForwardRef>

You can add a displayName to improve debugging:

const MyInput = forwardRef((props, ref) => {
return <input ref={ref} {...props} />;
});

MyInput.displayName = 'MyInput'; // Shows as <MyInput> in DevTools

Memory implications:

Each forwardRef call creates a new wrapper object, but it's created once at module initialization, not per render. This means 1,000 forwardRef components add ~32KB of memory overhead (32 bytes per wrapper object), which is negligible compared to component trees.

Edge case: Double wrapping

Be careful not to double-wrap components:

// ❌ BAD - unnecessary double wrapping
const Input = forwardRef((props, ref) => <input ref={ref} />);
const FancyInput = forwardRef((props, ref) => <Input ref={ref} />);
// Both wrappers do the same thing - wasteful

// βœ… GOOD - single wrapper
const FancyInput = forwardRef((props, ref) => {
// Additional logic here
return <input ref={ref} {...props} />;
});

Compatibility with memo:

forwardRef works seamlessly with React.memo for performance optimization:

const MemoizedInput = memo(
forwardRef((props, ref) => {
console.log('Render'); // Only logs when props change
return <input ref={ref} {...props} />;
})
);

Order matters: wrap forwardRef first, then memo, because memo expects a component type, and forwardRef returns a component type.

Without forwardRef (Doesn't Work)​

// ❌ This doesn't work!
function MyInput(props) {
// Where does ref go? Not in props!
return <input {...props} />;
}

function Parent() {
const inputRef = useRef();

// Warning: Function components cannot be given refs!
return <MyInput ref={inputRef} />;
}

// React sees ref on function component and throws warning
// ref is NOT passed in props - React handles it specially

With forwardRef (Works)​

// βœ… forwardRef enables ref forwarding
const MyInput = forwardRef((props, ref) => {
// ref is second parameter
return <input ref={ref} {...props} />;
});

function Parent() {
const inputRef = useRef();

// Works! inputRef.current will be the <input> DOM node
return <MyInput ref={inputRef} />;
}

useImperativeHandle Implementation​

useImperativeHandle is one of React's most misunderstood hooks. It customizes the instance value exposed to parent components when using refs, enabling controlled imperative APIs. Unlike useRef which stores mutable values, useImperativeHandle replaces what the ref exposes, giving child components full control over their public API.

// Simplified React useImperativeHandle implementation

function useImperativeHandle(ref, createHandle, deps) {
useLayoutEffect(() => {
if (typeof ref === 'function') {
// Callback ref
const handle = createHandle();
ref(handle);

return () => ref(null); // Cleanup
} else if (ref !== null) {
// Object ref
ref.current = createHandle();

return () => {
ref.current = null; // Cleanup
};
}
}, deps); // Re-run if deps change
}

// Why useLayoutEffect? Synchronous before paint
// Ensures ref is ready before browser paints

Key behaviors:

  • Synchronous - Uses useLayoutEffect, not useEffect (guarantees ref availability before paint)
  • Dependency tracking - Re-creates handle when deps change (enables reactive methods)
  • Cleanup - Nulls ref on unmount (prevents memory leaks)
  • Works with callback refs - Supports both ref types (object and function refs)

Why useLayoutEffect instead of useEffect?

useImperativeHandle uses useLayoutEffect to ensure the ref is populated before the browser paints. This is critical for components that imperatively trigger layout changes (focus, scroll, animations).

Timeline comparison:

// With useLayoutEffect (actual implementation):
// t=0ms: Render phase completes
// t=1ms: DOM mutations committed
// t=2ms: useImperativeHandle runs (sets ref.current)
// t=3ms: Parent can use ref in their useLayoutEffect
// t=4ms: Browser paints screen
// t=5ms: useEffect callbacks run

// If it used useEffect (hypothetical):
// t=0ms: Render phase completes
// t=1ms: DOM mutations committed
// t=2ms: Browser paints screen
// t=3ms: useImperativeHandle runs (sets ref.current) ❌ Too late!
// t=4ms: Parent's useLayoutEffect might have already run ❌

Performance implications:

Each useImperativeHandle call has ~0.02ms overhead per render (when dependencies don't change). With dependency changes, the createHandle function must execute, adding whatever time that function takes. For expensive handle creation, memoize dependencies carefully.

Benchmark (1000 components, 100 renders, stable deps):

// Without useImperativeHandle: 743ms
const Simple = forwardRef((props, ref) => <div ref={ref} />);

// With useImperativeHandle (empty deps): 764ms (+2.8% overhead)
const WithHandle = forwardRef((props, ref) => {
useImperativeHandle(ref, () => ({ method: () => {} }), []);
return <div />;
});

// With useImperativeHandle (changing deps): 1,247ms (+67% overhead)
const WithChangingDeps = forwardRef(({ value }, ref) => {
useImperativeHandle(ref, () => ({ getValue: () => value }), [value]);
return <div />;
});

This shows why dependency optimization is crucial - avoid including dependencies that change frequently unless absolutely necessary.

Dependency array gotchas:

The dependency array works like useEffect but with critical implications for API stability:

// ❌ BAD - missing dependencies causes stale closures
const Counter = forwardRef((props, ref) => {
const [count, setCount] = useState(0);

useImperativeHandle(ref, () => ({
getCount: () => count // Captures count from first render
}), []); // Empty deps - count is stale!

return <div>{count}</div>;
});

// βœ… GOOD - include dependencies
const Counter = forwardRef((props, ref) => {
const [count, setCount] = useState(0);

useImperativeHandle(ref, () => ({
getCount: () => count // Fresh count every render
}), [count]); // count dependency

return <div>{count}</div>;
});

// βœ… BETTER - use ref to avoid recreating handle
const Counter = forwardRef((props, ref) => {
const [count, setCount] = useState(0);
const countRef = useRef(count);

useEffect(() => {
countRef.current = count; // Keep ref in sync
}, [count]);

useImperativeHandle(ref, () => ({
getCount: () => countRef.current // Always current, stable handle
}), []); // Empty deps safe now

return <div>{count}</div>;
});

Memory management:

Each useImperativeHandle creates a new object when dependencies change. For components rendered 1,000 times with changing deps, this creates 1,000 objects (~32 bytes each = 32KB). Not huge, but can add up in large lists. Use stable deps when possible.

Cleanup behavior:

The cleanup function runs before the next effect and on unmount, ensuring refs don't point to stale handles:

const Component = forwardRef((props, ref) => {
const [version, setVersion] = useState(1);

useImperativeHandle(ref, () => {
console.log('Creating handle for version:', version);
return { version };
}, [version]); // Recreates when version changes

// When version changes:
// 1. Cleanup runs: ref.current = null
// 2. New handle created: ref.current = { version: 2 }
});

This prevents race conditions where parent components might call methods on outdated handles.

Advanced Pattern: Combining Multiple Refs​

// βœ… GOOD - Forward ref AND use locally
const FancyInput = forwardRef((props, forwardedRef) => {
const localRef = useRef();

// Combine refs - expose to parent AND use internally
useImperativeHandle(forwardedRef, () => ({
focus: () => localRef.current.focus(),
scrollIntoView: () => localRef.current.scrollIntoView(),

// Internal method - not exposed
getValue: () => localRef.current.value
}));

// Use local ref for internal logic
useEffect(() => {
console.log('Input width:', localRef.current.offsetWidth);
}, []);

return <input ref={localRef} {...props} />;
});

Real-World Example: Custom Video Player​

// βœ… ADVANCED - Video player with imperative API
const VideoPlayer = forwardRef(({ src, onTimeUpdate }, ref) => {
const videoRef = useRef();
const [isPlaying, setIsPlaying] = useState(false);

useImperativeHandle(ref, () => ({
play: async () => {
try {
await videoRef.current.play();
setIsPlaying(true);
} catch (error) {
console.error('Play failed:', error);
}
},

pause: () => {
videoRef.current.pause();
setIsPlaying(false);
},

seek: (time) => {
videoRef.current.currentTime = time;
},

getDuration: () => {
return videoRef.current.duration;
},

getCurrentTime: () => {
return videoRef.current.currentTime;
},

setVolume: (volume) => {
videoRef.current.volume = Math.max(0, Math.min(1, volume));
},

// Don't expose raw video element - keep encapsulated
}), []); // Empty deps - methods don't change

return (
<video
ref={videoRef}
src={src}
onTimeUpdate={onTimeUpdate}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
/>
);
});

// Usage in parent
function VideoApp() {
const playerRef = useRef();

const handlePlayPause = () => {
if (playerRef.current) {
const currentTime = playerRef.current.getCurrentTime();

if (currentTime > 0) {
playerRef.current.pause();
} else {
playerRef.current.play();
}
}
};

const handleSeek = (seconds) => {
playerRef.current?.seek(seconds);
};

return (
<div>
<VideoPlayer ref={playerRef} src="video.mp4" />
<button onClick={handlePlayPause}>Play/Pause</button>
<button onClick={() => handleSeek(30)}>Skip 30s</button>
</div>
);
}

TypeScript Typing​

// βœ… GOOD - Proper TypeScript types for forwardRef

// Define the imperative handle type
interface VideoPlayerHandle {
play: () => Promise<void>;
pause: () => void;
seek: (time: number) => void;
getDuration: () => number;
getCurrentTime: () => number;
setVolume: (volume: number) => void;
}

// Props interface
interface VideoPlayerProps {
src: string;
onTimeUpdate?: () => void;
}

// forwardRef with generic types
const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
({ src, onTimeUpdate }, ref) => {
const videoRef = useRef<HTMLVideoElement>(null);

useImperativeHandle(ref, () => ({
play: async () => {
await videoRef.current?.play();
},
pause: () => {
videoRef.current?.pause();
},
seek: (time: number) => {
if (videoRef.current) {
videoRef.current.currentTime = time;
}
},
getDuration: () => videoRef.current?.duration ?? 0,
getCurrentTime: () => videoRef.current?.currentTime ?? 0,
setVolume: (volume: number) => {
if (videoRef.current) {
videoRef.current.volume = Math.max(0, Math.min(1, volume));
}
}
}));

return <video ref={videoRef} src={src} onTimeUpdate={onTimeUpdate} />;
}
);

// Usage with type safety
function App() {
const playerRef = useRef<VideoPlayerHandle>(null);

const handleClick = () => {
playerRef.current?.play(); // Type-safe!
playerRef.current?.invalidMethod(); // TypeScript error βœ…
};

return <VideoPlayer ref={playerRef} src="video.mp4" />;
}

Dependency Array Gotcha​

// ❌ BAD - Missing dependencies causes stale closures
const Counter = forwardRef((props, ref) => {
const [count, setCount] = useState(0);

useImperativeHandle(ref, () => ({
getCount: () => count, // Stale closure!
increment: () => setCount(count + 1) // Stale closure!
}), []); // Empty deps - count is captured once

return <div>{count}</div>;
});

function Parent() {
const counterRef = useRef();

useEffect(() => {
counterRef.current.increment(); // Sets to 1
counterRef.current.increment(); // Sets to 1 again! (uses stale count)
console.log(counterRef.current.getCount()); // 1 (stale)
}, []);

return <Counter ref={counterRef} />;
}

// βœ… GOOD - Include dependencies OR use functional updates
const Counter = forwardRef((props, ref) => {
const [count, setCount] = useState(0);

useImperativeHandle(ref, () => ({
getCount: () => count, // Fresh closure
increment: () => setCount(c => c + 1) // Functional update
}), [count]); // Include count dependency

return <div>{count}</div>;
});

// βœ… EVEN BETTER - Use ref to store mutable value
const Counter = forwardRef((props, ref) => {
const [count, setCount] = useState(0);
const countRef = useRef(count);

useEffect(() => {
countRef.current = count; // Keep ref in sync
}, [count]);

useImperativeHandle(ref, () => ({
getCount: () => countRef.current, // Always current
increment: () => setCount(c => c + 1)
}), []); // Empty deps safe now

return <div>{count}</div>;
});

Performance Optimization​

// ❌ SLOW - Creates new handle object every render
const ExpensiveComponent = forwardRef((props, ref) => {
const [state, setState] = useState(0);

useImperativeHandle(ref, () => ({
method1: () => { /* ... */ },
method2: () => { /* ... */ },
method3: () => { /* ... */ }
})); // No deps - recreates every render!

return <div>{state}</div>;
});

// βœ… FAST - Memoize with empty deps (if methods don't need state)
const OptimizedComponent = forwardRef((props, ref) => {
const [state, setState] = useState(0);
const internalRef = useRef();

useImperativeHandle(ref, () => ({
method1: () => internalRef.current.doSomething(),
method2: () => internalRef.current.doSomethingElse(),
method3: () => internalRef.current.anotherThing()
}), []); // Empty deps - stable reference

return <div ref={internalRef}>{state}</div>;
});

πŸ› Real-World Scenario: Modal Focus Trap Implementation​

The Problem​

An e-commerce checkout modal had severe accessibility issues: keyboard users couldn't navigate properly, focus escaped the modal, and screen readers announced incorrect content. This violated WCAG 2.1 AA standards.

Symptoms:

  • Tab key moves focus outside modal (to background page)
  • Escape key doesn't close modal consistently
  • Screen reader announces background content
  • Focus not returned to trigger button after close
  • Multiple modals cause focus conflicts

Metrics:

  • Accessibility audit score: 67/100 (failing)
  • Keyboard-only users: 23% abandon checkout
  • Screen reader complaints: 41 WCAG violation reports
  • Legal risk: Potential ADA lawsuit
  • Checkout completion (keyboard users): 31% vs 68% (mouse users)

Initial Broken Implementation​

// ❌ BAD - No focus management
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;

return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
}

// Problems:
// 1. Focus stays on trigger button (can tab out of modal)
// 2. No Escape key handling
// 3. Focus not restored after close
// 4. Can't navigate with keyboard only

Debugging Process​

// Step 1: Identify focus issues
function Modal({ isOpen, onClose, children }) {
useEffect(() => {
if (isOpen) {
console.log('Modal opened, focus is:', document.activeElement);
// Focus is still on trigger button!

// Try focusing modal manually
const modal = document.querySelector('.modal-content');
modal?.focus(); // Doesn't work - div not focusable!
}
}, [isOpen]);

return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
}

// Step 2: Make modal focusable
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef(null);

useEffect(() => {
if (isOpen && modalRef.current) {
// Add tabindex to make div focusable
modalRef.current.tabIndex = -1;
modalRef.current.focus();
console.log('Focused:', document.activeElement); // <div> βœ…

// But... still can tab outside!
// Need focus trap
}
}, [isOpen]);

return (
<div className="modal-backdrop" onClick={onClose}>
<div ref={modalRef} className="modal-content" onClick={e => e.stopPropagation()}>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
}

Solution 1: Basic Focus Trap with useImperativeHandle​

// βœ… GOOD - Focus trap with keyboard handling
const Modal = forwardRef(({ isOpen, onClose, children }, ref) => {
const modalRef = useRef(null);
const previousFocusRef = useRef(null);

// Expose imperative methods to parent
useImperativeHandle(ref, () => ({
focus: () => modalRef.current?.focus(),
close: () => onClose()
}));

useEffect(() => {
if (isOpen) {
// Store currently focused element
previousFocusRef.current = document.activeElement;

// Focus modal
modalRef.current?.focus();

// Trap focus within modal
const handleTab = (e) => {
if (e.key === 'Tab') {
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);

const firstElement = focusableElements?.[0];
const lastElement = focusableElements?.[focusableElements.length - 1];

if (e.shiftKey && document.activeElement === firstElement) {
// Shift+Tab on first element - go to last
e.preventDefault();
lastElement?.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
// Tab on last element - go to first
e.preventDefault();
firstElement?.focus();
}
} else if (e.key === 'Escape') {
onClose();
}
};

document.addEventListener('keydown', handleTab);

return () => {
document.removeEventListener('keydown', handleTab);
// Restore focus to trigger button
previousFocusRef.current?.focus();
};
}
}, [isOpen, onClose]);

if (!isOpen) return null;

return (
<div className="modal-backdrop" onClick={onClose}>
<div
ref={modalRef}
className="modal-content"
role="dialog"
aria-modal="true"
tabIndex={-1}
onClick={e => e.stopPropagation()}
>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
});

// Usage
function App() {
const [isOpen, setIsOpen] = useState(false);
const modalRef = useRef();

const openModal = () => {
setIsOpen(true);
};

return (
<div>
<button onClick={openModal}>Open Modal</button>
<Modal ref={modalRef} isOpen={isOpen} onClose={() => setIsOpen(false)}>
<h2>Checkout</h2>
<input type="text" placeholder="Name" />
<input type="email" placeholder="Email" />
</Modal>
</div>
);
}

Solution 2: Reusable useFocusTrap Hook​

// βœ… BETTER - Reusable hook for focus trapping
function useFocusTrap(isActive) {
const containerRef = useRef(null);
const previousFocusRef = useRef(null);

useEffect(() => {
if (!isActive || !containerRef.current) return;

// Save previous focus
previousFocusRef.current = document.activeElement;

// Focus container
containerRef.current.focus();

const handleKeyDown = (e) => {
if (e.key === 'Tab') {
const focusableElements = containerRef.current.querySelectorAll(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
);

const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];

if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
};

document.addEventListener('keydown', handleKeyDown);

return () => {
document.removeEventListener('keydown', handleKeyDown);
previousFocusRef.current?.focus();
};
}, [isActive]);

return containerRef;
}

// Usage in Modal
const Modal = forwardRef(({ isOpen, onClose, children }, ref) => {
const modalRef = useFocusTrap(isOpen);

useImperativeHandle(ref, () => ({
focus: () => modalRef.current?.focus(),
close: () => onClose()
}));

// Handle Escape key
useEffect(() => {
const handleEscape = (e) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};

document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);

if (!isOpen) return null;

return (
<div className="modal-backdrop" onClick={onClose}>
<div
ref={modalRef}
className="modal-content"
role="dialog"
aria-modal="true"
tabIndex={-1}
onClick={e => e.stopPropagation()}
>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
});

Solution 3: Advanced Modal with useImperativeHandle API​

// βœ… BEST - Complete modal with full imperative API
const Modal = forwardRef(({
isOpen,
onClose,
children,
title,
closeOnBackdrop = true,
closeOnEscape = true
}, ref) => {
const modalRef = useRef(null);
const closeButtonRef = useRef(null);
const previousFocusRef = useRef(null);

// Expose full imperative API
useImperativeHandle(ref, () => ({
open: () => {
// Parent can call modalRef.current.open()
if (!isOpen) onClose(); // Toggle
},
close: () => {
onClose();
},
focus: () => {
modalRef.current?.focus();
},
focusCloseButton: () => {
closeButtonRef.current?.focus();
},
getModalElement: () => {
return modalRef.current;
}
}));

// Focus trap
useEffect(() => {
if (!isOpen) return;

previousFocusRef.current = document.activeElement;

// Wait for render, then focus first focusable element
const focusableElements = modalRef.current?.querySelectorAll(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
);

const firstElement = focusableElements?.[0];
firstElement?.focus();

const handleKeyDown = (e) => {
if (e.key === 'Escape' && closeOnEscape) {
e.stopPropagation();
onClose();
return;
}

if (e.key === 'Tab') {
const focusableElements = modalRef.current?.querySelectorAll(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
);

const firstElement = focusableElements?.[0];
const lastElement = focusableElements?.[focusableElements.length - 1];

if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
}
};

document.addEventListener('keydown', handleKeyDown);

// Prevent body scroll
document.body.style.overflow = 'hidden';

return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
previousFocusRef.current?.focus();
};
}, [isOpen, onClose, closeOnEscape]);

if (!isOpen) return null;

return (
<div
className="modal-backdrop"
onClick={closeOnBackdrop ? onClose : undefined}
>
<div
ref={modalRef}
className="modal-content"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
onClick={e => e.stopPropagation()}
>
<div className="modal-header">
<h2 id="modal-title">{title}</h2>
<button
ref={closeButtonRef}
onClick={onClose}
aria-label="Close modal"
>
Γ—
</button>
</div>
<div className="modal-body">
{children}
</div>
</div>
</div>
);
});

// Usage with full imperative control
function CheckoutApp() {
const [isModalOpen, setIsModalOpen] = useState(false);
const modalRef = useRef();

const handleOpenModal = () => {
setIsModalOpen(true);
};

const handleSubmit = () => {
// Close modal imperatively after async operation
setTimeout(() => {
modalRef.current?.close();
}, 2000);
};

return (
<div>
<button onClick={handleOpenModal}>Checkout</button>

<Modal
ref={modalRef}
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Checkout"
closeOnBackdrop={true}
closeOnEscape={true}
>
<form onSubmit={handleSubmit}>
<input type="text" placeholder="Card Number" />
<input type="text" placeholder="CVV" />
<button type="submit">Pay Now</button>
</form>
</Modal>
</div>
);
}

Results After Implementation​

BEFORE FIX:
- Accessibility score: 67/100
- Keyboard abandonment: 23%
- WCAG violations: 41 reports
- Screen reader compatibility: Poor
- Tab navigation: Broken

AFTER FIX (Focus Trap + useImperativeHandle):
- Accessibility score: 97/100 (↑45% improvement)
- Keyboard abandonment: 7% (↓70% improvement)
- WCAG violations: 0 (↓100% improvement)
- Screen reader compatibility: Excellent
- Tab navigation: Perfect

ROI:
- Checkout completion: +37% (keyboard users)
- Legal risk: Eliminated
- Brand reputation: Improved
- Development time: 6 hours
- Prevented lawsuit: Priceless

Key Lessons​

  1. Focus management is critical - Keyboard users can't use broken modals
  2. useImperativeHandle enables encapsulation - Parent controls modal without knowing internals
  3. Accessibility affects revenue - 23% abandonment = huge loss
  4. forwardRef enables reusability - Modal component works anywhere
  5. Testing keyboard navigation is essential - Often overlooked in testing

</details>


<details> <summary><strong>βš–οΈ Trade-offs: forwardRef vs Direct DOM Access</strong></summary>

Decision Matrix​

ScenarioforwardRef + useImperativeHandleDirect RefWinner
Reusable library componentβœ… Encapsulation, custom API❌ Exposes internalsforwardRef
Simple pass-throughβœ… Clean APIβœ… Simpler codeTie (use forwardRef for consistency)
Internal component❌ Unnecessary complexityβœ… StraightforwardDirect ref
Complex imperative APIβœ… Controlled exposure❌ Too much accessforwardRef + useImperativeHandle
Third-party integrationβœ… Hide implementation❌ Brittle if internals changeforwardRef
TypeScript safetyβœ… Type-safe API⚠️ Harder to typeforwardRef

Anti-Pattern: Overusing useImperativeHandle​

// ❌ BAD - Exposing too much imperative API
const Form = forwardRef((props, ref) => {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [errors, setErrors] = useState({});

useImperativeHandle(ref, () => ({
// Exposing ALL internal state imperatively - anti-pattern!
getName: () => name,
setName: (value) => setName(value),
getEmail: () => email,
setEmail: (value) => setEmail(value),
getErrors: () => errors,
setErrors: (value) => setErrors(value),
validate: () => { /* validation */ },
submit: () => { /* submit */ },
reset: () => { /* reset */ }
}));

return (
<form>
<input value={name} onChange={e => setName(e.target.value)} />
<input value={email} onChange={e => setEmail(e.target.value)} />
</form>
);
});

// Parent becomes tightly coupled to internals
function Parent() {
const formRef = useRef();

const handleClick = () => {
// Parent controls everything imperatively - defeats React's purpose!
formRef.current.setName('John');
formRef.current.setEmail('john@example.com');
formRef.current.validate();
formRef.current.submit();
};

return <Form ref={formRef} />;
}

// βœ… GOOD - Use props/callbacks (declarative)
function Form({ name, email, errors, onChange, onSubmit }) {
return (
<form onSubmit={onSubmit}>
<input
value={name}
onChange={e => onChange({ name: e.target.value })}
/>
<input
value={email}
onChange={e => onChange({ email: e.target.value })}
/>
</form>
);
}

function Parent() {
const [formData, setFormData] = useState({ name: '', email: '' });

const handleChange = (updates) => {
setFormData(prev => ({ ...prev, ...updates }));
};

const handleSubmit = (e) => {
e.preventDefault();
// Handle submit
};

return (
<Form
name={formData.name}
email={formData.email}
onChange={handleChange}
onSubmit={handleSubmit}
/>
);
}

Why declarative wins:

  • Single source of truth - State lives in parent
  • Predictable data flow - Props down, events up
  • Easier testing - Just pass props and assert
  • Better React DevTools - See state in components tree
  • Less coupling - Form doesn't control parent

When useImperativeHandle is Appropriate​

// βœ… GOOD - Expose minimal, high-level imperative API
const VideoPlayer = forwardRef(({ src }, ref) => {
const videoRef = useRef();

useImperativeHandle(ref, () => ({
// Only expose operations that MUST be imperative
play: () => videoRef.current.play(),
pause: () => videoRef.current.pause(),
seek: (time) => videoRef.current.currentTime = time,

// Don't expose: volume, playback rate, etc.
// Use props for those (declarative)
}));

return <video ref={videoRef} src={src} />;
});

// Use props for declarative state
function VideoApp() {
const [volume, setVolume] = useState(0.5);
const playerRef = useRef();

return (
<div>
{/* Declarative volume control */}
<input
type="range"
value={volume}
onChange={e => setVolume(e.target.value)}
/>

{/* Imperative playback control */}
<button onClick={() => playerRef.current.play()}>Play</button>
<button onClick={() => playerRef.current.pause()}>Pause</button>

<VideoPlayer ref={playerRef} src="video.mp4" volume={volume} />
</div>
);
}

Performance Impact​

// ❌ SLOW - Re-creating handle object every render
const ExpensiveComponent = forwardRef(({ data }, ref) => {
const [state, setState] = useState(0);

useImperativeHandle(ref, () => ({
method1: () => console.log(data), // Depends on data
method2: () => console.log(state), // Depends on state
method3: () => { /* ... */ }
})); // No deps - recreates EVERY render!

return <div>{state}</div>;
});

// βœ… FAST - Memoized with proper dependencies
const OptimizedComponent = forwardRef(({ data }, ref) => {
const [state, setState] = useState(0);

useImperativeHandle(ref, () => ({
method1: () => console.log(data),
method2: () => console.log(state),
method3: () => { /* ... */ }
}), [data, state]); // Only recreate when deps change

return <div>{state}</div>;
});

// βœ… EVEN FASTER - Stable methods with refs
const FastestComponent = forwardRef(({ data }, ref) => {
const [state, setState] = useState(0);
const dataRef = useRef(data);
const stateRef = useRef(state);

useEffect(() => {
dataRef.current = data;
stateRef.current = state;
}, [data, state]);

useImperativeHandle(ref, () => ({
method1: () => console.log(dataRef.current),
method2: () => console.log(stateRef.current),
method3: () => { /* ... */ }
}), []); // Never recreates - stable reference

return <div>{state}</div>;
});

Bundle Size Impact​

// forwardRef adds ~200 bytes
// useImperativeHandle adds ~300 bytes
// Total overhead: ~500 bytes (minimal)

// But improper usage can bloat code:

// ❌ BAD - Lots of boilerplate
const MyComponent = forwardRef((props, ref) => {
useImperativeHandle(ref, () => ({
method1: () => {},
method2: () => {},
method3: () => {},
// 20 methods = 2KB+ of imperative API
}));

return <div />;
});

// βœ… GOOD - Minimal API surface
const MyComponent = forwardRef((props, ref) => {
useImperativeHandle(ref, () => ({
// Only essential methods
focus: () => {},
reset: () => {}
}));

return <div />;
});

πŸ’¬ Explain to Junior: forwardRef and useImperativeHandle Simply​

The Library Analogy​

Imagine you're building a library (component library):

Without forwardRef:

  • You write a book (component)
  • Readers (parent components) can read it but can't add bookmarks
  • If they want to mark a page (ref a DOM node), they can't because you didn't provide bookmarks

With forwardRef:

  • You provide a bookmark ribbon in your book
  • Readers can place bookmarks wherever they want
  • They have direct access to pages (DOM nodes)

With useImperativeHandle:

  • Instead of letting readers bookmark any page, you provide a special index card
  • The index card says "Chapter 3 is on page 47" (controlled API)
  • Readers can only access what you tell them (encapsulation)
  • You control what they can and can't do

Simple Code Examples​

Example 1: Basic forwardRef

// WITHOUT forwardRef - doesn't work!
function MyInput(props) {
return <input {...props} />;
}

function Parent() {
const inputRef = useRef();

// ❌ Warning! Can't attach ref to function component
return <MyInput ref={inputRef} />;
}

// WITH forwardRef - works!
const MyInput = forwardRef((props, ref) => {
return <input ref={ref} {...props} />;
});

function Parent() {
const inputRef = useRef();

useEffect(() => {
inputRef.current.focus(); // βœ… Works!
}, []);

return <MyInput ref={inputRef} />;
}

Think of it like: A mailbox (ref) needs to be attached to a house (component). forwardRef is like adding a mailbox hook to your custom house design.

Example 2: useImperativeHandle

// WITHOUT useImperativeHandle - exposes everything
const MyInput = forwardRef((props, ref) => {
return <input ref={ref} {...props} />;
});

function Parent() {
const inputRef = useRef();

const handleClick = () => {
// Parent can do ANYTHING to the input
inputRef.current.focus(); // βœ… OK
inputRef.current.value = 'hacked'; // ⚠️ Breaking encapsulation
inputRef.current.remove(); // ❌ Dangerous!
};

return <MyInput ref={inputRef} />;
}

// WITH useImperativeHandle - controlled access
const MyInput = forwardRef((props, ref) => {
const inputRef = useRef();

useImperativeHandle(ref, () => ({
// Only expose these safe methods
focus: () => inputRef.current.focus(),
clear: () => inputRef.current.value = ''
}));

return <input ref={inputRef} {...props} />;
});

function Parent() {
const inputRef = useRef();

const handleClick = () => {
inputRef.current.focus(); // βœ… Allowed
inputRef.current.clear(); // βœ… Allowed
inputRef.current.value = 'hacked'; // ❌ Doesn't work - not exposed
inputRef.current.remove(); // ❌ Doesn't work - not exposed
};

return <MyInput ref={inputRef} />;
}

Think of it like: A TV remote control. Instead of letting people open the TV and touch the circuits (raw DOM access), you give them a remote with limited buttons (custom API).

Common Beginner Mistakes​

Mistake 1: Forgetting forwardRef wrapper

// ❌ WRONG
function MyComponent(props, ref) {
return <div ref={ref}>Content</div>;
}

// βœ… CORRECT
const MyComponent = forwardRef((props, ref) => {
return <div ref={ref}>Content</div>;
});

Mistake 2: Not using useImperativeHandle correctly

// ❌ WRONG - Missing useImperativeHandle
const MyComponent = forwardRef((props, ref) => {
ref.current = { // Can't assign to ref.current directly!
focus: () => {}
};

return <div />;
});

// βœ… CORRECT
const MyComponent = forwardRef((props, ref) => {
useImperativeHandle(ref, () => ({
focus: () => {}
}));

return <div />;
});

Mistake 3: Using when not needed

// ❌ WRONG - Overcomplicating simple case
const Button = forwardRef((props, ref) => {
useImperativeHandle(ref, () => ({
click: () => ref.current.click()
}));

return <button ref={ref} {...props} />;
});

// βœ… CORRECT - Just use normal props
function Button({ onClick, children }) {
return <button onClick={onClick}>{children}</button>;
}

Interview Answer Template​

Question: "Explain forwardRef and useImperativeHandle. When would you use them?"

Answer Structure:

"forwardRef is a React feature that allows a component to forward a ref to a child element. Normally, refs only work on DOM elements like <div> or <input>, but forwardRef lets you pass refs through custom components.

For example, if I create a custom Input component, I'd use forwardRef to let parent components ref the underlying <input> element:

const MyInput = forwardRef((props, ref) => {
return <input ref={ref} {...props} />;
});

useImperativeHandle is used together with forwardRef to customize what the ref exposes. Instead of exposing the raw DOM node, you can expose a custom object with specific methods:

const MyInput = forwardRef((props, ref) => {
const inputRef = useRef();

useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus(),
clear: () => inputRef.current.value = ''
}));

return <input ref={inputRef} {...props} />;
});

I'd use forwardRef when building reusable library components that need to expose DOM nodes. I'd add useImperativeHandle when I want to control exactly what the parent can access - for example, in a modal component, I might expose open(), close(), and focus() methods but hide internal implementation details.

The key benefit is encapsulation - the parent gets a clean API without knowing how the component works internally. However, I prefer declarative solutions (props/callbacks) when possible, and only use imperative APIs when absolutely necessary."

Mental Model​

Before using forwardRef/useImperativeHandle, ask:

  1. βœ… Can I do this with props? β†’ Use props instead
  2. βœ… Can I do this with callbacks? β†’ Use callbacks instead
  3. βœ… Can I do this with state lifting? β†’ Lift state instead
  4. βœ… Is this truly imperative? β†’ Consider forwardRef
  5. βœ… Do I need to hide internals? β†’ Add useImperativeHandle
  6. βœ… Am I building a library component? β†’ Good use case

Rule of thumb:

  • Use forwardRef when you need to pass refs through components
  • Add useImperativeHandle when you want to customize what the ref exposes
  • Prefer declarative solutions (props/state) whenever possible
  • Only use imperative APIs for truly imperative operations (focus, scroll, play/pause, etc.)

</details>


Source: refs-01-dom-access.md