Will King
Remix

Remix route helpers: A better way to use parent data

Before we get going let's do a little TL;DR on why this matters.

Remix leverages nested routing to only fetch data for the part of the route that changes. This means if you fetch a resource in the parent route and navigate between the child routes you will only fetch the resource once.

From the docs:

Remix also has some built in optimizations for client side navigation. It knows which layouts will persist between the two URLs, so it only fetches the data for the ones that are changing. A full document request would require all data to be fetched on the server, wasting resources on your back end and slowing down your app.

So, at some point while you're building a Remix application you're going to run into this question:

How do I get parent data in a child route?

The kicker? Remix provides a tool for fetching the data from other loaders for free.

useMatches

A utility that can be used to loop through all routes called during load and access the data that was returned in each routes loader.

Let's take a look at an app where you can write and manage a series of web novels.

You have a route novels/$novelSlug where you have a shared layout for all of your novel routes. In the loader for that route you fetch the novel from your database so that all of the child routes do not have to refetch that data when you navigate between them.

// novels/$novelSlug

export function loader({ params }: LoaderArgs) {
  const novel = fetchNovel(params.novelSlug);

  // Some error handling

  return json({ novel: fetchNovel })
}

Now let's say you have a child route where you want to show a summary of the novel. You can leverage useMatches to get that data from the parent loader.

// novels/$novelSlug/summary

export default function SummaryPage() { 
  const novelData = useMatches().find((m) => m.id === "/novels/$novelId")?.data as { novel: Novel }
  invariant(novelData, "Could not find parent data needed to render page.")
  const { novel } = novelData

  ...
} 

Dope, just like that we have the data from the parent without the extra performance cost of fetching between page loads. Buuuut let's picture doing that on every child page.

Then what happens if we decide to rebrand from “novels” to “stories” and all of our routes change?

Or since we are using type casting, what happens if we change what data is returned on the parent?

As helpful as useMatches is calling it directly in child routes can lead to more duplication and easy to miss bugs.

Reliably fetch parent data with route helpers

To fix our concerns with fragile implementation at the call site, we are going to define all types and fetching in the parent route itself using a few easy helpers.

ROUTE_ID

This provides an easy reference for your route id so that that if it changes all references to this ID are updated in one location.

const ROUTE_ID = 'routes/novels/$novelSlug'
export { ROUTE_ID as novelRouteId }

export type NovelMetaRecord = { [ROUTE_ID]: typeof loader }

This is often used in meta functions where you want to add dynamic parent data. For instance, including the novel title in child routes.

export const meta: MetaFunction<typeof loader, NovelMetaRecord> = ({ parentsData }) => ({ 
  title: `Summary | ${parentsData.novel.title}`
})

useNovelLoaderData

Okay, this is the one. It probably looks familiar because it is basically the same method used in the example above but there are three things that make this more reliable and maintainable.

export function useNovelLoaderData(): SerializeFrom<typeof loader> {
	const matches = useMatches()
	const match = useMemo(() => matches.find(m => m.id === ROUTE_ID), [matches])
	invariant(match, 'Unable to find cluster layout data')
	return match.data as SerializeFrom<typeof loader>
}

It lives where the data is

Instead of this process being written out in every child route we write it once. in the parent file that loads the data. We leverage the ROUTE_ID discussed above so that route changes are not a concern, and if this route is removed we get errors during development where this data is trying to be fetched.

It memoizes the result

All call sites are expecting the exact same data from the exact same route. Because useMatches returns an array of all routes and the data attached useMemo is a great tool provided by React to say “if we are passing you the same route just give us the same data you did last time”.

Is useMemo going to be a giant perf gain with the matches array being as small as it is? Probably not, but will provide some benefit for no risk and that sounds pretty good to me.

Types are included at call site

This is a nice little extra benefit of having it collocated with the loader it is referencing. We do not have to create and export a type for casting data all over our routes. We get to directly reference the loader type in the helper and call sites get that for free.

useNovelSlugParam

I'm just going to throw this one in there too. It is a helper that is occasionally useful that achieves the same goal as the loader helper but when you only need the param and not the entire object.

export function useNovelSlugParam(): string {
	const { novelSlug } = useParams<'novelSlug'>()
	invariant(novelSlug, 'No novel slug in URL params')
	return novelSlug
}

Do you have any useful helpers or tips?

That's all there is to it. If you have any interesting helpers like this hit me up on Twitter and share!

Updates and More

Get updated when new articles, products, or components are released. Also, whatever else I feel like would be fun to send out.