DEV Community

Cover image for Creating a deferred promise hook in React
Victor Novais
Victor Novais

Posted on

Creating a deferred promise hook in React

Hello fellow readers! In this post I am going to show how to create and use a deferred promise hook in React. Feel free to ask or give your opinion in the comments section.
It is important that you may have some knowledge about promises to fully understand this article. If you don't, please read this great article from MDN.
Let's go!

Principle

A deferred promise, defined by the jQuery lib, is:

An object that can register multiple callbacks into callback queues, invoke callback queues, and relay the success or failure state of any synchronous or asynchronous function.

In simple words, it means that we can store promise's callbacks, such as resolve and reject to use them later, deferring an action until it's done.

Use case

Let's imagine the following scenario:

  • There is a task list component that has a remove button
  • Once the remove button is clicked, a confirm dialog shows up
  • Once the user confirms the removal, the task is deleted, otherwise nothing happens

Here is a draft of this idea:

Scenario draft print screen showing a list with three tasks along with three remove buttons aside

We can build the code of this scenario as the following:

  • Task list component
type ListProps = {
  allowDelete: () => Promise<boolean>;
};

const data = ['Task 1', 'Task 2', 'Task 3'];

const List = ({ allowDelete }: ListProps) => {
  const [tasks, setTasks] = useState(data);

  const handleRemove = async (task: string) => {
    const canDelete = await allowDelete();
    if (!canDelete) return;

    const newTasks = tasks.filter((innerTask) => innerTask !== task);
    setTasks(newTasks);
  };

  return (
    <ul>
      {tasks.map((task) => (
        <li style={{ marginBottom: 10 }}>
          <span>{task}</span>
          <button style={{ marginLeft: 10 }} onClick={() => handleRemove(task)}>
            Remove
          </button>
        </li>
      ))}
    </ul>
  );
};
Enter fullscreen mode Exit fullscreen mode
  • Confirm dialog
type DialogProps = {
  isOpen: boolean;
  handleConfirm: () => void;
  handleClose: () => void;
};

const Dialog = ({ isOpen, handleConfirm, handleClose }: DialogProps) => {
  return (
    <dialog open={isOpen}>
      <div>Do you really want to remove this task?</div>
      <button onClick={handleConfirm}>Yes</button>
      <button onClick={handleClose}>No</button>
    </dialog>
  );
};
Enter fullscreen mode Exit fullscreen mode
  • Application
const App = () => {
  const [isDialogOpen, setIsDialogOpen] = useState(false);

  const allowDelete = async () => {
    setIsDialogOpen(true);
    return true;
  };

  const handleConfirm = () => {
    setIsDialogOpen(false);
  };

  const handleClose = () => {
    setIsDialogOpen(false);
  };

  return (
    <Fragment>
      <List allowDelete={allowDelete} />
      <Dialog
        isOpen={isDialogOpen}
        handleConfirm={handleConfirm}
        handleClose={handleClose}
      />
    </Fragment>
  );
};
Enter fullscreen mode Exit fullscreen mode

Looking at this scenario, it stays clear that the list component needs to wait for the user intervention before deciding if a task can or cannot be removed.

But, there is a problem! If we run this code, we will encounter a bug. As soon as the user clicks the remove button, the task is already deleted before the user's consent.

Gif showing the code bug. When the user clicks the remove button of a task, it is immediately removed before the user's consent.

Deferred promise to the rescue

To fix this bug, we need to tell our code to wait for the user consent, and this is possible by creating a deferred promise.
I will show step by step how to create our custom hook.

  • First, we will create a type that will hold our defer object. This object must have three properties: a resolve function, a reject function and the promise that will be fulfilled. We can note below that the DeferredPromise receives a generic type (DeferType) that infers the resolve's value type as well as the promise type. You may skip this step if you are using plain JavaScript instead of TypeScript.
type DeferredPromise<DeferType> = {
  resolve: (value: DeferType) => void;
  reject: (value: unknown) => void;
  promise: Promise<DeferType>;
};
Enter fullscreen mode Exit fullscreen mode
  • Next, we are going to start to define the hook's function. This hook begins with a simple ref that will hold our defer object. Note that the hook receives the same generic type defined above.
