How toCreate a Per-Request Singleton with React Router Middleware

Create a Per-Request Singleton with React Router Middleware

If you're using the new middleware API in React Router v7.3.0, you can leverage this pattern to create a per-request singleton. This is useful for scenarios like per-request caching or batching multiple requests together.

Creating the Middleware

First, let's create a context and middleware that will generate a new singleton instance for each request:

This same middleware is part of Remix Utils so you don't need to copy it

import type {
  unstable_MiddlewareFunction,
  unstable_RouterContextProvider,
} from "react-router";
import { unstable_createContext } from "react-router";
import type { Class } from "type-fest";

export function createSingletonMiddleware<T, A extends unknown[] = any[]>(
  options: createSingletonMiddleware.Options<T, A>
): createSingletonMiddleware.ReturnType<T> {
  let singletonContext = unstable_createContext<T | null>(null);

  return [
    async function singletonMiddleware({ context }, next) {
      let instance = context.get(singletonContext);
      if (instance) return await next();
      instance = new options.Class(...options.arguments);
      context.set(singletonContext, instance);
      return await next();
    },

    function getSingletonInstance(context) {
      let instance = context.get(singletonContext);
      if (!instance) throw new Error("Singleton instance not found");
      return instance;
    },
  ];
}

export namespace createSingletonMiddleware {
  export interface Options<T, A extends unknown[] = any[]> {
    Class: Class<T, A>;
    arguments: A;
  }

  export type ReturnType<T> = [
    unstable_MiddlewareFunction<unstable_RouterContextProvider>,
    (context: unstable_RouterContextProvider) => T
  ];
}

Using the Middleware

Now, you can instantiate the middleware with your class and arguments:

import { createSingletonMiddleware } from "./singleton-middleware";
import { Batcher } from "@edgefirst-dev/batcher";

let [batcherMiddleware, getBatcher] = createSingletonMiddleware({
  Class: Batcher,
  arguments: [],
});

Adding the Middleware to the Router

Next, add the middleware to a route of your choice:

import { batcherMiddleware } from "~/middleware/batcher";
export const unstable_middleware = [batcherMiddleware];

Accessing the Singleton Instance

Now, you can retrieve the singleton instance inside your route loaders:

import { getBatcher } from "~/middleware/batcher";

// routes/a.tsx
export async function loader({ context }: Route.LoaderArgs) {
  let batcher = getBatcher(context);
  let viewer = await batcher.call(["viewer"], () => api.fetchViewer());
  // ...
}

// routes/b.tsx
export async function loader({ context }: Route.LoaderArgs) {
  let batcher = getBatcher(context);
  let viewer = await batcher.call(["viewer"], () => api.fetchViewer());
  // ...
}

Both route loaders will now share the same Batcher instance within a single request.

When to Use This Pattern

This approach ensures a new instance of a class is created only when needed, making it ideal for per-request caching or request-specific batching. However, if you need to share the same instance across multiple requests, a traditional singleton pattern may be more appropriate.