Use React-like hooks in your ExpressJS apps

Use React-like hooks in your ExpressJS apps

ยท

7 min read

If you are a React developer, you know how hooks like useState are flexible and powerful, making it easier to write and maintain code.

With the introduction of AsyncLocalStorage in NodeJS, I figured out a way to implement React-like hooks in a plain NodeJS application, which can be used in ExpressJS apps, for example.

Motivation

Sometimes, you need to pass values around during a request, and it is not always convenient or even recommended to stuff those values in the request object and pass it as a parameter to classes and functions. While this surely works, it is not clean and not advisable in big code bases.

One use case would be to auto-filter results from a database query by the current userId making a request. For this, we could extract the userId from the auth token and then use it in the "find" middleware in Mongoose to filter results without the developer having to manually specify the filter (which they could forget to do).

That would require the userId to travel from a middleware at the start of a request down to the database layer, and a React-like useState hook could come a long way in helping us achieve that without tight coupling.

So let's see how to do it.

AsyncLocalStorage

AsyncLocalStorage is what enables it all.

AsyncLocalStorage is a utility provided by the async_hooks module in Node.js that allows you to store and retrieve values that are specific to the current execution context. This can be useful for storing values that need to be shared across asynchronous code, but should not be shared between different execution contexts (such as requests in a server application).

To use AsyncLocalStorage, you can create an instance of the AsyncLocalStorage class and store values using the set method. You can then retrieve the stored values using the get method. For example:

const { AsyncLocalStorage } = require('async_hooks');

const storage = new AsyncLocalStorage();

storage.set('key', 'value');
console.log(storage.get('key')); // prints 'value'

AsyncLocalStorage works by associating values with a specific execution context, which is represented by an "asyncId" in Node.js. When you call the set method, the value is stored using the current asyncId as the key. When you call the get method, the value is retrieved using the current asyncId as the key. This ensures that the stored value is only accessible within the same execution context.

Creating React-like hooks

We start by creating some helper functions to make it easier to use AsyncLocalStorage:

// src/requestContext.ts

import { AsyncLocalStorage } from "async_hooks";

const context = new AsyncLocalStorage<Map<string, unknown>>();

/**
 * Creates a new request context, which is a map of key-value
 * pairs that can be accessed from anywhere in the request.
 */
export function withRequestContext(callback: () => void) {
  context.run(new Map<string, unknown>(), callback);
}

/**
 * Ends the current request context.
 */
export function endRequestContext() {
  context.exit(() => {
    return;
  });
}

/**
 * Gets the value for the given key from the current request context.
 * @param key The key to get the value for.
 * @returns The value for the given key.
 */
export function getRequestContextValue<T = unknown>(key: string): T {
  const storeMap = getStoreMapOrThrow();
  return storeMap.get(key) as T;
}

/**
 * Sets the value for the given key in the current request context.
 * @param key The key to set the value for.
 * @param value The value to set.
 */
export function setRequestContextValue<TValue = unknown>(key: string, value: TValue): void {
  const storeMap = getStoreMapOrThrow();
  storeMap.set(key, value);
}

function getStoreMapOrThrow(): Map<string, unknown> | never {
  const storeMap = context.getStore();
  if (!storeMap)
    throw new Error(
      "Store is not initialized or in being called from outside of a request context."
    );
  return storeMap;
}

Then, we use these functions to create the useState hook, very similar to that of React:

// src/hooks/useState.ts
import {
  getRequestContextValue,
  setRequestContextValue,
} from "../requestContext";

/**
 * A React-like hook that returns a value from the request context, and a setter function to set the value.
 * @param key Key to use to get and set the value in the request context.
 * @param initialValue Initial value to set if the value is not set in the request context.
 * @returns A tuple containing the value and a setter function.
 * @throws If the request context is not initialized.
 * @example
 * const [value, setValue] = useState("key", "initialValue");
 */
export function useState<TValue = unknown>(
  key: string,
  initialValue?: TValue
): [TValue, (value: TValue) => void] | never {
  if (getRequestContextValue(key) === undefined) setRequestContextValue(key, initialValue);
  return [(getRequestContextValue(key) ?? null) as TValue, setRequestContextValue.bind(null, key)];
}

