Contributing Callsite Revalidation Opt-out to React Router

This post is long overdue since it happened back in November but alas here we are...

I contributed callsite revalidation opt-out to React Router(with a lot of help from Matt Brophy) and am pretty proud of that. React Router is very well established, and new features don't get incorporated without tons of thought and consideration from the steering committee. Pretty pumped I got to implement a feature Ryan Florence pitched way back in early 2023. Feels good to finally contribute code, not just feedback, after years of using the software.

Revalidation

If you've worked with React Router for any length of time you'll immediately notice the nuclear approach to revalidations. What's a "revalidation" you ask?

Imagine you have three nested routes that each exports a loader to supply data.

app/routes/root.tsx                 -> supplies account data
app/routes/projects.tsx             -> supplies list of projects
app/routes/projects.$projectId.tsx  -> supplies project details

If you make a successful POST request to mutate some data in the projects.$projectId.tsx, then all three loaders will "revalidate": run again to fetch fresh data.

History lesson

This behavior harkens back to the days where you just relied on <form method="post" action="action.php"> and the browser would refresh the whole page. Your backend would have to refetch all the data that the page needs.

We've come along way since then though. The pendulum swung to SPA's where most people used react-query to make all their api calls and it was up to you to explicitly revalidate data. React Router's default behavior swings that pendulum all the way back and revalidates everything for you.

Today

React Router does give you means to control revalidations in the form of exporting a shouldRevalidate function from your route. This is your means of opting loaders out of rerunning based on certain app specific conditions. This is great but a bit unwieldy at times.

Tags/Labels

Imagine the projects.$projectId.tsx route allows you to "tag" the project. Think GitHub labels. You could tag the project "High Priority", "Feature", "Frontend", etc. In GitHub you can create new labels on the fly.

You would most likely use a fetcher.submit() call for this request and since that's a mutation it will automatically revalidate all three loaders in our hypothetical app. This can lead to some wonky behavior and we know that for our use-case these automatic revalidations are completely unnecessary.

Old solution

The old way was to export a shouldRevalidate function in every active route.

// app/routes/root.tsx
// app/routes/projects.tsx
// app/routes/projects.$projectId.tsx

import type { ShouldRevalidateFunctionArgs } from "react-router";

export function shouldRevalidate({
  defaultShouldRevalidate,
  formMethod,
  formAction,
}: ShouldRevalidateFunctionArgs) {

  const isTagCreate =
    formMethod === "post" &&
    formAction?.endsWith("/tags");

  if (isTagCreate) return false;

  return defaultShouldRevalidate;
};

Now, imagine your coworker needs to use your fancy new creatable tag combobox component. They better remember to do the same shouldRevalidate shenanigans in the active routes they're working in.

This gets messy and is prone to being missed.

After some research, I came across Ryan's old proposal for Callsite Revalidation Opt-out which is exactly what I wanted. I wanted to be able to control this revalidation at the callsite.

fetcher.submit(target, {
  method: 'post',
  shouldRevalidate: () => false
}

It was validating to see others experiencing this same pain point. It was at this point that I thought "Hey, maybe I can I figure this out and make it a reality".

Rolling up my sleeves

I forked the repo and was able to figure out how to get the examples to run but I was struggling to figure out a workflow to modify the core code and test that out. I added some console.log and debugger statements but they were never being triggered when they absolutely should have been. I have been fairly active in the React Router discord so I headed to the #contributing channel.

I was able to get the console.logs to show up but couldn't get it to break with my debugger statements. After some head scratching, I figured out that my console had a rule to ignore files in node_modules. I needed to uncheck the checkbox in the Custom exclusion rules here.

I was in business after that! I could step through the code to see how React Router worked and flowed under the hood.

After a couple hours of investigation and trying things I was able to put a PR together.

The steering committee met and made some design choices that made a lot of sense. Matt asked if i'd like to make the changes or hand it off.

I wasn't going to come this far and not try to see it through myself.

I was able to make the requested changes and then Matt wrote a tests covering it. Brooks Lybrand was checking out the PR and discovered a bug which I was quickly able to determine the cause but wasn't sure the best way to go about fixing. Matt figured out that another PR would solve the problem here so he merged that in and it was good to go. 🎉

The feature was included in the v7.11.0 version as "unstable" to give people a chance to try it out and let it soak before they're marked as stable. Here's what it looks like to use currently. Eventually the unstable_ bit will drop off.

fetcher.submit(target, {
  method: 'post',
  unstable_defaultShouldRevalidate: false
}

<Form method="post" unstable_defaultShouldRevalidate={false}>
...
</Form>

<Link to={href('/some/where')} unstable_defaultShouldRevalidate={false}>
...
</Link>

const navigate = useNavigate()

navigate(href('/some/where'), {
  unstable_defaultShouldRevalidate: false
})

It's been added to all navigations which should cover any use-case you might have.

This gives you a lot of flexibility: the value you set becomes the defaultShouldRevalidate argument in the route's shouldRevalidate function, so you can control revalidation at both the callsite and the route level.

Try it out

If you've been wrestling with React Router's aggressive revalidation behavior, this feature should make your life easier.

Big thanks to Matt Brophy and the React Router steering committee for guiding this through. Open source collaboration at its best.

Give it a go and let me know how it works out for ya ✌️

Categories: React, Javascript, Node, Typescript