Run incidents without leaving Slack
Tailored to your organization for smarter incident management
Confident, autonomous teams guided by automations
Learn from insights to improve your resilience
Build customer trust even during downtime
Connect everything in your organization
Streamlined incident management for complex companies
Run incidents without leaving Slack
Tailored to your organization for smarter incident management
Confident, autonomous teams guided by automations
Learn from insights to improve your resilience
Build customer trust even during downtime
Connect everything in your organization
Streamlined incident management for complex companies
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.
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.
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:
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.loading
and error
variables, but I’d have to do so explicitly.…but it also had some downsides:
fetcher
had to be written out every time.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!
Okay, so we want to have a way of using SWR which:
data
There were a couple of options:
export const useCustomFields = () => {
const apiClient = useClient();
const fetcher : Fetcher<CustomField[], string> =
(_key) => apiClient.customFieldsList();
return useSWR("/api/custom_fields", fetcher)
}
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.
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:
[api, request]
as the cache keyThe 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.
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 callundefined
, so you’re not allowed to pass in any request parametersCustomFieldsListResponseBody
, 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.
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.
Enter your details to receive our monthly newsletter, filled with incident related insights to help you in your day-to-day!
How our product team use Catalog
In the process of building Catalog, we’ve also been building out the content of our own catalog. This post explains how our product team uses our catalog to store features, integrations, and teams and the powerful Workflows that unlock them all for us.
Sam Starling
How our engineering team uses Polish Parties to maintain quality at pace
In a fast-moving company, quality cannot be delegated to a few individuals—it has to be a shared responsibility. One tool that helps us maintain our quality of work is Polish Parties. Here's how we run these crucial feedback sessions.
Leo Sjöberg
We used GPT-4 during a hackathon—here's what we learned
We learned a lot about using OpenAI and which things to keep an eye on to decide when it’s worth revisiting.
Rory Bain