Dependency injection in Remix loaders and actions

Dependency Injection is a way our function or class can receieve from the caller the instancies of some dependencies the function/class have

Let's say we have a function that sent a query to a DB, we could import directly the DB connection object and send the query, or we could receive the DB connection object as an argument if it follows a specific interface our function expects.

interface DBConnection {
  query(query: string): Promise<unknown>;
}

function getUsers(db: DBConnection) {
  return db.query("SELECT * FROM users");
}

There are many benefits of this, but one I'll focus here is testing, by using dependency injection we could easily mock the DB connection for our tests and test the function without doing many changes.

So using the getUsers from above we could for example do

// create the mock object
let db: DBConnection = { query: jest.fn() };
// mock the return value
db.query.mockReturnValue(Promise.resolve([{ id: 1, name: "Sergio" }]));
// call the function
let users = await getUsers(db);
// assert the result
expect(users).toEqual([{ id: 1, name: "Sergio" }]);

What has all of this to do with Remix? Well, a Remix loader and action function has a context object it can be used to send data from the HTTP server to your function, and we could use this to inject objects to our loader and actions functions (I'm gonna call them data functions from now).

Configuring the HTTP server

Let's say you have used the Express template, and have this in your server.js file

app.all("*", (req, res, next) => {
  if (process.env.NODE_ENV === "development") purgeRequireCache();

  return createRequestHandler({
    build: require(BUILD_DIR),
    mode: process.env.NODE_ENV,
  })(req, res, next);
});

We can add to the createRequestHandler a getLoadContext function that returns the context object we'll pass the the data functions.

let db = new PrismaClient();

app.all("*", (req, res, next) => {
  if (process.env.NODE_ENV === "development") purgeRequireCache();

  return createRequestHandler({
    build: require(BUILD_DIR),
    mode: process.env.NODE_ENV,
    getLoadContext() {
      return { db };
    },
  })(req, res, next);
});

This way, we'll create the PrismaClient instance outside the request and pass it on the context object.

The data functions in the route

Now, if we go to our data functions we can use context.db to access the PrismaClient instance and run our queries.

export async function loader({ params, context }: LoaderArgs) {
  let data = await context.db.user.findUniqueOrThrow({
    where: { id: params.id },
  });
  return json(data);
}

Typing the context

If you use TypeScript, you may notice that context is typed as any, so we lose autocomplete when trying to use our PrismaClient instance.

To solve this, we can create a remix.d.ts file somewhere, let's say at types/remix.d.ts on the root of the project (same level as public or app). There we could re-declare the @remix-run/node package to overwrite the LoaderArgs and ActionArgs types.

import type { PrismaClient } from "@prisma/client";
import "@remix-run/node";
import type { DataFunctionArgs } from "@remix-run/node";

declare module "@remix-run/node" {
  export interface LoaderArgs extends DataFunctionArgs {
    context: { db: PrismaClient };
  }

  export interface ActionArgs extends DataFunctionArgs {
    context: { db: PrismaClient };
  }
}

After this, when we import both types from the official Remix package, we'll have the correct types for the context object.

Testing it

Now let's go to the important part, the test. Let's say we want to test our loader or action function, we could write a test like this one

// create a PrismaClient using a test DB with seed data
let db = new PrismaClient({ datasources: { db: { url: "file:./test.db" } } });

test("the loader return the user for params.id", async () => {
  let request = new Request("/users/1") // mock the request
  let params  = { id: 1 } // mock the params
  // run the loader using the mocked request and params and db
  let response = await loader({ request params, context: { db } });
  // get the body from the response
  let body = await response.json();
  // assert the response and body match what we want
  expect(response.status).toBe(200);
  expect(body).toEqual({ id: 1, name: "Sergio" });
});

Final words

As we can see, using the context object for dependency injection could let us simplify the testing of our data functions, we could also decuple the data sources from our routes.

And we can use this for more things, we could create a logger in our server inject it using context, so we re-use the logger of the HTTP server on the app code.

let logger = new Logger();

app.all("*", (req, res, next) => {
  if (process.env.NODE_ENV === "development") purgeRequireCache();

  return createRequestHandler({
    build: require(BUILD_DIR),
    mode: process.env.NODE_ENV,
    getLoadContext() {
      return { logger };
    },
  })(req, res, next);
});

We could also create an API client for an external API our Remix app consumes.

let api = new ApiClient();

app.all("*", (req, res, next) => {
  if (process.env.NODE_ENV === "development") purgeRequireCache();

  return createRequestHandler({
    build: require(BUILD_DIR),
    mode: process.env.NODE_ENV,
    getLoadContext() {
      return { api };
    },
  })(req, res, next);
});

We could even create a shared cache to avoid re-fetching or re-querying the same data across loaders.

let api = new ApiClient();
let cache = new Cache();
let logger = new Logger();

app.all("*", (req, res, next) => {
  if (process.env.NODE_ENV === "development") purgeRequireCache();

  return createRequestHandler({
    build: require(BUILD_DIR),
    mode: process.env.NODE_ENV,
    getLoadContext() {
      return { api, cache, logger };
    },
  })(req, res, next);
});

And now our loaders we could that.

export function loader({ request, params, context }: LoaderArgs) {
  await authenticate(request)

  context.logger.info("Loading user", { id: params.id });

  return json({ user: await getUser() });

  function getUser() {
    let cacheKey = `user:${params.id}`;

    if (await context.cache.has(cacheKey)) {
      context.logger.info(`Cache hit for ${cacheKey}`);
      return await context.cache.get(params.id);
    }

    context.logger.info(`Cache miss for ${cacheKey}`);

    let user = await context.api.getUser(params.id);

    context.cache.set(cacheKey, user);

    return user;
  }
}

What's even better, on our tests we could mock the ApiClient to return mocked data instead of doing a fetch, we could mock our cache to be an in-memory cache that we reset between tests or we could initiate with "previously cached" data, our logger could don't log anything to avoid spamming the terminal.