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
: becausefetcher
returns a specific generated type,useFetchData
can tell TypeScript “the type ofdata
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
anderror
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 ofTApiFunc
TResponse
must be whatever is inside the promise returned byTApiFunc
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 todata
🎉 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.