Recently I had to build a React component that would merge the results of several independent requests into one set of data and render it as a single list.
The requests would look like this:
I wanted the list to ultimately render like this:
The problem was: how to load a list of items, then load details of each item, making a separate request per item, then combine all of this information and render it to the user, all within one component.
The simplest way would be to await all the requests and then render them together at once.
Here is an implementation which uses Promise.all
.
function UsersAndStatuses(props) {
const [users, setUsers] = React.useState([]);
React.useEffect(async () => {
const users = await mockUsers();
const userIds = users.map((user) => user.id);
// Promise.all technique
const userStatuses = await Promise.all(userIds.map(mockUserStatus));
const usersWithStatus = users.map((user, index) => ({
...user,
...userStatuses[index],
}));
setUsers(usersWithStatus);
}, []);
return (
<ul>
{!users.length && "Loading..."}
{users.map((user) => (
<li>
{user.name} {user.status}
</li>
))}
</ul>
);
}
The problem with the above is:
It could take a long time for all the requests to complete.
We don't want to keep the user waiting for the whole list to load before they can see any results.
It would be better if we could
Implementing this improved solution raised a challenge:
How to merge the details from all the requests together into one state variable without triggering a React refresh cycle?
If the React refresh cycle triggered, it would have caused the state variable to contain incomplete data, as one partial value would override another.
It turns out the solution is rather simple: we just have to re-use the latest copy of our state variable each time we set it.
So instead of the typical setState
call:
setUsers({
...users,
[updatedUserId]: updatedUser,
});
We pass a state setter whose parameter (currentUsers
) will always have the last updated value:
setUsers((currentUsers) => ({
...currentUsers,
[updatedUserId]: updatedUser,
}));
So... here's the parallel loading solution.
function UsersAndStatuses(props) {
const [usersById, setUsersById] = React.useState({});
const users = React.useMemo(() => Object.values(usersById), [usersById]);
React.useEffect(async () => {
const usersList = await mockUsers();
setUsersById(
usersList.reduce(
(acc, user) => ({
...acc,
[user.id]: user,
}),
{}
)
);
const userIds = usersList.map((user) => user.id);
userIds.forEach(async (userId) => {
const userStatus = await mockUserStatus(userId);
// Async state setter technique
setUsersById((currentUsersById) => ({
...currentUsersById,
[userId]: {
...currentUsersById[userId],
...userStatus,
},
}));
});
}, []);
return (
<ul>
{!users.length && "Loading..."}
{users.map((user) => (
<li>
{user.name} {user.status}
</li>
))}
</ul>
);
}