Context, refs and escape hatches
Providers and consumers without prop drilling, memoising a context value, refs for values and DOM nodes, portals, and imperative handles.
Context without prop drilling
import { createContext, useContext, useMemo, useState } from 'react'
const ThemeContext = createContext(null)
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light')
// memoise the value: a fresh object on every render re-renders every consumer
const value = useMemo(() => ({ theme, setTheme }), [theme])
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export function useTheme() {
const context = useContext(ThemeContext)
if (context === null) throw new Error('useTheme requires a ThemeProvider above it')
return context
}
function ThemeToggle() {
const { theme, setTheme } = useTheme() // no props were threaded through
return (
<button type="button" onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
{theme === 'light' ? 'Dark mode' : 'Light mode'}
</button>
)
}- Contexts transport a value down the tree; they do not store or change it. The state still lives in a component, usually the provider itself.
- Consumers re-render whenever the value identity changes, so an unmemoised object literal defeats the purpose of the provider.
- Split contexts by update frequency: a tall list of changing values in one context means every consumer re-renders on every keystroke.
- A custom
use-prefixed hook that throws when the context is missing turns anullcrash three levels down into a clear message at the call site. - Keep the provider near the branch that needs it. One provider at the root for everything couples unrelated parts of the app.
Refs: values and DOM nodes
import { useEffect, useRef, useState } from 'react'
function AutofocusInput() {
const inputRef = useRef(null)
useEffect(() => {
inputRef.current?.focus() // the node exists only after mount
}, [])
return <input ref={inputRef} />
}
// a ref holds a value across renders without causing one
function Stopwatch() {
const startedAt = useRef(Date.now())
const [ticks, setTicks] = useState(0)
useEffect(() => {
const id = setInterval(() => setTicks(value => value + 1), 1000)
return () => clearInterval(id)
}, [])
return <p>{ticks}s since {new Date(startedAt.current).toLocaleTimeString()}</p>
}
// React 19: ref is a normal prop on a function component, so it can be forwarded
// by destructuring instead of the deprecated forwardRef wrapper
function TextField({ ref, ...rest }) {
return <input ref={ref} {...rest} />
}A ref is the right tool when the value must survive a render without triggering one: a DOM node, a timer id, the previous value of a prop, a mutable instance. Everything the UI renders should be state, because a change to a ref is invisible to React.
Portals and imperative handles
import { createPortal } from 'react-dom'
import { useImperativeHandle, useRef, useState } from 'react'
function Modal({ open, onClose, children }) {
const [closed, setClosed] = useState(false)
// escaping overflow and stacking contexts is the reason to portal at all
return createPortal(
<div className="overlay" onClick={onClose}>
<div
role="dialog"
aria-modal="true"
onClick={event => event.stopPropagation()}
>
{children}
</div>
</div>,
document.body
)
}
// an imperative handle exposes a narrow API instead of the whole DOM node
function VideoPlayer({ ref }) {
const videoRef = useRef(null)
useImperativeHandle(ref, () => ({
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause()
}), [])
return <video ref={videoRef} src="/clip.mp4" />
}
function Player() {
const playerRef = useRef(null)
return (
<>
<VideoPlayer ref={playerRef} />
<button type="button" onClick={() => playerRef.current?.pause()}>Pause</button>
</>
)
}- A portal changes where the DOM node sits, not where it sits in the React tree, so context, event bubbling and prop flow are unchanged.
- Because the tree is unchanged, a click inside a portal still reaches handlers above it in the React hierarchy - the DOM parent is not what decides that.
- Layout that assumed the overlay was inside a positioned parent breaks: portal content must style itself, usually with
position: fixed. - Prefer props and state over an imperative handle. Reach for one only for behaviour that has no declarative form, such as
play(),focus()or a scroll position.
⚠️
Treat a ref as an escape hatch. If a value affects what is rendered, it belongs in state; a ref that drives the UI produces component trees that do not update, and the bug looks like a React failure rather than a misuse of the API.
FAQ
Is context a replacement for a state library?
No. Context transports a value; it does not manage updates, selectors or persistence. It replaces prop drilling for low-frequency values, and stores handle shared state that changes often.
Why is ref.current null when I read it?
Refs are populated after the DOM is committed, so reading one during render is too early. Read it in an effect or an event handler, and keep the optional chaining since the node can be unmounted.
Related
Custom hooks and sharing logic Component composition and reusable APIs
Last refreshed 2026-09-18.