Generate swr
hooks using OpenAPI schemas
npm install swr-openapi swr openapi-fetch
Follow openapi-typescript directions to generate TypeScript definitions for each service being used.
Here is an example of types being generated for a service via the command line:
npx openapi-typescript "https://sandwiches.example/openapi/json" --output ./types/sandwich-schema.ts
Initialize an openapi-fetch client and create any desired hooks.
// sandwich-api.ts
import createClient from "openapi-fetch";
import { createQueryHook } from "swr-openapi";
import type { paths as SandwichPaths } from "./types/sandwich-schema";
const client = createClient<SandwichPaths>(/* ... */);
const useSandwiches = createQueryHook(client, "sandwich-api");
const { data, error, isLoading, isValidating, mutate } = useSandwiches(
"/sandwich/{id}", // <- Fully typed paths!
{
params: {
path: {
id: "123", // <- Fully typed params!
},
},
},
);
Wrapper hooks are provided 1:1 for each hook exported by SWR.
import createClient from "openapi-fetch";
import {
createQueryHook,
createImmutableHook,
createInfiniteHook,
createMutateHook,
} from "swr-openapi";
import { paths as SomeApiPaths } from "./some-api";
const client = createClient<SomeApiPaths>(/* ... */);
const prefix = "some-api";
export const useQuery = createQueryHook(client, prefix);
export const useImmutable = createImmutableHook(client, prefix);
export const useInfinite = createInfiniteHook(client, prefix);
export const useMutate = createMutateHook(
client,
prefix,
_.isMatch, // Or any comparision function
);
Each builder hook accepts the same initial parameters:
client
: An OpenAPI fetch client.prefix
: A prefix unique to the fetch client.
createMutateHook
also accepts a third parameter:
compare
: A function to compare fetch options).
createQueryHook
→useQuery
createImmutableHook
→useImmutable
createInfiniteHook
→useInfinite
createMutateHook
→useMutate
This hook is a typed wrapper over useSWR
.
const useQuery = createQueryHook(/* ... */);
const { data, error, isLoading, isValidating, mutate } = useQuery(
path,
init,
config,
);
How useQuery
works
useQuery
is a very thin wrapper over useSWR
. Most of the code involves TypeScript generics that are transpiled away.
The prefix supplied in createQueryHook
is joined with path
and init
to form the key passed to SWR.
prefix
is only used to help ensure uniqueness for SWR's cache, in the case that two or more API clients share an identical path (e.g./api/health
). It is not included in actualGET
requests.
Then, GET
is invoked with path
and init
. Short and sweet.
function useQuery(path, ...[init, config]) {
return useSWR(
init !== null ? [prefix, path, init] : null,
async ([_prefix, path, init]) => {
const res = await client.GET(path, init);
if (res.error) {
throw res.error;
}
return res.data;
},
config,
);
}
path
: Any endpoint that supportsGET
requests.init
: (sometimes optional1)- Fetch options for the chosen endpoint.
null
to skip the request (see SWR Conditional Fetching).
config
: (optional) SWR options.
- An SWR response.
This hook has the same contracts as useQuery
. However, instead of wrapping useSWR
, it wraps useSWRImmutable
. This immutable hook disables automatic revalidations but is otherwise identical to useSWR
.
const useImmutable = createImmutableHook(/* ... */);
const { data, error, isLoading, isValidating, mutate } = useImmutable(
path,
init,
config,
);
Identical to useQuery
parameters.
Identical to useQuery
returns.
This hook is a typed wrapper over useSWRInfinite
.
const useInfinite = createInfiniteHook(/* ... */);
const { data, error, isLoading, isValidating, mutate, size, setSize } =
useInfinite(path, getInit, config);
How useInfinite
works
Just as useQuery
is a thin wrapper over useSWR
, useInfinite
is a thin wrapper over useSWRInfinite
.
Instead of using static fetch options as part of the SWR key, useInfinite
is given a function (getInit
) that should dynamically determines the fetch options based on the current page index and the data from a previous page.
function useInfinite(path, getInit, config) {
const fetcher = async ([_, path, init]) => {
const res = await client.GET(path, init);
if (res.error) {
throw res.error;
}
return res.data;
};
const getKey = (index, previousPageData) => {
const init = getInit(index, previousPageData);
if (init === null) {
return null;
}
const key = [prefix, path, init];
return key;
};
return useSWRInfinite(getKey, fetcher, config);
}
path
: Any endpoint that supportsGET
requests.getInit
: A function that returns the fetch options for a given page (learn more).config
: (optional) SWR infinite options.
This function is similar to the getKey
parameter accepted by useSWRInfinite
, with some slight alterations to take advantage of Open API types.
pageIndex
: The zero-based index of the current page to load.previousPageData
:undefined
(if on the first page).- The fetched response for the last page retrieved.
- Fetch options for the next page to load.
null
if no more pages should be loaded.
Example using limit and offset
useInfinite("/something", (pageIndex, previousPageData) => {
// No more pages
if (previousPageData && !previousPageData.hasMore) {
return null;
}
// First page
if (!previousPageData) {
return {
params: {
query: {
limit: 10,
},
},
};
}
// Next page
return {
params: {
query: {
limit: 10,
offset: 10 * pageIndex,
},
},
};
});
Example using cursors
useInfinite("/something", (pageIndex, previousPageData) => {
// No more pages
if (previousPageData && !previousPageData.nextCursor) {
return null;
}
// First page
if (!previousPageData) {
return {
params: {
query: {
limit: 10,
},
},
};
}
// Next page
return {
params: {
query: {
limit: 10,
cursor: previousPageData.nextCursor,
},
},
};
});
useMutate
is a wrapper around SWR's global mutate function. It provides a type-safe mechanism for updating and revalidating SWR's client-side cache for specific endpoints.
Like global mutate, this mutate wrapper accepts three parameters: key
, data
, and options
. The latter two parameters are identical to those in bound mutate. key
can be either a path alone, or a path with fetch options.
The level of specificity used when defining the key will determine which cached requests are updated. If only a path is provided, any cached request using that path will be updated. If fetch options are included in the key, the compare
function will determine if a cached request's fetch options match the key's fetch options.
const mutate = useMutate();
await mutate([path, init], data, options);
How useMutate
works
function useMutate() {
const { mutate } = useSWRConfig();
return useCallback(
([path, init], data, opts) => {
return mutate(
(key) => {
if (!Array.isArray(key) || ![2, 3].includes(key.length)) {
return false;
}
const [keyPrefix, keyPath, keyOptions] = key;
return (
keyPrefix === prefix &&
keyPath === path &&
(init ? compare(keyOptions, init) : true)
);
},
data,
opts,
);
},
[mutate, prefix, compare],
);
}
key
:path
: Any endpoint that supportsGET
requests.init
: (optional) Partial fetch options for the chosen endpoint.
data
: (optional)- Data to update the client cache.
- An async function for a remote mutation.
options
: (optional) SWR mutate options.
- A promise containing an array, where each array item is either updated data for a matched key or
undefined
.
SWR's
mutate
signature specifies that when a matcher function is used, the return type will be an array. Since our wrapper uses a key matcher function, it will always return an array type.
When calling createMutateHook
, a function must be provided with the following contract:
type Compare = (init: any, partialInit: object) => boolean;
This function is used to determine whether or not a cached request should be updated when mutate
is called with fetch options.
My personal recommendation is to use lodash's isMatch
:
Performs a partial deep comparison between object and source to determine if object contains equivalent property values.
const useMutate = createMutateHook(client, "<unique-key>", isMatch);
const mutate = useMutate();
await mutate([
"/path",
{
params: {
query: {
version: "beta",
},
},
},
]);
// ✅ Would be updated
useQuery("/path", {
params: {
query: {
version: "beta",
},
},
});
// ✅ Would be updated
useQuery("/path", {
params: {
query: {
version: "beta",
other: true,
example: [1, 2, 3],
},
},
});
// ❌ Would not be updated
useQuery("/path", {
params: {
query: {},
},
});
// ❌ Would not be updated
useQuery("/path");
// ❌ Would not be updated
useQuery("/path", {
params: {
query: {
version: "alpha",
},
},
});
// ❌ Would not be updated
useQuery("/path", {
params: {
query: {
different: "items",
},
},
});
Footnotes
-
When an endpoint has required params,
init
will be required, otherwiseinit
will be optional. ↩