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:
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>
);
};
- 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>
);
};
- 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>
);
};
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.
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, areject
function and thepromise
that will be fulfilled. We can note below that theDeferredPromise
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>;
};
- 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 };
}
- 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;
- Next, we will update the ref hook with the new deferred object.
deferRef.current = deferred;
- 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 };
}
- 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>
);
};
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.
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)
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).
Good stuff, saved my day at job today!