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 ✌ïļ

Executing api requests in React Router

There's not a lot of opinions on how to structure code that interacts with apis in React Router's actions and loaders. This is what I've come up with and the post I wish existed before I started using React Router's actions/loaders with my existing api(and if middleware existed back then). You need some understanding of how context works which you can learn about in the middleware docs.

getLoadContext

This is where you can define context values before any of your actions and loaders get hit.

// server.ts
import { createRequestHandler } from "@react-router/express";
import express from "express";
import { unstable_RouterContextProvider } from "react-router";
import { getSession } from "~/services/session.server";
import { appContext, makeFetchTyped } from "~/contexts.server";

export const app = express();

app.use(
  createRequestHandler({
    build: () => import("virtual:react-router/server-build"),
    async getLoadContext(req) {
      const session = await getSession(req.headers.cookie);
      const user = session.get("user");
      const fetchTyped = makeFetchTyped(user);
      const context = new unstable_RouterContextProvider();
      context.set(appContext, {
        user,
        fetchTyped,
      });

      return context;
    },
  }),
);

Now you have access to the the appContext values in your actions and loaders.

import { appContext } from '~/contexts.server'

export async function loader({ context }: Route.LoaderArgs) {
  const ctx = context.get(appContext)
  // ctx.user
  // ctx.fetchTyped
}

See this middleware project example for all the pieces to make this work: vite.config.ts, react-router.config.ts, etc.

Middleware

You can also skip getLoadContext and do this directly in middleware.

// app/root.tsx
const rootMiddleware: Route.unstable_MiddlewareFunction = async ({
  request,
  context,
}) => {
  const session = await getSession(request.headers.get("Cookie"));
  const user = session.get("user");
  const fetchTyped = makeFetchTyped(user);
  context.set(appContext, {
    user,
    fetchTyped,
  });
};

export const unstable_middleware = [rootMiddleware];

The middleware version is the way of the future. I only mention the getLoadContext because you might come across it being mentioned and be confused since it shares a lot of similarity with middleware.

fetchTyped

What is fetchTyped? Another concept I picked up from Bryan Ross(@rossipedia) in React Router's discord. This is a function I've defined in my codebase. When dealing with an api, you're usually making authenticated requests to a particular user via auth token that's stored in the session. fetchTyped is a thin wrapper around fetch that binds the current user to a fetch wrapper. It accepts a zod schema to validate the response. Here's the implementation.

import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import { env } from '~/env.server'

export function makeFetchTyped(user: User) {
    const defaultHeaders: HeadersInit = {
        Accept: 'application/json',
        'Content-Type': 'application/json',
        Authorization: user.apiToken,
    }

    return async function fetchTyped<T extends z.ZodType>(
        url: string,
        schema: T,
        options?: RequestInit,
    ): Promise<z.infer<T>> {
          // Merge the default headers with the provided options
          const mergedOptions: RequestInit = {
            ...options,
            headers: {
              ...defaultHeaders,
              ...(options.headers || {}),
            },
          }
        const res = await fetch(env.API_HOST + url, mergedOptions)
        const json = await res.json()
        try {
          return schema.parse(json)
        } catch (reason) {
          throw reason instanceof z.ZodError ? fromZodError(reason) : reason
        }
    }
}

export type FetchTyped = ReturnType<typeof makeFetchTyped>

And here's how it can be used.

import { redirect, href, data } from "react-router";
import { appContext } from "~/contexts.server";
import { z } from "zod";
import type { Route } from "./+types/route";

export async function loader({ context }: Route.LoaderArgs) {
  const ctx = context.get(appContext);
  const schema = z.object({
    data: z.array(
      z.object({
        id: z.number(),
        firstName: z.string(),
        lastName: z.string(),
      }),
    ),
  });
  const res = await ctx.fetchTyped("/contacts", schema);
  return {
    contacts: res.data,
  };
}

export async function action({ request, context }: Route.LoaderArgs) {
  const formData = await request.formData();
  const ctx = context.get(appContext);
  const schema = z
    .object({
      id: z.string(),
      message: z.string(),
    })
    .or(
      z.object({
        error: z.string(),
      }),
    );
  const contactId = String(formData.get("contactId"));
  const res = await ctx.fetchTyped(`/contacts/${contactId}`, schema, {
    method: "PUT",
    body: JSON.stringify({
      data: {
        firstName: String(formData.get("firstName") ?? ""),
        lastName: String(formData.get("lastName") ?? ""),
      },
    }),
  });

  if ("error" in res) {
    return data({ error: res.error }, { status: 400 });
  }

  return redirect(href('/app/contacts/:id', { id: res.id }));
}

