React useRef Beyond DOM Manipulation: The Patterns Worth Knowing

React useRef Beyond DOM Manipulation: The Patterns Worth Knowing
Brandon Perfetti

Technical PM + Software Engineer

Topics:ReactHooksuseRef
Tech:JavaScriptHooks API

useRef is often introduced as "a way to access DOM nodes." That's true, but it's only the smallest part of what refs enable. Over years of React development, teams discover a set of patterns that turn useRef into a reliable tool for solving stale closure problems, persisting mutable values between renders, interfacing with non-React libraries, and creating tightly controlled imperative APIs. This article distills those patterns, explains when to use them, and gives concrete, implementation-forward examples you can drop into your codebase.

1) Ref vs State: When to prefer a ref

React state triggers renders; refs do not. That simple distinction is the basis for all useRef patterns. Use state when the UI must reflect a change. Use a ref when you need to persist a value across renders but changing it shouldn't cause a render.

Common examples: storing previous values for comparison, keeping timers or subscription handles, caching expensive values that don't require UI updates, and maintaining mutable counters used only by event handlers.

  • State: drives rendering, use for UI-driven values.
  • Ref: mutable container, survives renders without re-rendering.
  • If you find yourself calling setState purely to store an object that never affects UI, prefer a ref.

2) Storing the previous value

One of the most common and elegant useRef uses is remembering the previous prop or state value for comparison in effects or event handlers. It avoids extra state and extra renders.

Implementation: use useRef to hold the previous value, update it in an effect after rendering, and read it from handlers or effects that run during the render cycle. This pattern avoids stale reads and is straightforward.

  • Pattern (previous prop):
  • const prev = useRef(); useEffect(() => { prev.current = props.value; }); // use prev.current inside other effects or callbacks
  • Update in an effect so prev.current holds the value from the previous render.

3) Persisting mutable values without re-render (timers, counters, caches)

Refs are ideal for mutable handles like timers, intervals, or cache objects used by event handlers. Because updates to ref.current don't rerender, you avoid performance costs and infinite loops that could happen when state is misused.

Example: cancelable polling logic. The ref stores the interval id and a flag indicating mounted state. Handlers can safely read and update the ref without worrying about extra renders or stale closures.

  • Timer example:
  • function usePolling(fn, intervalMs) { const timerRef = useRef(null); useEffect(() => { timerRef.current = setInterval(fn, intervalMs); return () => clearInterval(timerRef.current); }, [fn, intervalMs]); } // fn can be stable or wrapped by useCallback; otherwise use a ref for latest fn
  • Mutable cache example: store a Map in a ref instead of in state to avoid rerenders when you add/remove items that don't affect UI directly.

4) Keeping the latest value for event handlers (avoid stale closures)

A recurring problem: closures in event handlers capture stale props or state. Instead of re-creating handlers every render or adding many dependencies to useEffect, store the latest value in a ref and read it in the handler. This keeps handlers stable while ensuring they always access the current value.

Implementation combines two steps: keep the latest value in a ref via effect, and use a stable handler that reads ref.current.

  • Pattern:
  • const latestValueRef = useRef(value); useEffect(() => { latestValueRef.current = value; }, [value]); const handler = useCallback(() => { doSomethingWith(latestValueRef.current); }, []);
  • Benefits: stable identity for handler (good for dependencies and performance), no stale reads, minimal re-renders.

5) Integrating third-party libraries and non-React state

Third-party libraries often expect mutable objects, imperative APIs, or DOM nodes; refs are the bridge between React's declarative world and these imperative contracts. You can hand a ref to a library and update or read it without disturbing React's render cycle.

Common strategies: store the library instance in a ref, expose DOM nodes via callback refs for libraries that operate on nodes, and ensure cleanup happens in useEffect to avoid memory leaks.

  • Store instance:
  • const libRef = useRef(null); useEffect(() => { libRef.current = ThirdPartyLib.create(node); return () => libRef.current?.destroy(); }, []);
  • Callback ref for DOM nodes:
  • const nodeRef = useCallback(node => { if (node) thirdParty.attach(node); else thirdParty.detach(); }, []);
  • Tip: prefer callback refs for dynamic attachment/detachment logic; they give precise lifecycle control.

6) useImperativeHandle: controlled imperative APIs

When building reusable components, sometimes imperative methods (focus, scroll, reset) are convenient. forwardRef + useImperativeHandle lets you expose a custom API while keeping internal implementation private. This is safer and clearer than letting consumers access internals directly.

Example: a TextInput that exposes focus() and clear() methods via a ref. The implementation wraps internal refs and only exposes the intended methods.

  • Pattern:
  • const MyInput = forwardRef((props, ref) => { const inputRef = useRef(); useImperativeHandle(ref, () => ({ focus: () => inputRef.current?.focus(), clear: () => { inputRef.current.value = ''; } })); return <input ref={inputRef} {...props} />; });
  • Guidelines: expose a minimal API, avoid leaking internals, document the imperative methods.

7) Advanced tricks and pitfalls

There are several advanced patterns and common mistakes worth knowing. First, don't read or write refs during render for values that affect rendering—doing so breaks render purity and can make behavior unpredictable.

Second, be careful with server-side rendering: refs are null on the server. Guard against accessing DOM or library instances during render or in code that runs on the server.

Third, avoid overusing refs as a replacement for state. If the UI needs to change when a value updates, use state so React can reconcile properly.

  • Avoid mutating ref during render; use effects to apply side effects.
  • When using refs for shared mutable state across components, consider contexts or controlled components to keep reasoning simple.
  • Debugging tip: log ref.current inside effects to inspect lifecycle changes; don't rely on console logs in render for mutated refs since render order can mislead you.

Conclusion

useRef unlocks a set of practical patterns that go well beyond querying DOM nodes. When used intentionally it helps you avoid unnecessary renders, fix stale closure bugs, integrate smoothly with imperative third-party APIs, and provide controlled imperative interfaces. The core rules are simple: choose refs when you need stable, mutable storage that doesn't affect rendering; keep side effects in effects; and expose minimal imperative APIs when necessary. Adopt these patterns incrementally in places where state-based solutions cause performance or complexity issues.

Action Checklist

  1. Refactor a component that uses state purely for non-UI storage (timers, caches) into a ref-based implementation and measure render count before/after.
  2. Implement a custom hook that provides the 'latest value' ref pattern to eliminate stale closures in event handlers across your codebase.
  3. Wrap a native third-party UI widget with forwardRef and useImperativeHandle to give consumers a clean imperative API without exposing internal DOM nodes.
  4. Create a short checklist for your team: when deciding between state and ref, ask if the value affects the UI, whether it needs to trigger re-render, and who owns the lifecycle.