example, complex DOM interactions often involve HTML5 APIs like Canvas. This pattern demonstrates attaching a ref to manage a drawing context without triggering re-renders.
import { useRef, useEffect } from 'react';
interface CanvasProps {
width: number;
height: number;
}
export function DrawingCanvas({ width, height }: CanvasProps) {
// Initialize ref with null; type safety ensures current is HTMLCanvasElement | null
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// Access imperative API only after mount
const context = canvas.getContext('2d');
if (!context) return;
// Perform imperative drawing operations
context.fillStyle = '#3b82f6';
context.fillRect(20, 20, 150, 100);
context.strokeStyle = '#1e40af';
context.strokeRect(20, 20, 150, 100);
// Cleanup logic if necessary
return () => {
context.clearRect(0, 0, width, height);
};
}, [width, height]);
return (
<canvas
ref={canvasRef}
width={width}
height={height}
style={{ border: '1px solid #e5e7eb' }}
/>
);
}
Architecture Decisions:
- Type Safety: The ref is typed as
useRef<HTMLCanvasElement>(null). This prevents accidental access to non-existent elements and provides IDE autocompletion for DOM methods.
- Guard Clauses: The
if (!canvas) return check handles the initial render where current is null. This prevents runtime errors during the commit phase.
- Effect Dependencies: The effect depends on
width and height. If these props change, the canvas redraws. The ref itself is never a dependency, avoiding infinite loops.
Pattern 2: Silent State for Debounce Logic
Storing timer IDs in useState causes re-renders every time the timer is set or cleared. useRef stores the ID silently, enabling efficient debounce implementations.
import { useState, useEffect, useRef } from 'react';
/**
* Custom hook that debounces a value.
* Uses useRef to store the timer ID without triggering re-renders.
*/
export function useDebounce<T>(value: T, delayMs: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
// Ref stores the timer ID; mutation does not cause re-render
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
// Clear existing timer silently
if (timerRef.current) {
clearTimeout(timerRef.current);
}
// Set new timer and store ID in ref
timerRef.current = setTimeout(() => {
setDebouncedValue(value);
}, delayMs);
// Cleanup on unmount or value change
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [value, delayMs]);
return debouncedValue;
}
// Usage Example
export function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
console.log(`Fetching results for: ${debouncedQuery}`);
// Trigger API call here
}
}, [debouncedQuery]);
return (
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Type to search..."
/>
);
}
Architecture Decisions:
- Ref for Timer ID:
timerRef holds the return value of setTimeout. Updating timerRef.current is a direct mutation with no render overhead.
- Separation of Concerns: The hook returns
debouncedValue via useState because the debounced result does need to trigger effects (like API calls). The internal timer management uses useRef for performance.
- Cleanup Safety: The cleanup function checks for
null before clearing, preventing errors if the ref is already cleared.
Pitfall Guide
Production experience reveals recurring anti-patterns when using useRef. Avoid these mistakes to ensure stability and performance.
-
Premature Access in Render Body
- Explanation: Reading
ref.current during the render phase returns null because the DOM node has not been attached yet.
- Fix: Access
ref.current only inside useEffect, event handlers, or callbacks that execute after mount.
-
Expecting UI Updates from Ref Mutation
- Explanation: Changing
ref.current does not trigger a re-render. If you modify a ref and expect the UI to reflect the change, the component will not update.
- Fix: Use
useState for values that influence the JSX output. Reserve useRef for values that are read imperatively or used for side effects.
-
The Dependency Array Trap
- Explanation: Including
ref.current in a useEffect dependency array causes the effect to run on every render or creates stale closure issues. Refs are mutable, so their value can change without React knowing.
- Fix: Never include
ref.current in dependency arrays. If the effect depends on the ref's value, restructure the logic to use state or pass the value as a prop.
-
Memory Leaks with Timers
- Explanation: Storing interval or timeout IDs in refs without proper cleanup leads to memory leaks and unexpected behavior after unmount.
- Fix: Always implement cleanup in
useEffect or component unmount logic to clear timers stored in refs.
-
Closure Staleness with Destructuring
- Explanation: Destructuring
const val = ref.current inside a callback captures the value at creation time. Subsequent ref mutations won't update val.
- Fix: Always access
ref.current directly inside callbacks to ensure you read the latest value.
-
Ref Forwarding Bypass
- Explanation: Passing a ref to a custom component without
forwardRef results in the ref pointing to the component instance rather than the underlying DOM node.
- Fix: Use
React.forwardRef in custom components to expose DOM refs to parent components.
-
Derived State in Ref
- Explanation: Storing derived data in a ref instead of computing it on demand leads to synchronization bugs. If the source data changes, the ref value becomes stale.
- Fix: Compute derived values during render or use
useMemo. Only store values in refs that cannot be derived or are needed for imperative access.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Store Interval ID | useRef | Timer ID is logic-only; UI doesn't change when timer starts/stops. | Zero render cost. |
| User Input Value | useState | Input value drives UI rendering and validation feedback. | Necessary render cost. |
| Previous Prop Value | useRef | Comparison logic needs history without causing re-renders. | Zero render cost. |
| Canvas Context | useRef | Imperative API access required; context object is stable. | Zero render cost. |
| Animation Frame ID | useRef | High-frequency updates; state would cause performance degradation. | Critical performance gain. |
| Form Validation Error | useState | Error message must be displayed to the user. | Necessary render cost. |
Configuration Template
Template 1: usePrevious Hook
A reusable pattern to track previous values without re-renders.
import { useRef, useEffect } from 'react';
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
Template 2: useFocus Hook
A declarative wrapper for imperative focus management.
import { useRef, useEffect } from 'react';
export function useFocus<T extends HTMLElement>(
shouldFocus: boolean
): React.RefObject<T> {
const ref = useRef<T>(null);
useEffect(() => {
if (shouldFocus && ref.current) {
ref.current.focus();
}
}, [shouldFocus]);
return ref;
}
Quick Start Guide
- Import the Hook: Add
import { useRef } from 'react'; to your component file.
- Initialize the Ref: Call
const myRef = useRef<T>(initialValue); where T is the expected type.
- Attach or Assign: Pass
ref={myRef} to a JSX element or assign logic values to myRef.current.
- Access Safely: Read or modify
myRef.current inside useEffect, event handlers, or callbacks.
- Clean Up: If storing timers or subscriptions, implement cleanup in
useEffect return functions.
Mastering useRef transforms it from a simple DOM accessor into a powerful tool for performance optimization and imperative logic. By adhering to these patterns and avoiding common pitfalls, you can build React applications that are both efficient and maintainable.