Organization

Defining your api requests directly in your actions and loaders is totally valid but it leaves a lot to be desired in terms of code organization. If we extract these interactions into a class, we can organize and reuse these methods across the application if needed.

// app/service/contact-client.server.ts
import type { FetchTyped } from "~/lib/fetch-typed.server";
import { DomainError } from "~/lib/validation.server";

export class ContactClient {
  #fetchTyped: FetchTyped;

  constructor(fetchTyped: FetchTyped) {
    this.#fetchTyped = fetchTyped;
  }

  async getContacts() {
    const schema = z.object({
      data: z.array(
        z.object({
          id: z.number(),
          firstName: z.string(),
          lastName: z.string(),
        }),
      ),
    });
    const { data } = await this.#fetchTyped("/contacts", schema);
    return data;
  }

  async updateContact({
    contactId,
    values,
  }: {
    contactId: string;
    values: CreateContactValues;
  }) {
    const schema = z
      .object({
        id: z.string(),
        firstName: z.string(),
        lastName: z.string(),
      })
      .or(
        z.object({
          error: z.string(),
        }),
      );
    const res = await this.#fetchTyped(`/contacts/${contactId}`, schema, {
      method: "PUT",
      body: JSON.stringify({
        data: values,
      }),
    });
    if ("error" in res) {
      throw new DomainError(res.error);
    }
    return res;
  }
}

type CreateContactValues = {
  firstName: string;
  lastName: string;
};

Next, well add this to our context.

const rootMiddleware: Route.unstable_MiddlewareFunction = async ({
  request,
  context,
}) => {
  const session = await getSession(request.headers.get("Cookie"));
  const user = session.get("user");
  const fetchTyped = makeFetchTyped(user);
  context.set(appContext, {
    user,
    fetchTyped, 
    // you'll need to add contactClient to the AppContext type too
    contactClient: new ContactClient(fetchTyped)
  });
};

And update our route to use it.

import { redirect, href, data } from "react-router";
import { appContext } from "~/contexts.server";
import { z } from "zod";
import type { Route } from "./+types/route";
import { DomainError } from "~/validation.server";

export async function loader({ context }: Route.LoaderArgs) {
  const ctx = context.get(appContext);
  const contacts = await ctx.contactClient.getContacts();
  return {
    contacts,
  };
}

export async function action({ request, context }: Route.LoaderArgs) {
  const ctx = context.get(appContext);
  const formData = await request.formData();
  try {
    const contact = await ctx.contactClient.updateContact({
      contactId: String(formData.get("contactId")),
      values: {
        firstName: String(formData.get("firstName") ?? ""),
        lastName: String(formData.get("lastName") ?? ""),
      },
    });
    redirect(href('/app/contacts/:id', { id: contact.id }));
  } catch (e) {
    if (e instanceof DomainError) {
      return data({ error: e.message }, { status: 400 });
    }
    throw e;
  }
}

I like this for several reasons.

  • We have a central location to define api requests related to this resource.
  • We're hiding the implementation of how the data is fetched and persisted. If we decide we want to hit a database directly, instead of going through an api, this code doesn't need to change. We could set up a database connection and feed that to the class constructor instead of fetchTyped and the route code would be none the wiser.
  • We can come up with style "rules" to make less thinking involved when developing new code.

Rules

Here are a few rules i've documented in my project repo to share with my team.


Organization

All api interactions should be located in app/module/<module-name>/<resouce-name>-client.server.tsx. The resource name should be singular. Ex. note-client.server.tsx, not notes-client.server.tsx.

Class methods

The method names should not allude to an api or database being used.

Good:

  • getNote
  • getNotes
  • createNote
  • updateNote
  • deleteNote

Bad:

  • fetchNote
  • queryNote

These methods should only contain one configuration object. Descriptive id field names should be used to decrease ambiguity inside the configuration object. Ex. contactId, not id.

Errors should be thrown inside of a DomainError, not returned as data.

The functions should be ui agnostic. For example, they should not return toast messages. This should be done in the route files. Example:

// app/routes/app.contacts.$id/route.tsx

export async function action({ request, params, context }: Route.ActionArgs) {
  const formData = await request.formData();
  const ctx = getAppContext(context);
  const result = await updateContact(ctx.contactClient, { // see updateContact below
    contactId: params.id,
    values: {
      firstname: String(formData.get("firstname") ?? ""),
      // ...
    },
  });

  // use react router's data() utility to send proper status code
  return data(result, { status: result.success ? 200 : 400 });
}

