Let's connect!

close
Dotis.io Menu
5 min read • June 22, 2024

Clean APIs with useImperativeHandle

Picture of author
Dávid Kasznár CEO | Full Stack Developer

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.

Passing events in to components

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: the ref we received from forwardRef
  • createHandle: a function that will create the exposed object
  • dependecies: 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.

API we have vs API we want

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.

Got a project in mind?

Let's connect!