React Hooks Explained: useImperativeHandle

anikcreative

Anik

Posted on April 13, 2021

React Hooks Explained: useImperativeHandle

Table Of Contents


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>
    </>
  );
};


Enter fullscreen mode Exit fullscreen mode

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>
  );
};


Enter fullscreen mode Exit fullscreen mode

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>
...


Enter fullscreen mode Exit fullscreen mode

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>
    ...
  );
};


Enter fullscreen mode Exit fullscreen mode

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();
    }
  }, []);
...


Enter fullscreen mode Exit fullscreen mode

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:

Comments section, collapsed vs expanded

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, []);
...


Enter fullscreen mode Exit fullscreen mode

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}
    />
  );
};


Enter fullscreen mode Exit fullscreen mode

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,
}) => ...


Enter fullscreen mode Exit fullscreen mode

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 () { ... }
  ...
}


Enter fullscreen mode Exit fullscreen mode

Let’s say we were previously exporting this component as a module-level default export:



export default Comments;


Enter fullscreen mode Exit fullscreen mode

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);


Enter fullscreen mode Exit fullscreen mode

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 () { ... }
  ...
}


Enter fullscreen mode Exit fullscreen mode

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();
...


Enter fullscreen mode Exit fullscreen mode

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,
      }
    }
  );
  ...
}


Enter fullscreen mode Exit fullscreen mode

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,
  })
);


Enter fullscreen mode Exit fullscreen mode

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]
);


Enter fullscreen mode Exit fullscreen mode

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}
    />
  );
};


Enter fullscreen mode Exit fullscreen mode

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>
  );
};


Enter fullscreen mode Exit fullscreen mode

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

💖 💪 🙅 🚩
anikcreative
Anik

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