Now, we use these to create a middleware to initialize a new AsyncLocalStorage for the request, and another one to terminate it so that we don't leave values hanging in memory:

// src/middlewares/requestContext.ts

import { NextFunction, Request, Response } from "express";
import { useState } from "../hooks/useState";
import {
  withRequestContext,
  endRequestContext as _endRequestContext,
} from "../requestContext";
import { v4 as uuid } from "uuid";

export const REQUEST_ID_KEY = "__requestId__";

/**
 * Middleware to create a request context and set the request id on the request.
 * THIS ENABLES THE USE OF HOOKS.
 */
export function requestContext() {
  return (req: Request, res: Response, next: NextFunction) => {
    withRequestContext(() => {
      useState(REQUEST_ID_KEY, uuid());
      next();
    });
  };
}

export function endRequestContext() {
  return (req: Request, res: Response, next: NextFunction) => {
    _endRequestContext();
    next();
  };
}

/**
 * Gets the request id for the current request.
 * @returns The request id.
 */
export function getRequestId(): string {
  const [requestId] = useState<string>(REQUEST_ID_KEY);
  if (!requestId) {
    throw new Error(
      "Request id is not defined. Ensure that the requestContext middleware is used."
    );
  }
  return requestId;
}

Finally, we configure our Express app with those middlewares and use the useState hook.

// src/app.ts

const app = express();
app.use(requestContext()); // init the context

// Handle requests here
app.get("/", (req: Request, res: Response) => {
    const [value, setValue] = useState("myValue", 0);
    res.status(200).send("OK");
});

app.use(endRequestContext()); // terminate the context

Use case: auto-filter results in a database query

This is what we are going to do:

  1. Extract the userId from the auth token;

  2. Store the userId in the request context;

  3. Use the userId in a mongoose middleware to auto-filter the request.

The useCurrentUser hook will let us set the current user for the request and read it anywhere:

// src/hooks/useCurrentUser.ts

import { useState } from "./useState";

const KEY = "__currentUser__";

export function useCurrentUser(initialValue?: string):
  [string, (value: string) => void] {
  return useState(KEY, initialValue);
}

The currentUser middleware will extract the userId from the auth token and set it using the useCurrentUser hook we just created.

// src/middlewares/currentUser.ts

import { Request, Response, NextFunction } from "express";
import { useCurrentUser } from "../hooks/useCurrentUser";

/**
 * Middleware to set the current user on the request.
 * Get the user id by invoking the `useCurrentUser` hook.
 * Make sure to use the `requestContext` middleware before this one.
 */
export function currentUser() {
  return (req: Request, res: Response, next: NextFunction) => {
    // TODO: Get the user id from the auth token
    // For this demo, we just read it straight from a "x-userid" header
    // or use a fixed id.
    const userId = req.header("x-userid") ?? "639bc79418063f2751e2ca43";
    useCurrentUser(userId);
    next();
  };
}

We add the currentUser middleware and perform a database query without ever passing the userId as a filter.

// src/app.ts

const app = express();
app.use(requestContext()); // init the context
app.use(currentUser()); // inject the current user

// Handle requests here
app.get("/", async (req: Request, res: Response) => {
    // Note how we don't specify any filters here
    const products = await Product.find({});
    res.status(200).send(products);
});

app.use(endRequestContext()); // terminate the context

In the mongoose schema, we create a middleware that applies a filter by ownerId to every query; it reads the current userId from the request context using the same useCurrentUser hook.

// src/models/Product.ts

const schema = new mongoose.Schema({
  name: String,
  ownerId: mongoose.Types.ObjectId
});

// The filter is automatically applied base on the current user id.
schema.pre(/^find/, function (next) {
  const [userId] = useCurrentUser();
  this.where({ ownerId: userId });
  return next();
});

And that's it ๐Ÿ™‚

Now you can enjoy React-like hooks in your back-end apps.

ย