Sergio Xalambrí

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>
);