
Creating good separation between modules in our application is a key factor for a quality codebase.
In React, it’s straightforward to pass values down to a component and events out.
But this is not the case with passing events in, and this can introduce unnecessary complexity.
In this article I would like to show you how I use useImperativeHandle
in production.
First I would like to briefly go over what this hook does, then I will show you an example of how to use it
to simplify component APIs.
What is useImperativeHandle?
Refs in React let you have access to a mutable object in your component. It’s value lives outside the component’s reactivity and won’t trigger updates. It can also be used to get access to DOM elements, you have probably seen something like this:
import { useEffect, useRef } from "react";
export const DomRef = () => {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
ref.current?.focus();
}, []);
return <input ref={ref} />;
};
In jsx ref
is a special prop.
When you pass a ref to a component, it’s current
value is set to an object by the component after the first render.
This exposes an API that we can use to interact with the component.
In the above example we can call the focus()
method to move focus to the input.
This lets us pass events in to a component, which otherwise is not trivial to do.
I will get back to this later, first let’s see how we can define such APIs.
export interface ControllableRef {
exposedValue: boolean;
sendEventIn: () => void;
}
const ControllableComponent = forwardRef<ControllableRef>((_, ref) => {
const [value, setValue] = useState(false);
useImperativeHandle(
ref,
() => ({
exposedValue: value,
sendEventIn: () => {
alert("event inside");
},
}),
[value],
);
return (
<input
type="checkbox"
checked={value}
onChange={() => setValue((prev) => !prev)}
></input>
);
});
We need to use the built-in forwardRef
function, because normally
we don’t have access to the special ref
prop.
It accepts a render
function that looks very similar to a function component.
The only exception is that we will receive a ref
object as the second argument.
We use ControllableRef
interface as a generic to define what values we want to expose.
useImperativeHandle
expects 3 arguments:
ref
: theref
we received fromforwardRef
createHandle
: a function that will create the exposed objectdependecies
: and an optional dependency array.
In this example, we have a checkbox component, and we are exposing the current value of the checkbox and a function that can trigger events inside the component. Let’s see how to use the exposed API from another component!
const ImperativeHandle = () => {
const controllRef = useRef<ControllableRef>(null);
const handleAlert = () => {
alert(controllRef.current?.exposedValue);
};
const handleEventIn = () => {
controllRef.current?.sendEventIn();
};
return (
<>
<Button onClick={handleEventIn}>event</Button>
<Button onClick={handleAlert}>alert</Button>
<ControllableComponent ref={controllRef} />
</>
);
};
Initially controllRef.current
is set to null
, then useImperativeHandle
inside the component assigns it the
value we returned by the createHandle
function. From this point we can access the value of the checkbox through
exposedValue
and trigger an alert inside the component with sendEventIn
.
Now that we know how useImperativeHandle
works, I will go over a real wold example and show you how to improve it.
A typical modal
Here we have a simple modal with an input element and two buttons.
export const BasicExample = () => {
const [visible, setVisible] = useState(false);
const [text, setText] = useState("");
const [result, setResult] = useState("");
const handleSubmit = () => {
setResult(text);
setVisible(false);
};
const handleCancel = () => {
setVisible(false);
};
const handleOpen = () => {
setText("");
setVisible(true);
};
return (
<>
result: {JSON.stringify(result)}
<Button onClick={handleOpen}>open basic</Button>
{visible && (
<Modal onClose={handleCancel}>
<input value={text} onChange={(e) => setText(e.target.value)} />
<Button onClick={handleSubmit}>submit</Button>
<Button onClick={handleCancel}>cancel</Button>
</Modal>
)}
</>
);
};
Since we need access to the text input value, we lifted the text
state up to the parent component.
We want to trigger opening the component from the parent, so we had to lift the visible
state up as well.
We have the visible
and text
states polluting our parent component, when the only thing we care about in
this component is the result
.
API we have vs API we want
We ended up with an API for the modal, like the one we see on the left in the diagram below. We could have a bunch of ways to
close the modal, such as clicking outside the dialog, hitting an X icon or having a cancel button. We would have to handle
all of these in the parent component, because we have the visible
state defined there. But all of this is
implementation details.
On the right we can see the absolute minimal and optimal API. We tell the modal that it should prompt the user, the user interacts with the input and might or might not submit a value. All the implementation details and UI states are encapsulated inside the modal component.
A better modal
In the example below we have a modal implementation that uses useImerativeHandle
to expose an openModal
function.
When it’s called we show the modal and reset the text state.
We don’t share the visible
or text
states with the outside world. Compared to the other implementation, here we
wouldn’t need to change the parent if we introduced new ways to close the modal.
If the user decides to submit the text, we call the onResult
prop and notify the parent component about the new value.
interface ModalControls {
openModal: () => void;
}
interface ModalProps {
onResult: (result: string) => void;
}
const MyModal = forwardRef<ModalControls, ModalProps>((props, ref) => {
const [visible, setVisible] = useState(false);
const [text, setText] = useState("");
const handleSubmit = () => {
props.onResult(text);
setVisible(false);
};
const handleCancel = () => {
setVisible(false);
};
useImperativeHandle(
ref,
() => ({
openModal() {
setVisible(true);
setText("");
},
}),
[],
);
return (
<>
{visible && (
<Modal onClose={handleCancel}>
<input value={text} onChange={(e) => setText(e.target.value)} />
<Button onClick={handleSubmit}>submit</Button>
<Button onClick={handleCancel}>cancel</Button>
</Modal>
)}
</>
);
});
In the parent component we have a ref
that we use to access the component and a result
state to store the submitted
value. That’s it. We call modalRef.current?.openModal
to prompt the user and if he or she decides to submit, then
the handleResult
function is called, and we can decide what to do with the new value.
export const RefExample = () => {
const [result, setResult] = useState("");
const modalRef = useRef<ModalControls>(null);
const handleOpen = async () => {
modalRef.current?.openModal();
};
const handleResult = (modalResult: string) => {
setResult(modalResult)
}
return (
<>
result: {JSON.stringify(result)}
<Button onClick={handleOpen}>open basic</Button>
<MyModal ref={modalRef} onResult={handleResult} />
</>
);
};
We trigger and then handle events in two different places.
One part of this business logic lives in handleOpen
and the other part is in handleResult
.
This is fine, but it would be even better if we could write everything in one place.
Handling this interaction with async/await
would allow us to do just that.
Using such API would look something like this:
const handleOpen = async () => {
const modalResult = await modalRef.current?.getResponse();
if (modalResult !== undefined) {
setResult(modalResult);
}
};
I have a promise based implementation on the with-promise
branch in the GitHub repository linked in the summary.
Summary
We can see that in exchange for a bit of extra complexity, we can greatly simplify the APIs of our components. This is a basic example containing just a couple of state variables, but for example we could use the same ideas with a complex multistep form. The API could still be as simple as the one above: we could encapsulate all the steps that the user needs to take and only notify the parent, when he or she finishes.
A demo application featuring these examples is available in this repository.