Handling Dependent Loaders in React Router | Accessing Parent Loader's Promise in a Child Loader
Pretext:
The following article provides a better solution with a clean code, to sequentialize data fetching and accessing flow in the react router loaders.
The Problem:
By default, all loaders in react router are executed parallelly. This is great for speed and efficiency. But what if you have a child route’s loader dependent on a data that is being set by some preceding parent route loader ?
For Example:
Imagine you are calling user API on a /
loader and setting user state. And you are getting that user state in the user-profile/
loader (BTW, Jotai can be used to set/get user state outside react components). You will be getting the user data as null
in user-profile/
route loader. Because the /
loader is running in parallel and its Promise is not yet resolved.
Concepts of React Router used in the solution:
1. dataStrategy
Function Route option: It lets you override the default execution behaviour of route loaders and write your own logic to run the loaders.
( ref: https://reactrouter.com/en/main/routers/create-browser-router#optsdatastrategy )
2. handle
route field: It lets you store any type of data and callbacks in route’s meta data and access it anywhere, inside and outside of components.
( ref: https://reactrouter.com/en/main/hooks/use-matches )
The Solution:
- One of the solutions would be running all loaders sequentially, instead of parallelly. But as your app gets bigger with deeper parent-child routes, the initial load time would be massive comparatively. Hence by keeping the efficiency factor in mind, we will not consider this approach as a solution, which leads to classic Waterfall Problem.
- We somehow want to access the promises of dependent loaders to run our dependent logic on child loader after it is resolved, by still running the loaders parallelly.Step-1
: Storing the dependent loaders in route object’s handle
field:
type RouteHandle =
| {
dependencies?: LoaderFunction[];
}
| undefined;
const rootLoader = async ()=>{
const user = await getUser();
state.set(userState, user)
return user;
}
const userProfileLoader:LoaderFunction = async ({}: LoaderFunctionArgs, dependentLoadersPromises)=>{
await dependentLoadersPromises; // no promises if current page is a subsequent navigation from home page to profile page
const user = state.get(userState, user);
// laoder logic that requires user data, goes here...
}
const userProfileHandle: RouteHandle = {
dependencies: [rootLoader],
};
const router = createBrowserRouter({
path: "/",
element: <HomePage />,
loader: rootLoader,
children: [{
path: "user-profile/",
element: <UserProfile />,
loader: userProfileLoader,
handle: userProfileHandle
}]
}, routerOptions);
Step-2
: Accessing the dependent loaders in dataStrategy
function and providing their Promises to the loaders: (routerOptions)
const resolveLoaders = async (
matches: DataStrategyMatch[]
) => {
const matchesToLoad = matches.filter((m) => m.shouldLoad);
const promises: Promise<DataStrategyResult>[] = [];
for (const [index, match] of matchesToLoad.entries()) {
const currentLoader = match.route.loader;
// if current match has loader
if (
currentLoader &&
typeof currentLoader !== "boolean" &&
match.route.handle?.dependencies &&
match.route.handle.dependencies.length > 0
) {
const dependenciesPromises: Promise<DataStrategyResult>[] = []; // promises that the current loader is dependent on.
// iterate over preceeding matches from current match, and check if its loader is a dependency in current match's 'dependencies' array.
matchesToLoad.slice(0, index).forEach((mat, i) => {
if (
match.route.handle.dependencies.includes(mat.route.loader)
) {
// push the promise of the preceeding match, whose loader is a dependency.
dependenciesPromises.push(promises[i]);
}
});
promises.push(
// override the resolve
match.resolve(async (loaderCallback) => {
let result;
// if promise(s)/resoved-data is required in the child loader
await loaderCallback(Promise.all(dependenciesPromises)); // dependencies resolver passed to the loader as argument.
// if resolved data is not requied in the child loader
/*
await Promise.all(dependenciesPromises); // resolve all dependent loaders before calling the child loader
await loaderCallback();
*/
return result;
})
);
} else {
promises.push(match.resolve());
}
}
const allResults = await Promise.all(promises);
return allResults.reduce(
(acc, result, i) =>
Object.assign(acc, { [matchesToLoad[i].route.id]: result }),
{}
);
};
const routerOptions = {
dataStrategy: ({ matches }: DataStrategyFunctionArgs) =>
resolveLoaders(matches),
};
Logic:
Iterate over all loaders’ resolves and push them into
promises
array. If the loader has dependencies, then get the dependent promises that were pushed before in thepromises
array and provide them to the loader as an argument, by overriding its resolve logic.Get the provided
dependentLoadersPromises
inside the loader and await over them.
Post-text:
Here is the current default dataStrategy
in Remix Router
:
By utilizing handle
route option, I have achieved to provide a clean code system to handle dependent loaders.
Here “clean code” only means that “Remix has already provided a way to pass any data to loaders as arguments and I’ve discovered and utilized it.”
Subscribe to my newsletter
Read articles from Charan Mudiraj directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Charan Mudiraj
Charan Mudiraj
I am CS Student current pursing B.tech. I love coding and system design. This is my official blog post site where I share some of my highlighted experiences and learnings, being in the IT industry.