// clientAction should be origin of toasts, not useEffects
export async function clientAction({ serverAction }: Route.ClientActionArgs) {
  const result = await serverAction();
  toast({
    title: result.toast.title,
    variant: result.toast.variant,
  });
  return result;
}

// Wrapper function to put together toast messages based on the response.
// This code is specific to this use case of the api function.
async function updateContact(
  contactClient: ContactClient,
  ...args: Parameters<ContactClient['updateContact']>
) {
  try {
    const contact = await contactClient.updateContact(...args);
    return {
      success: true,
      contact,
      toast: {
        title: "Contact updated!",
        variant: "default",
      },
    };
  } catch (e) {
    if (e instanceof DomainError) {
      return {
        success: false,
        contact: null,
        toast: {
          title: e.message,
          variant: "destructive",
        },
      };
    }

    throw e;
  }
}

You may absolutely hate these rules but that's not really the point. The point is that your project will benefit if you spend some time figuring out how to make your app consistently built. Having a "standard" way to do things will make it so much easier to develop new features. There are less decisions to make in the moment. They've already been made and documented. Not only will this help your human coworkers, but also Copilot, Cursor, Claude Code, OpenCode, Junie, etc.

Do you have some opinions on how to structure your react router applications? I'd love to hear what those are. Reach out to me on twitter.

Happy building ✌ïļ

How I fixed a bug with Prettier

I recently encountered a Rube Goldberg machine type of system to generate PDFs and CSV reports. The main application is a php api. A php process that shell_exec's a command that calls a node script. This node script uses puppeteer to launch Chromium navigate to the main web app, and generates a PDF of the page or clicks a button that downloads a CSV, then emails this report to some recipients.

Something like this.

shell_exec("npx cross-env DOCUMENT_TYPE=\"csv\" TARGET_URL=\"$targetUrl\" node /app/generate-report.js");

This process hasn't changed in years. You know the saying, if it ain't broke, don't fix it. Well...it broke after a recent frontend(react) and backend(php) release. The mysterious part was that nothing related to these reports being generated changed.

Luckily, the app has sufficient logs that I could view so I was able to grab the exact command being shell_exec'd to run a report.

I navigated to the TARGET_URL directly in my browser and saw that the app was behaving as expected there. I was hoping maybe just that page of the app was crashing and it'd be a simple fix there. No luck.

Next, I ssh'd into the testing/development server and manually executed the shell command and encountered the same issue. No CSV file was being created. This ruled out the problem being in the backend php code.

Then, on the server I copied the existing generate-report.js script to a generate-report-debug.js file and added a bunch of logging into that. Notably this:

page.on('pageerror', error => {
  console.log('Page error:', error.message);
  console.log('Stack trace:', error.stack || '(No stack trace available)');
});

After running it, I was seeing this logged:

Page error: SyntaxError: Unexpected token '='
Stack trace: (No stack trace available)

After discovering this syntax error, but not knowing where the heck is was coming from I wanted to test if PDF reports worked. It was initially reported that PDF still worked because there were PDFs being generated. I ran a PDF version of the command above and a PDF was created but after downloading it locally and opening, it was just a blank white document. Both PDF and CSV reports were borked.

I couldn't figure out how to get any more information about the SyntaxError on the server so I went local. I ran the puppeteer script in normal mode(not headless) so Chromium would actually launch and I could poke around. I set the target url to the staging app and I was able to reproduce the same error. The error was coming from a minified javascript build file but it was so big I couldn't get Chromium to pretty print it to actually discover where this error was coming from.

I decided to build the frontend locally and pointed the node script at that and was able to reproduce it there as well. I took the offending minified javascript file and pasted it into the prettier playground to have it nicely formatted and replaced my local version with that.

Side note: I was super impressed the prettier playground/Chrome was able to handle the gigantic file I threw at it.

I re-ran the script to launch chromium and discovered it was erroring on this code.

The null-coalescing assignment syntax. This is in the most recent version of react-redux which got upgraded in the release(intentionally).

The problem was that the version of Chromium that came with puppeteer was using Chromium v80. (I told you this thing has been around and working for a while...) This new syntax wasn't supported until v85.

The app was just crashing because of this. Hence, the blank PDF document being generated. It was generating a PDF of the crashed app.

On the testing server, I created a new little npm project and tried to use the latest puppeteer version(which comes with Chromium) but I couldn't get the newer version of Chromium to work on the server. There were dependencies missing that I was not authorized to update/install. Instead, I figured out the oldest version of puppeteer that uses Chromium >= 85 and installed that. Ran my test script and it everything worked. 🎉

What a ride that was. I never would have thought the Prettier playground would ever help me fix a bug but here we are! Another bug bites the dust. ðŸŠĶ