React Hooks Explained: useImperativeHandle
Anik
Posted on April 13, 2021
Table Of Contents
- A Note From the Author
- Intro
- Unidirectional Data Flow
- Adding Imperative Logic
- Imperative Handling and Function Components
- Use It Wisely
- Additional Reading
A Note From the Author
I've seen some decent explanations here and there on how to use React's useImperativeHandle
hook — Kent C. Dodds' React course has a great short exercise on how to properly use this hook. But I still feel like there's room for more conversation around exactly when to use this hook, because it's the sort of thing you should be doing sparingly and only in certain situations where it's the most logical (or only remaining) choice.
This is my first post here on DEV (✨🥳🎉) and I intend for this to be the first in a series of short articles centered around React and Typescript. I started working with React about four years ago and I'm excited to share with you some of what I've learned since then. If you notice any errors, please do let me know!
Intro
With rare exception, data flow in React apps is unidirectional. Components comprise a hierarchy of parent and child nodes. Child nodes are privy to information and can call functions that have been passed down to them from parent nodes, via a declarative “props” API. Parent nodes, on the other hand, do not have access to (and are not affected by) the internal state of child nodes. Parent nodes also generally do not call functions declared within child components.
Callback functions usually suffice when closer coordination between parent and child nodes is necessary. More intricate situations involving multiple moving parts and dense component hierarchy may call for things like Redux or the built-in Context API. Even so, parent nodes are usually not given direct control over child nodes.
But what about those very rare situations where callbacks, contexts, and whatnot are simply not enough — where the cleanest, most flexible, or perhaps the only option left is to let the parent directly control the child and imperatively dictate what the latter should do? Let’s see how such a situation might arise, and what we can do about it.
Unidirectional Data Flow
Let’s say you’ve been tasked with building a “comments feed” component that will be used in a number of different places throughout several applications. The exact use cases will vary; all you have to work with are the following acceptance criteria:
- Criterion #1: The comments feed should accept a list of existing comments (an array) as one of its props, and should display them.
- Criterion #2: The comments feed should have a form at the bottom which allows the user to add new comments. This form should consist of two fields: one for the user’s name, and one for the new comment itself. At the bottom of the form, there should be a “Submit” button that lets the user request that the new comment be added.
- Criterion #3: When the user clicks the button, the comments feed should send up the information from the new comment form (user’s name and new comment) to the parent component that mounted it. It is that parent component’s responsibility to process the request, update the list of existing comments, and give the updated comment list to the comments feed to display.
Here’s what a very basic implementation of the comments feed might look like (we’ll name this component Comments
):
const Comments = (props: {
comments: [];
onSubmitComment: (name: string, newComment: string) => void;
}) => {
// State management for form
const [values, setValues] = useState({
name: "",
newComment: "",
});
// Handle changes to form fields
function handleChange (event) {
setValues((values) => {
...values,
[event.target.name]: event.target.value,
});
}
// Function that renders content of each comment
function renderComment (comment) { ... }
// Submit comment
function handleSubmit () {
const { name, newComment } = values;
props.onSubmitComment(name, newComment);
}
return (
<>
<ul>
{props.comments.map(renderComment)}
</ul>
<h4>Add a comment</h4>
<form>
<label for="name">Your Name</label>
<input
name="name"
type="text"
value={values.name}
onChange={handleChange}
/>
<label for="newComment">Your Comment</label>
<textarea
name="newComment"
rows={4}
value={values.newComment}
onChange={handleChange}
/>
</form>
<button onClick={handleSubmit}>Submit</button>
</>
);
};
This component expects to be given two props. The first prop, comments
, supplies the list of comments to be displayed. The comments are rendered as list-items within an unordered list. This fulfills criterion #1.
The form allows the user to type in their name and the new comment. There’s a “Submit” button at the bottom of the form that can be clicked to submit the new comment. This fulfills criterion #2.
The second prop supplied to this component is a callback function, onSubmitComment
. This callback function expects two parameters to be passed in: the name of the person submitting the comment, and the comment itself. When the Submit button is clicked, the handleSubmit
function is executed. Inside it, the onSubmitComment
callback function is executed and the values that the user typed into the form are passed in. This is how the Comments
component will “send up” to its immediate parent the new comment that is to be saved. This fulfills the third and final acceptance criterion.
Now let’s see how a “parent” component would implement the Comments
component:
const Article = () => {
// State management
const [comments, setComments] = useState([]);
// Load comments when component mounts
async function loadComments () {
const existingComments = await fetch(...) // API request to get comments
setComments(existingComments); // Store comments in state
}
useEffect(() => {
loadComments();
}, []);
// Event handlers
async function addComment (name: string, newComment: string) {
// API request to persist new comment...
// Optimistic update of comments list...
...
}
return (
<div>
<article>
...
</article>
...
<Comments
comments={comments}
onSubmitComment={addComment}
/>
</div>
);
};
As shown above, the parent component, once mounted, loads the initial set of comments. The comments list, stored in the comments
state variable, is passed down to the Comments
component, which is mounted as a child of this parent component. The addComment()
function is assigned to the onSubmitComment
prop’s value. When the user clicks the “Submit” button, the Comments
component is effectively calling the parent component’s addComment()
function, by way of the onSubmitComment
prop.
This is a very basic example of coordinating parent and child node behavior without violating unidirectional flow. The values in the new comment form, and the submit button, and any interactions thereof, are none of the parent component’s concern. The parent doesn’t directly “reach in” and grab information stored inside the child component. Instead, the parent component gives the child a callback function and expects the child to call said function whenever a new comment is to be added. The parent cannot call the handleSubmit()
function declared inside the Comments
component.
Adding Imperative Logic
If you’ve worked extensively with forms in React apps, you may be familiar with how input
elements expose functions like blur
, focus
, and select
which can be used to programmatically blur or focus a field, or to select all text inside a field, respectively. Normally, when the user clicks inside a field, that field is focused, and when the user moves to another field or clicks outside, that previous field is blurred. But sometimes, it’s necessary to do these things without waiting for user input.
When the user first loads a form inside a page or a dialog, it can be beneficial to the user’s experience to immediately place keyboard focus on the first field in the form (or whichever field the user is expected to start typing in first). Doing so saves the user some time and motor interaction cost otherwise needed to move their mouse cursor to the field and click on it.
There are other situations where you may want to do something like this. If the user attempts to submit a form, but there was an error in one of the fields, it would be really nice if the application automatically focused on the field with the error (and made sure that the field in question had been scrolled into view).
Let’s say that we are given an additional acceptance criterion for our new Comments
component:
- Acceptance Criterion 4: When the comments feed is mounted and made visible to the user, the “Your Name” field should immediately be given keyboard focus.
Revisiting the Comments
component again, we see that the new comments form currently looks like this:
...
<form>
<label for="name">Your Name</label>
<input
name="name"
type="text"
value={values.name}
onChange={handleChange}
/>
<label for="newComment">Your Comment</label>
<textarea
name="newComment"
rows={4}
value={values.newComment}
onChange={handleChange}
/>
</form>
...
We want the first input
, the “Your Name” field, to be immediately focused as soon as the Comments
component mounts. It’s not like we can change the input’s value (or some other prop) and expect the input to auto-focus again. The parent (in this case, the Comments
component) node simply needs a way to directly (imperatively) call the focus function on behalf of the child (the input
).
This is one of the simplest examples of imperative logic in action. We've finally encountered a situation where it's actually called for!
In order to get access to that function, though, we need a way to reference the specific input element in question. In React, we do this by using a ref (we’ll call it nameInputRef
):
const Comments = ...
...
const nameInputRef = useRef();
...
return (
...
<form>
<label for="name">Your Name</label>
<input
name="name"
type="text"
value={values.name}
onChange={handleChange}
ref={nameInputRef}
/>
...
</form>
...
);
};
The focus()
function can now be accessed via nameInputRef.current
. With the help of a useEffect
hook, we can call this function after the Comments
component is first mounted and rendered.
...
const nameInputRef = useRef();
useEffect(() => {
if (nameInputRef.current) {
nameInputRef.current.focus();
}
}, []);
...
Imperative Handling and Function Components
Let’s say our Comments
component is now being used in numerous applications. On some pages, it’s at the bottom. On other pages, it’s placed off to the side. It’s also inside a few dialogs and tooltips. In all these cases, it is immediately rendered with the “Your Name” field auto-focused. However, as its usage increases, developers start to find themselves in situations where the “auto-focus first field on initial mount” behavior is insufficient.
One day, a developer is tasked with implementing your comments feed in a slightly different manner. At the bottom of the page, there’s a set of collapsible accordion tabs, each with different content within. One of these accordion tabs contains the comments feed. To view the comments feed, the user must expand the accordion tab by clicking “View Comments”, like so:
The developer working on this was told that whenever the comments section is expanded, the “Your Name” field must always be initially auto-focused. They achieved this by mounting the comments feed only when the accordion tab is expanded, and unmounting it when it is collapsed. This way, expanding the accordion tab always results in the comments feed being freshly re-mounted. Whenever this happens, the useEffect
side effect is executed, and the “Your Name” field is once again auto-focused.
The project manager and UX lead, however, were not satisfied with this workaround. You see, if a user begins typing a comment and then collapses the comments section, whatever they painstakingly typed in will be instantly annihilated when the comments feed is unmounted. After expanding the comments section again, they will find to their dismay that everything they wrote is now lost to the sands of time.
There are some other ways of getting around this issue: you could temporarily store (in local storage, for instance) whatever the user typed in. These stored values could then be passed in to the comments feed as “initial values” when the component is re-mounted.
But for the sake of our discussion, what if we could avoid adding more props and making significant changes to the Comments
component by doing something similar to what we did earlier with the input
field? What if the Comments
component contained a function to focus on the “Your Name” field, and exposed this function to any parent implementing it, just like the focus()
function exposed by the input
element? This function could then be imperatively called by any parent, whenever necessary.
Step 1: Define a function in the child component
Let’s first define said function inside the Comments
component. We’ll call it focusOnForm()
:
const Comments = ...
...
const nameInputRef = useRef();
function focusOnForm () {
if (nameInputRef.current) {
nameInputRef.current.focus();
}
}
useEffect(focusOnForm, []);
...
All we’ve really done so far is move all the logic previously defined inside the useEffect
hook to its own separate function. We are now calling that function inside the useEffect
.
Remember how we needed to reference the specific input
element by way of a ref
in order to access its focus()
function? We’ll need to do something similar in order to allow the parent component to access the focusOnForm()
function inside the Comments
component.
Step 2: Define a ref in the parent component and pass it to the child
Let’s go back up to the parent now. First, we’ll define a new ref, called commentsFeedRef
. Then, we’ll assign the ref to the Comments
component, via the ref
prop, just as we did with the input
element:
const Article = () => {
...
const commentsFeedRef = useRef();
...
return (
...
<Comments
comments={comments}
onSubmitComment={addComment}
ref={commentsFeedRef}
/>
);
};
If this was 2018, and our Comments
component was a class component, this would be perfectly fine and we would be well on our way. But this is the f u t u r e, man — the Comments
component is a function component. And unlike class components, function components do not have an associated component instance when they are mounted. In other words, there is no way to access some “instance” of a function component via a default ref
property. There’s a little more work we must do first.
Simply adding a ref
property to the existing props on the Comments component will not work either, by the way, so the following approach is also incorrect:
const Comments = (props: {
comments: [];
onSubmitComment: (name: string, newComment: string) => void;
ref,
}) => ...
Instead, we have to use the forwardRef
feature provided by React in order to pass a ref to our function component.
Step 3: Use forwardRef to allow a ref to be passed to the child
There are a few different ways of doing this but here’s the approach I usually prefer, as it’s pretty clean and easy to follow. We first need to define the component as a named function instead of an anonymous function assigned to a constant:
function Comments (
props: {
comments: [];
onSubmitComment: (name: string, newComment: string) => void;
}
) {
...
function focusOnForm () { ... }
...
}
Let’s say we were previously exporting this component as a module-level default export:
export default Comments;
We now need to first pass the Comments
component to the forwardRef
higher-order component, and then export the result:
export default React.forwardRef(Comments);
Next, we’ll add the ref
property to the Comments
component. Notice, however, that the ref
property is kept separate from the main component props:
function Comments (
props: {
comments: [];
onSubmitComment: (name: string, newComment: string) => void;
},
ref
) {
...
function focusOnForm () { ... }
...
}
The parent component can now pass a ref to the Comments
component, and use it to call the focusOnForm()
function. When we call it, we’ll probably do something like this:
...
commentsFeedRef.current.focusOnForm();
...
But this still won't work. What gives?
Well, the ref's current
property doesn't actually have the focusOnForm
function in it yet. We first need to define exactly what gets exposed via the current
property.
Step 4: Expose function(s) via passed ref, with useImperativeHandle
We’ll accomplish that with useImperativeHandle
:
function Comments (
props: {
comments: [];
onSubmitComment: (name: string, newComment: string) => void;
},
ref
) {
...
function focusOnForm () { ... }
useImperativeHandle(
// Parameter 1: the ref that is exposed to the parent
ref,
// Parameter 2: a function that returns the value of the ref's current property,
// an object containing the things we're trying to expose (in this case, just
// one function)
() => {
return {
focusOnForm: focusOnForm,
}
}
);
...
}
We’re passing two parameters into useImperativeHandle
. The first parameter simply indicates the ref that is being exposed to the parent.
In the second parameter, we pass a function that returns an object containing the various functions and properties we are trying to expose to the parent. useImperativeHandle
will return this object when the parent accesses the current
property of the ref passed in as the first parameter.
We can simplify it, like so:
useImperativeHandle(
ref,
() => ({
focusOnForm,
})
);
There’s actually a third, optional parameter. You can pass in an array of dependencies, and useImperativeHandle
will recalculate what is to be returned when any of those dependencies change. This can be useful if anything you’re returning is being influenced by the child component’s state; for instance:
const [someValue, setSomeValue] = useState<number>(...);
...
useImperativeHandle(
ref,
() => ({
someFunction: (value) => value * someValue,
}),
[someValue]
);
For now, though, we won’t be needing that.
Now, when the Comments
component is passed a ref, it will immediately assign an object to the value of the ref’s current
property. For now, this object only contains the focusOnForm()
function.
Step 5: Call function(s) exposed by child, via the ref passed to the child
Going back to the parent component, we can see how the focusOnForm()
function, defined inside the child component, can now be called inside the parent:
const Article = () => {
...
const commentsFeedRef = useRef();
...
function focusOnNewCommentForm () {
if (commentsFeedRef.current) {
commentsFeedRef.current.focusOnForm();
}
}
...
return (
...
<Comments
comments={comments}
onSubmitComment={addComment}
ref={commentsFeedRef}
/>
);
};
With this, the developer can now easily call focusOnForm()
whenever necessary, without having to unmount and remount the Comments
component. The showComments
variable shown below controls the expanded / collapsed state of the comments section. A useEffect
hook watches for changes in its value. Whenever its value changes to true
, we'll call focusOnForm()
.
const Article = () => {
...
const [showComments, setShowComments] = useState(false);
useEffect(() => {
if (showComments && commentsFeedRef.current) {
commentsFeedRef.current.focusOnForm();
}
}, [showComments]);
...
return (
...
<Accordion ...>
<Accordion.Tab show={showComments}>
<Comments
comments={comments}
onSubmitComment={addComment}
ref={commentsFeedRef}
/>
</Accordion.Tab />
</Accordion>
);
};
Great! Now the “Your Name” field in the new comment form will always be re-focused whenever the comments feed is displayed again, even though the Comments
component has not been unmounted and re-mounted.
Use It Wisely
At the end of the day, useImperativeHandle
isn’t used very often, and with good reason – it’s an escape hatch, a fire escape, a method of absolute last resort when other options have failed or are simply not viable.
One of the rare spots where I’ve encountered useImperativeHandle
in the wild is when there's some kind of scrollable area and button to let the user scroll all the way back up to the top. It’s simple enough to just get the element in question (either via ref, or with a document.querySelector
query), and call scrollTop = 0
. But you don’t want developers to have to write this logic every time they implement the component in question – the component should expose some property that can be passed a value which triggers the effect, right?
But you'll quickly find that passing in a value doesn’t make much sense for an imperative action. What would you pass in? A boolean variable (onRequestScrollToTop
) with the value true
? Does this variable then get set back to false
? Does the parent set it back to false
with setTimeout
and a short delay? Or is there a callback function (onScrollToTop
) which is executed after the scroll-to-top is complete, at which time the variable in question is set to false
? All of these sound equally awful and unnecessary.
It’s peculiar and rare situations like these where useImperativeHandle
actually shines and should actually be considered. Conversely, if you don’t find yourself asking these types of questions, you can probably accomplish what you’re trying to do without using useImperativeHandle
.
Here’s another thing to think about: when you’re creating components for others and publishing them as open-source tools, it’s impossible to predict in advance all the ways in which they are going to be used. There are clear advantages to constructing our components in a way that maximizes their flexibility. That comments feed, for instance: there’s nothing saying it has to be used in an accordion. Perhaps, in some rare cases, adding useImperativeHandle
could allow developers to use specific features in specific situations without us being forced to drastically alter the original component every single time a new, unique situation arises.
Additional Reading
Posted on April 13, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 27, 2024