Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,17 @@ export default defineConfig({
link: '/use-infinite'
},
{
text: 'useMutate',
link: '/use-mutate'
text: 'useMutation',
link: '/use-mutation'
},
{
text: 'useQuery',
link: '/use-query'
},
{
text: 'useRevalidate',
link: '/use-revalidate'
},

]
},
Expand Down
25 changes: 14 additions & 11 deletions docs/api/hook-builders.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ title: Hook Builders

# {{ $frontmatter.title }}

Hook builders initialize `useQuery`, `useImmutate`, `useInfinite`, and `useMutate`.
Hook builders initialize `useQuery`, `useImmutable`, `useInfinite`, `useMutation`, and `useRevalidate`.

Each builder function accepts an instance of a [fetch client](https://openapi-ts.dev/openapi-fetch/api) and a prefix unique to that client.

Expand All @@ -23,7 +23,8 @@ import {
createQueryHook,
createImmutableHook,
createInfiniteHook,
createMutateHook,
createMutationHook,
createRevalidateHook,
} from "swr-openapi";

import type { paths } from "./my-openapi-3-schema"; // generated by openapi-typescript
Expand All @@ -34,10 +35,11 @@ const prefix = "my-api";
export const useQuery = createQueryHook(client, prefix);
export const useImmutable = createImmutableHook(client, prefix);
export const useInfinite = createInfiniteHook(client, prefix);
export const useMutate = createMutateHook(
export const useMutation = createMutationHook(client, prefix);
export const useRevalidate = createRevalidateHook(
client,
prefix,
isMatch, // Or any comparision function
isMatch, // Or any comparison function
);
```

Expand All @@ -50,7 +52,7 @@ Each builder hook accepts the same initial parameters:
- `client`: A [fetch client](https://openapi-ts.dev/openapi-fetch/api).
- `prefix`: A prefix unique to the fetch client.

`createMutateHook` also accepts a third parameter:
`createRevalidateHook` also accepts a third parameter:

- [`compare`](#compare): A function to compare fetch options).

Expand All @@ -59,28 +61,29 @@ Each builder hook accepts the same initial parameters:
- `createQueryHook` → [`useQuery`](./use-query.md)
- `createImmutableHook` → [`useImmutable`](./use-immutable.md)
- `createInfiniteHook` → [`useInfinite`](./use-infinite.md)
- `createMutateHook` → [`useMutate`](./use-mutate.md)
- `createMutationHook` → [`useMutation`](./use-mutation.md)
- `createRevalidateHook` → [`useRevalidate`](./use-revalidate.md)

## `compare`

When calling `createMutateHook`, a function must be provided with the following contract:
When calling `createRevalidateHook`, a function must be provided with the following contract:

```ts
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.
This function is used to determine whether or not a cached request should be updated when `revalidate` is called with fetch options.

My personal recommendation is to use lodash's [`isMatch`][lodash-is-match]:

> Performs a partial deep comparison between object and source to determine if object contains equivalent property values.

```ts
const useMutate = createMutateHook(client, "<unique-key>", isMatch);
const useRevalidate = createRevalidateHook(client, "<unique-key>", isMatch);

const mutate = useMutate();
const revalidate = useRevalidate();

await mutate([
await revalidate([
"/path",
{
params: {
Expand Down
98 changes: 98 additions & 0 deletions docs/api/use-mutation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
---
title: useMutation
---

# {{ $frontmatter.title }}

`useMutation` is a wrapper around SWR's [useSWRMutation][swr-mutation] hook. It provides a type-safe way to perform remote mutations (POST, PUT, PATCH, DELETE) with full OpenAPI type inference.

```ts
const { trigger, isMutating } = useMutation("post", "/pet");

await trigger({
body: { name: "doggie", photoUrls: ["https://example.com/photo.jpg"] }
});
```

## API

### Parameters

- `method`: The HTTP method to use (`"post"`, `"put"`, `"patch"`, or `"delete"`).
- `path`: Any endpoint that supports the specified method.
- `config`: (_optional_) [SWR mutation configuration][swr-mutation-options].

### Returns

Returns the same object as [useSWRMutation][swr-mutation]:

- `trigger`: A function to trigger the mutation. Accepts the request init (body, params, etc.).
- `isMutating`: Whether the mutation is currently in progress.
- `data`: The data returned from a successful mutation.
- `error`: The error thrown if the mutation failed.
- `reset`: A function to reset the state.

## Usage Examples

### POST - Create a resource

```ts
function AddPet() {
const { trigger, isMutating } = useMutation("post", "/pet");

const handleSubmit = async (data: PetData) => {
await trigger({
body: { name: data.name, photoUrls: data.photos }
});
};

return (
<button onClick={() => handleSubmit(formData)} disabled={isMutating}>
{isMutating ? "Adding..." : "Add Pet"}
</button>
);
}
```

### PUT - Update a resource

```ts
function UpdatePet() {
const { trigger } = useMutation("put", "/pet");

const handleUpdate = async (pet: Pet) => {
await trigger({
body: { id: pet.id, name: pet.name, photoUrls: pet.photoUrls }
});
};

return <button onClick={() => handleUpdate(pet)}>Update</button>;
}
```

### DELETE - Remove a resource

```ts
function DeletePet({ petId }: { petId: number }) {
const { trigger, isMutating } = useMutation("delete", "/pet/{petId}");

const handleDelete = async () => {
await trigger({
params: { path: { petId } }
});
};

return (
<button onClick={handleDelete} disabled={isMutating}>
Delete
</button>
);
}
```

## Cache Key

The cache key format is `[prefix, method, path]`. This ensures mutations to the same path with different methods don't collide in the cache.

[swr-mutation]: https://swr.vercel.app/docs/mutation#useswrmutation
[swr-mutation-options]: https://swr.vercel.app/docs/mutation#useswrmutation-parameters
12 changes: 6 additions & 6 deletions docs/api/use-mutate.md → docs/api/use-revalidate.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
---
title: useMutate
title: useRevalidate
---

# {{ $frontmatter.title }}

`useMutate` is a wrapper around SWR's [global mutate][swr-global-mutate] function. It provides a type-safe mechanism for updating and revalidating SWR's client-side cache for specific endpoints.
`useRevalidate` is a wrapper around SWR's [global mutate][swr-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.
Like global mutate, this revalidate 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`](./hook-builders.md#compare) function will determine if a cached request's fetch options match the key's fetch options.

```ts
const mutate = useMutate();
const revalidate = useRevalidate();

await mutate([path, init], data, options);
await revalidate([path, init], data, options);
```

## API
Expand All @@ -38,7 +38,7 @@ await mutate([path, init], data, options);
## How It Works

```ts
function useMutate() {
function useRevalidate() {
const { mutate } = useSWRConfig();
return useCallback(
([path, init], data, opts) => {
Expand Down
163 changes: 163 additions & 0 deletions src/__test__/mutation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import createClient from "openapi-fetch";
import * as React from "react";
import * as SWRMutation from "swr/mutation";
import type { MutationFetcher } from "swr/mutation";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createMutationHook } from "../mutation.js";
import type { paths } from "./fixtures/petstore.js";

type AnyKey = readonly [string, string, string];
type AnyInit = Record<string, unknown>;
type AnyFetcher = MutationFetcher<unknown, AnyKey, AnyInit>;

// Mock `useCallback` and `useDebugValue`
vi.mock("react");
const { useCallback, useDebugValue } = vi.mocked(React);
useCallback.mockImplementation((fn) => fn);

// Mock `useSWRMutation`
vi.mock("swr/mutation");
const { default: useSWRMutation } = vi.mocked(SWRMutation);
useSWRMutation.mockReturnValue({
trigger: vi.fn(),
isMutating: false,
data: undefined,
error: undefined,
reset: vi.fn(),
});

// Setup
const client = createClient<paths>();
const useMutation = createMutationHook(client, "<unique-key>");
const postSpy = vi.spyOn(client, "POST");
const putSpy = vi.spyOn(client, "PUT");
const deleteSpy = vi.spyOn(client, "DELETE");

describe("createMutationHook", () => {
afterEach(() => {
vi.clearAllMocks();
});

describe("POST mutations", () => {
it("passes correct key to useSWRMutation", () => {
useMutation("post", "/pet");

expect(useSWRMutation).toHaveBeenLastCalledWith(
["<unique-key>", "post", "/pet"],
expect.any(Function),
undefined,
);
});

it("fetcher calls client.POST and returns data", async () => {
postSpy.mockResolvedValueOnce({
data: { id: 1, name: "doggie", photoUrls: [] },
error: undefined,
response: new Response(),
});

useMutation("post", "/pet");

const fetcher = useSWRMutation.mock.lastCall![1] as AnyFetcher;
const init = { body: { name: "doggie", photoUrls: [] } };

const result = await fetcher(["<unique-key>", "post", "/pet"] as const, {
arg: init,
});

expect(postSpy).toHaveBeenLastCalledWith("/pet", init);
expect(result).toEqual({ id: 1, name: "doggie", photoUrls: [] });
});

it("fetcher throws on error response", async () => {
postSpy.mockResolvedValueOnce({
data: undefined,
error: { message: "Invalid input" },
response: new Response(),
});

useMutation("post", "/pet");

const fetcher = useSWRMutation.mock.lastCall![1] as AnyFetcher;

await expect(
fetcher(["<unique-key>", "post", "/pet"] as const, { arg: {} }),
).rejects.toEqual({ message: "Invalid input" });
});
});

describe("PUT mutations", () => {
it("passes correct key with put method", () => {
useMutation("put", "/pet");

expect(useSWRMutation).toHaveBeenLastCalledWith(
["<unique-key>", "put", "/pet"],
expect.any(Function),
undefined,
);
});

it("fetcher calls client.PUT", async () => {
putSpy.mockResolvedValueOnce({
data: { id: 1, name: "updated", photoUrls: [] },
error: undefined,
response: new Response(),
});

useMutation("put", "/pet");

const fetcher = useSWRMutation.mock.lastCall![1] as AnyFetcher;
const init = { body: { id: 1, name: "updated", photoUrls: [] } };

await fetcher(["<unique-key>", "put", "/pet"] as const, { arg: init });

expect(putSpy).toHaveBeenLastCalledWith("/pet", init);
});
});

describe("DELETE mutations", () => {
it("passes correct key with delete method", () => {
useMutation("delete", "/pet/{petId}");

expect(useSWRMutation).toHaveBeenLastCalledWith(
["<unique-key>", "delete", "/pet/{petId}"],
expect.any(Function),
undefined,
);
});

it("fetcher calls client.DELETE with path params", async () => {
deleteSpy.mockResolvedValueOnce({
data: undefined,
error: undefined,
response: new Response(),
});

useMutation("delete", "/pet/{petId}");

const fetcher = useSWRMutation.mock.lastCall![1] as AnyFetcher;
const init = { params: { path: { petId: 123 } } };

await fetcher(["<unique-key>", "delete", "/pet/{petId}"] as const, { arg: init });

expect(deleteSpy).toHaveBeenLastCalledWith("/pet/{petId}", init);
});
});

it("invokes debug value hook", () => {
useMutation("post", "/pet");

expect(useDebugValue).toHaveBeenLastCalledWith("<unique-key> - post /pet");
});

it("passes config to useSWRMutation", () => {
// @ts-expect-error - Testing config passthrough
useMutation("post", "/pet", { throwOnError: false });

expect(useSWRMutation).toHaveBeenLastCalledWith(
["<unique-key>", "post", "/pet"],
expect.any(Function),
{ throwOnError: false },
);
});
});
Loading