Bubble up data on Remix routes
React introduced a one-way data flow where a parent component has some data (state) and passes it to the children components as props.
In Remix, we can invert the data flow and let children's routes pass data to parent routes. We can do this by using the useMatches
hook.
Bubbling static data
Imagine this scenario, you're building a multi-step flow with some sidebar showing the progress, and each step is a nested route, so you have a folder structure like this:
└ app/
└ routes/
├ multi-step-flow.tsx
└ multi-step-flow/
├── step-1.tsx
├── step-2.tsx
└── step-3.tsx
And you want to pass from step-1
, step-2
, and step-3
the step name to multi-step-flow
so it can show previous steps as completed.
You can pass static data by using the handle
export from the children's routes:
export let handle = { step: "step-1" };
Now in the parent route, we can access this data using the useMatches
hook:
let matches = useMatches();
let match = matches.find((match) => "step" in match.handle);
let step = match.handle?.step as string;
With this, our parent route looks for every rendered route's handle
export and finds the first one with the step
property. Then it saves the step in a variable.
With these few lines of code, we could pass a static value from a child route to its parent route.
Bubbling dynamic data
Another thing we could do is send dynamic data to a parent route. Let's say we're building a blogging platform, and the content can be in multiple languages, so we want to change the <html lang>
attribute depending on the language of the current content. So we could have something like this:
└ app/
├ root.tsx
└ routes/
└ $content.tsx
On the routes/$content.tsx
file, we might have a loader function that uses the parameter on the route to fetch the content from the database:
export async function loader({ params }: LoaderArgs) {
let slug = z.string().parse(params.content);
let content = await Content.findBySlug(slug);
return json(content);
}
And if content
has a lang
property, we know there will always be this lang
on the loader data. We could use, again, useMatches
to grab the data on the root
route component.
let matches = useMatches();
// here, we use match.data instead of match.handle
let match = matches.find((match) => "lang" in match.data);
let lang = match.data?.lang as string;
return <html lang={lang}> ... </html>;
And again, with a few lines of code, we could pass dynamic data from a child route to its parent route using the loader
and useMatches
hook.
Bubbling functions
Let's say we need to know if the route required loading JS, so we could have some routes that don't load client-side JS while other routes do it.
A straightforward way to do this is by using the handle
export to pass a boolean.
export let handle = { hydrate: true };
But if hydrating the route depends on the data we get from the loader, we can't use the handle
export. We need the loader
. But using a loader
function would mean the route always needs to have a loader
. To solve this, we can still use handle
but simultaneously combine it with loader
.
export let handle = {
hydrate(data: SerializeFrom<typeof loader>) {
return data.hydrate;
},
};
Now, in our root
route component, we can use the useMatches
hook to access this hydrate
function and call it with the data from the loader.
let matches = useMatches();
let shouldHydrate = matches.some((match) => {
if (typeof match.handle?.hydrate === "function") {
return match.handle.hydrate(match.data);
}
return match.handle?.hydrate ?? false;
});
return <html> ... {shouldHydrate ? <Scripts /> : null} ... </html>;
We check if hydrate
is a function and call it by passing the match.data
as an argument. If it's not a function, we use the value and default to false
if it's not defined.
Bubbling components
And since we can pass functions, we can also pass components because components are, in the end, just functions.
export let handle = { Aside };
function Aside({ data }: { data: SerializeFrom<typeof loader> }) {
return <aside> ... </aside>;
}
With this, we defined the component as a property of handle
. This component could also receive the data from the loader
as a prop
.
Finally, in the parent route, we could render it.
let matches = useMatches();
let match = matches.find(
(match) => "Aside" in match.handle && typeof match.handle.Aside === "function"
);
let Aside = match.handle?.Aside as React.ComponentType<{ data: any }>;
return (
<Layout>
<Aside data={match.data} />
<main>
{" "}
<Outlet />{" "}
</main>
</Layout>
);