Engineering

Integrating the SWR library with a type-safe API client

When we built Status Pages earlier this year, we had the chance to build a new front-end product from scratch, using the latest tools the front-end ecosystem has to offer. We chose NextJS and used the SWR hook to manage data loading.

We ran a hackathon just a few weeks after launching Status Pages, which gave me some time to think about what might be applicable to our main dashboard React app.

What’s SWR?

SWR is a great little library for fetching data from an API into your React app. The acronym stands for “stale while revalidate,” which is a caching strategy best described in the SWR docs:

“SWR is a strategy to first return the data from cache (stale), then send the fetch request (revalidate), and finally come with the up-to-date data”

This is pretty magic: if you visit a page, switch to another page, and then switch back, it will render almost instantly using the stale data from the cache.

There’s also lots of API responses in our app that are used across many different pages: for example, what your custom fields are. Once those are loaded into the cache, we don’t need to wait to refetch them if another page needs them.

Life before SWR

We’d built our own data-loading hook called `useFetchData,` which worked something like this:

// First grab our internal API client from a React Context:
const apiClient = useClient();

// Then write a fetcher function
const fetcher = useCallback(() => apiClient.customFieldsList(), [apiClient]);

// Then have the data get fetched:
const [data, { loading, error, refetch }] = useFetchData(fetcher);

There were some things we really liked about this:

  • It’s all type-checked: because the API client is generated from our OpenAPI schema, if I forget or misspell a parameter, TypeScript will tell me right away.
  • That includes the type of data: because fetcher returns a specific generated type, useFetchData can tell TypeScript “the type of data is whatever the return type of the function I’m passed is,” and the rest happens by magic.
  • It reminds me to think about loading states and failed API requests. I could do nothing with those loading and error variables, but I’d have to do so explicitly.

…but it also had some downsides:

  • On complex pages we needed to load all the data in a React Context, to avoid fetching the same thing many times.
  • It was pretty verbose: the fetcher had to be written out every time.
  • Keeping a page up-to-date once it’s loaded was fiddly: we had another hook that handled “reload content every so often while the app is open,” and it was pretty complex to debug!

Drinking the SWR Kool-Aid

At first we started replacing uses of useFetchData with the direct equivalent:

const apiClient = useClient();
const fetcher = (_key) => apiClient.customFieldsList();
const { data, isLoading, error } = useSWR("/api/custom_fields", fetcher)

This was pretty good, but still quite repetitive.

There also wasn’t a clear pattern for what the cache key should look like. If two people wrote different cache keys for the same data, we’d lose out on the benefits of the shared cache, which isn’t great, but if two people used the same key for different data we’d have really weird bugs.

Imagine writing a component thinking you are working with an array of custom fields but sometimes you’re getting something totally unrelated!

Back to type-safety

Okay, so we want to have a way of using SWR which:

  • Generates a cache key consistently for the same API
  • Understands our generated API client to type-check the request, and use the right type on the returned data

There were a couple of options:

  • Code-generation: Our backend is written in Go, so we’re no strangers to code-gen! With this approach we would generate a hook for each of our internal APIs, something like:
export const useCustomFields = () => {
  const apiClient = useClient();

  const fetcher : Fetcher<CustomField[], string> =
  (_key) => apiClient.customFieldsList();

  return useSWR("/api/custom_fields", fetcher)
}
  • Generics: TypeScript has some pretty powerful generic types magic. This would look something like:
const { data, isLoading, error } = useOurSWRWrapper("customFieldsList");

While we do lots of code-gen in Go, we don’t have a pattern for doing custom code-gen in TypeScript yet, so I decided to see if I could get generics to do the job.

Making generics work

Our internal API client is generated using the OpenAPI Generator for Typescript, which gives gives us a few types that are useful to us.

BaseAPI is the hard-coded part of the client. This has methods for building and executing requests, parsing errors, configuring authentication etc. ClientType combines that with all the methods which are generated from our OpenAPI spec.

That means we can get an enum containing all the possible APIs that can be called like this:

type AvailableAPIs = Exclude<keyof ClientType, keyof BaseAPI>

That’s step one, we can now enforce that you can only use APIs that actually exist!

Now we need to get the request and response type for that request. That’s a little harder, but we can get there step-by-step. After a few hours of trial and error, this is what the hook ended up looking like:


export function useAPI<
  TApi extends AvailableAPIs,
  TApiFunc extends ClientType[TApi], 
  TFetcher extends TApiFunc extends (req: TRequest) => Promise<TResponse>
    ? TApiFunc
    : never,
  TRequest extends Parameters<TApiFunc>[0],
  TResponse extends Awaited<ReturnType<TApiFunc>>
>(
  api: TApi,
  request: TRequest,
): SWRResponse<TResponse, ErrorResponse> {
  ...
}

Phew, that’s a lot of types! Let’s break it up and see what’s going on:

TApi extends AvailableAPIs,

First, we’re creating a type variable called TApi, which must fit into the AvailableAPIs type. In other words, it must be one of the keys of our ClientType, but not one of the utilities on the BaseAPI class.

TApiFunc extends ClientType[TApi],

This is another type variable, this time holding the type of the function on our API client. So if api is of type TApi, then TApiFunc is the type of apiClient[api]. For example, if api = "customFieldsList", TApiFunc is the type of apiClient.customFieldsList.

In other words, this is the type of our API-calling function.

TFetcher extends TApiFunc extends (req: TRequest) => Promise<TResponse>
    ? TApiFunc
    : never,

If you’ve not seen it before, this is a super useful pattern for generic types, but it is a little weird to read, because extends is being used in two slightly-different ways in the same line.

First TFetcher extends ... means “I’m defining a new type variable called TFetcher," and it must fit in to whatever comes after this.

Then we’re using a ternary statement. You can read it as “if TApiFunc looks like a function that takes one argument and returns a promise, then return TApiFunc, otherwise return never." never is a magic type in TypeScript which tells the compiler to produce an error if it’s being forced to use it.

That means that if TApiFunc doesn’t match the expected “one argument and returns a promise” constraint, TypeScript will return a type error. Neat!

TRequest extends Parameters<TApiFunc>[0],
  TResponse extends Awaited<ReturnType<TApiFunc>>

These are just here for convenience: they’re saying:

  • TRequest must be the first parameter of TApiFunc
  • TResponse must be whatever is inside the promise returned by TApiFunc

Alright, with all our types defined, the actual hook is pretty simple:

export function useAPI< ... >(
  api: TApi,
  request: TRequest,
): SWRResponse<TResponse, ErrorResponse> {
  const apiClient = useClient();

  const fetcher = (api, request) =>
    // We need to bind `this` as the apiClient, and pass the 
    // request as the only argument
    apiClient[api].apply(apiClient, [request])

  return useSWR([api, request], fetcher);
}

We are:

  • Grabbing an instance of our API client to perform the request
  • Generating a fetcher function that will call the right method on that client
  • Handing it off to SWR, using [api, request] as the cache key

The SWRResponse<TResponse, ErrorResponse> annotation tells TypeScript that the data will be of type TResponse and any error will be our ErrorResponse type, which comes from our generated client types.

Putting it all together

That’s an awful lot of types and a little bit of code, but it works wonders when you’re using it:

const { data, isLoading, error } = useAPI("customFieldsList", undefined);

With that one line, TypeScript will infer:

  • customFieldsList is a valid API you can call
  • Its request type is undefined, so you’re not allowed to pass in any request parameters
  • Its response type is CustomFieldsListResponseBody, and assigns that type to data

🎉 We’re now back in the nice type-safe world of useFetchData, only we’ve added the magic of SWR to make fewer requests and render pages more quickly, and made our code simpler and harder to mess up.

What did we learn?

Firstly, there are some great tools out there in the React ecosystem. Frontend engineering moves infamously quickly, and there’s great stuff getting built all the time. incident.io has only been around for 2 years, but in that time SWR has gone from pre-1.0 to stable and widely used.

We also learned that creating space for experimentation can produce great results. Our new status page product was just right for that: it was both small and pre-release, which made trying new things safe, but because it was going to become a core part of our product, it was real enough that we could learn a lot about how SWR works in the wild.

It’s easy to experiment with new libraries and techniques when building something small and low-risk, but getting successful experiments adopted across your whole codebase is where the real value lies. Using something like SWR for a little side-project can validate that it’s very cool and useful (it is!), but doesn’t help you find the tricky parts of rolling it out across a large codebase with lots of people working on it.

Picture of Isaac Seymour
Isaac Seymour
Product Engineer

Operational excellence starts here