Providers, a context API for TypeScript

·

5 min read

The previous post discussed what context APIs are and why they are important.

This post outlines Providers, a halfway decent implementation of a TypeScript Context API we built for the NPE Toolkit.

Recap: Goals for a TypeScript context API

  • Allow sharing code across client & server: in React components, Node.js server code, and non-React client code (e.g. background process triggered by a Geofence notification)

  • TypeScript-flavored API, and specifically using types wherever possible

  • Standard definitions of the types for core dependencies, including ID and credentials for the current user, logging, flags, and the current locale

  • Bonus: Can be used in place of React Context to avoid deeply nested provider trees

Overview

The core of the Providers API is straightforward:

  • A key is defined for each provided value (a “ProviderKey”). ProviderKeys are associated with a TypeScript type (for type-safe usage).

  • At startup you configure which providers are used for these keys

  • At runtime call use(ProviderKey) to get a provided value

Example code to use Providers

The code that most developers will interact with:

// Getting a provided value
const log = use(LogApiKey); // <- LogApiKey is a `ProviderKey`
...
log({name: 'UserLike'});

// Configuring Providers on the client
<Scope providers={[/* list of providers */]}>
  ...
</Scope>

// Configuring Providers on the server
addProviderScope([/* list of providers */]); // <- In request initialization code

Example code defining Providers

These calls are only needed if you’re defining new providers:

// Creating a `ProviderKey`
type Theme = {primary: string, secondary: string};
export const ThemeKey = providerKeyFor<Theme>();

// Defining a provider by value
const MyTheme = {primary: 'red', secondary: 'blue'};
providesValue(ThemeKey, MyTheme); // <- `MyTheme` is of type `Theme`

// Defining a provider by function that returns value
function ConsoleLogger() {
  const flags = useFlags();
  enabled = flags.enabled('ConsoleLogger');

  return (event: LogEvent) => {
    if (enabled) {
      console.log(event);
    }
  };
}
provides(LogApiKey, ConsoleLogger);

Providers (MyTheme and ConsoleLogger above) are used in an app by configuring at startup, using <Scope providers=...> on the client, and addProviderScope(...) on the server.

Sharing code between client and server

A challenge in sharing code used in React apps between client and server is that a large percentage of useful client code uses React context. Since React context isn’t available in server request handlers, the code can’t be easily shared.

With providers, all access to context is via use(), with a React Context client implementation and a request-scoped server implementation using cls-hooked.

Having a shared API layer on top of these underlying primitives makes it possible to share code that uses context between client and server.

Changing values at runtime

The useScope() API gives direct access to the context so that you can change values at runtime. This is used, for example, when a user logs in or logs out. Example code:

const scope = useScope();

function onUserChange(user: newUser) {
  scope.provide(UserKey, user);
}

On the client, calls sites that use() a provided value that changed will be re-rendered. On the server, values don’t change during the processing of a single request.

Providers with UI

Some client-only providers need an associated React component tree to provide UI..To support this, you can provide an inner function from the component using useScope().

An example AlertDialog that needs a component at the top level of the React Component tree:

function AlertDialog() {
  const scope = useScope();  // <- Gets a 
  const visible = React.useState(false);

  function showAlert(text: string) {
    ...
    setVisible(true);
  }

  scope.provideValue(AlertApiKey, showAlert);

  return (
    <>
      {visible && <View>
        ...
      </View>
    </>
}

To configure, the app adds <AlertDialog> as a child of the top level<Scope>. To show an alert, the app calls use(AlertApiKey); ... alert('We have a problem');

Other implementation notes

ProviderKeys

  • ProviderKey uses a JavaScript symbol to uniquely identify a providable value.

  • When defining a key using providerKeyFor(), you can pass in a default provider and a string name to use in debugging.

Providing values

  • Calling providesValue(key, value) or provides(key, fnReturningValue) marks the object or function passed in as a provider by adding a symbol to it.

  • Objects or functions marked as providers are not a different TypeScript type than the original value. This was a tradeoff — by using the existing value (as opposed to a wrapper), we avoided a level of indirection which makes it easier for users to find the implementation of a provider, with the downside that you don’t get static type checks for some calls.

Helper functions

  • A common pattern that emerged when using Providers was to wrap the call to use(ProviderKey) with a no-arg function, so clients call useTheme() instead of use(ThemeApiKey). This was just for developer ergonomics — these calls are functionally identical.

  • Additionally, we often found ourselves creating helper functions for API variants, so that we didn’t create a new ProviderKey. An example: we wrapped use(LogApiKey) with useLogEvent(name: string) for a simple logging API.

Server Providers

  • Providers on the server use cls-hooked library to share a thread-level context. This provider context is propagated to threads spawned by the server request.

  • The rules of hooks don’t apply to server code, so use() can be called by any function at any layer of request processing.

Status of Providers

Providers we think will be generally useful, but it hasn’t been exposed as a standalone NPM package because it’s a work in progress. It likely still has some bugs and we may iterate on the API surface.

If you want to use it, feel free to copy!

If you think this should be packaged up and want to contribute, or have any comments or questions, get in touch with me at uidude@innate.net.

About these posts

Thanks for taking an interest in what I have to say! 🙏

Although the tools for building apps have never been better, there aren’t commonly used abstractions for users and auth, logging, flags, data access, storage, and theming, and this feels like a hole that needs to be filled.

In my day job I’m a software engineer at Snowflake (starting in a week). Previously I worked at Meta as part of their New Product Experimentation group, and before that I was at Google, eBay, Ask Jeeves, and a bunch of startups that you probably never heard of.

The “UI” in “uidude” refers to my being a product and frontend infrastructure engineer more than a backend / database dude (although I do manage to find ways to meddle at all layers of the stack). And I’m definitely not a UI designer, as you’ll see if I post any UI that I've built :)