export function useDeferredPromise<DeferType>() {
  const deferRef = useRef<DeferredPromise<DeferType>>(null);

  return { deferRef: deferRef.current };
}
Enter fullscreen mode Exit fullscreen mode
  • So far, so good! Now let's increment our hook with a function that creates the defer object. First, we will build our deferred object.
// Here is our deferred object that will hold the callbacks and the promise
const deferred = {} as DeferredPromise<DeferType>;

// We then create the main part of our defer object: the promise
// Note that we take the promise's callbacks and inject them into our deferred object
const promise = new Promise<DeferType>((resolve, reject) => {
   deferred.resolve = resolve;
   deferred.reject = reject;
});

// Finally, we inject the whole promise into the deferred object
deferred.promise = promise;
Enter fullscreen mode Exit fullscreen mode
  • Next, we will update the ref hook with the new deferred object.
deferRef.current = deferred;
Enter fullscreen mode Exit fullscreen mode
  • Now we have our complete function and hook! Check it out:
export function useDeferredPromise<DeferType>() {
  const deferRef = useRef<DeferredPromise<DeferType>>(null);

  const defer = () => {
    const deferred = {} as DeferredPromise<DeferType>;

    const promise = new Promise<DeferType>((resolve, reject) => {
      deferred.resolve = resolve;
      deferred.reject = reject;
    });

    deferred.promise = promise;
    deferRef.current = deferred;
    return deferRef.current;
  };

  return { defer, deferRef: deferRef.current };
}
Enter fullscreen mode Exit fullscreen mode
  • Alright! Our hook is now complete. Let's now use it to solve the bug we found!

Using the deferred promise hook

Let's modify the Application component adding the new hook. Note that the allowDelete function now returns a deferred promise and the confirm/delete functions resolve this deferred promise.

const App = () => {
  const [isDialogOpen, setIsDialogOpen] = useState(false);

  // Here we declare the new hook
  // Note that we will resolve this promise using a boolean value (`true` or `false`). This is the generic type that we defined earlier.
  const { defer, deferRef } = useDeferredPromise<boolean>();

  const allowDelete = async () => {
    setIsDialogOpen(true);
    // Now a deferred promise is being returned
    return defer().promise;
  };

  const handleConfirm = () => {
    setIsDialogOpen(false);
    // If the user consents, the deferred promise is resolved with `true`
    deferRef.resolve(true);
  };

  const handleClose = () => {
    setIsDialogOpen(false);
    // If the user declines, the deferred promise is resolved with `false`
    deferRef.resolve(false);
  };

  return (
    <Fragment>
      <List allowDelete={allowDelete} />
      <Dialog
        isOpen={isDialogOpen}
        handleConfirm={handleConfirm}
        handleClose={handleClose}
      />
    </Fragment>
  );
};
Enter fullscreen mode Exit fullscreen mode

Now if we run this code, we will note that the bug is fixed! Our code successfully waits for the user consent before removing a task. If the removal action is declined, nothing happens, as expected.

Gif showing that the bug is now fixed. The task will be remove if the user gives it's consent, otherwise nothing happens.

Wrapping up

We successfully created our deferred promise hook from scratch, and it was pretty simple!
I showed just one of the use cases that this hook might become handy, but you can use this whenever you need to wait something to happen before running an action.
Here I also leave the link for every code that was written in this article: https://stackblitz.com/edit/react-ts-sukfgm?file=index.tsx

Just one important note: once you defer a promise, never forget to resolve or reject it, otherwise you might encounter some memory leak problems.

That's it for now! Please, if you have any doubts, don't hesitate to use the comments section, as I will keep an eye on it!

Top comments (2)

Collapse
 
harishteens profile image
Harish

Good stuff, saved my day at job today!

Collapse
 
anduser96 profile image
Andrei Gatej

Thanks for sharing!

I had to resort to something similar at my job. The use case was that we needed to collect some data from components which were down in the tree and lifting the state would cost a lot of time. What we ended up doing was to force re-render those components(and thus some of their parents) and in a useEffect a check would be done to see if it’s time to call a function prop with the data. A deferred promise was used to wait until the components re-rendered(AFAIK, re-renders in React 18 occur asynchronously).