Let’s build a case
Imagine you have a counter—a classic, simple counter component.
function Counter(){
const [counter, setCounter] = useState(0)
const handleIncrement = ()=> setCounter(counter + 1)
return (
<>
<p>Counter: {counter}</p>
<button onClick={handleIncrement}>Inc</button>
</>
)
}
function App(){
return (
<>
<Counter />
</>
)
}
Now, for some reason, you’re asked to add a second button that increments the counter but is placed somewhere far away from the Counter component—although still on the same screen.
There are a few options to achieve this, for example:
- Lift the counter state up to the parent component.
- Use context to share the state.
- Use useRef, useImperativeHandle, and forwardRef.
We’ll explore the third option: creating an interface to expose the counter’s methods via refs.
useRef/useImperativeHandle/forwardRef
Our goal is to create an interface that exposes a method using refs. This allows the parent component to access and modify the child component’s state.
Here’s how to do it:
Wrap the Counter component with forwardRef (not needed in React 19!) and use useImperativeHandle to expose the handleIncrement method via the passed ref.
const Counter = forwardRef((_, ref) => {
const [counter, setCounter] = useState(0)
const handleIncrement = ()=> setCounter(counter + 1)
useImperativeHandle(ref, ()=> ({
increment: handleIncrement
}))
return (
<>
<p>Counter: {counter}</p>
<button onClick={handleIncrement}>Inc</button>
</>
)
})
Next, modify the parent component to pass a ref to Counter and add a button to increment the counter’s state from elsewhere:
function App(){
const counterRef = useRef(null)
return (
<>
<Counter ref={counterRef}/>
{/* somewhere bit far away... */}
<button onClick={()=> counterRef.current?.increment()}>Increment from here too!</button>
</>
)
}
And that’s it!
Adding a Reset Functionality
Now, let’s add a reset button to reset the counter.
To do this, create a new handler in the Counter component and expose it through the ref interface:
const Counter = forwardRef((_, ref) => {
const [counter, setCounter] = useState(0)
const handleIncrement = ()=> setCounter(counter + 1)
useImperativeHandle(ref, ()=> ({
increment: handleIncrement,
reset: ()=> setCounter(0)
}))
return (
<>
<p>Counter: {counter}</p>
<button onClick={handleIncrement}>Inc</button>
</>
)
})
Then, use this new method in the parent component:
function App(){
const counterRef = useRef(null)
return (
<>
<Counter ref={counterRef}/>
{/* somewhere bit far away... */}
<button onClick={()=> counterRef.current?.increment()}>Increment from here too!</button>
<button onClick={()=> counterRef.current?.reset()}>Reset</button>
</>
)
}
As you can see, I didn’t add a button to reset the counter inside of the Counter component, why so? Nothing special, I just wanted to show a different implementation that might fit other use cases.
Why Use This Pattern?
This pattern opens up new possibilities for component communication without requiring state lifting or context. However, there are trade-offs.
Caveat: This Pattern Breaks React’s Declarative Model
React is fundamentally declarative—you describe what the UI should look like, and React handles the rest. Introducing an imperative approach with useImperativeHandle can:
- Increase Complexity: You must manually define and handle actions.
- Encourage Side Effects: This can lead to less predictable, harder-to-debug code.
While powerful, use this pattern judiciously, especially when simpler alternatives like state lifting or context would suffice.
Source link
lol