@tpzdsp/next-toolkit 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpzdsp/next-toolkit",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "description": "A reusable React component library for Next.js applications",
5
5
  "packageManager": "pnpm@11.5.3",
6
6
  "engines": {
@@ -122,11 +122,12 @@
122
122
  "dev:push": "yalc push",
123
123
  "tsc:check": "tsc -b --noEmit",
124
124
  "storybook": "storybook dev -p 6006",
125
+ "build-storybook": "storybook build",
125
126
  "release": "semantic-release",
126
127
  "postinstall": "if [ \"$CI\" != \"true\" ]; then ./postinstall.sh; fi"
127
128
  },
128
129
  "devDependencies": {
129
- "@better-fetch/fetch": "^1.1.21",
130
+ "@better-fetch/fetch": "^1.3.1",
130
131
  "@commitlint/cli": "^21.1.0",
131
132
  "@commitlint/config-conventional": "^21.1.0",
132
133
  "@commitlint/types": "^21.1.0",
@@ -0,0 +1,100 @@
1
+ import z from 'zod/v4';
2
+
3
+ import { apiFetch } from './fetch';
4
+ import { ApiError, ApiErrorSchema } from '../errors';
5
+
6
+ const UserSchema = z.object({ id: z.number(), name: z.string() });
7
+
8
+ const USERS_URL = 'https://api.test/users/1';
9
+ const CREATE_USER_URL = 'https://api.test/create-user';
10
+ const INVALID_NAME_ERROR = { message: 'Invalid name', details: 'name is required' };
11
+
12
+ const stubFetchResponse = (status: number, body: unknown) => {
13
+ vi.stubGlobal(
14
+ 'fetch',
15
+ vi.fn(
16
+ async () =>
17
+ new Response(JSON.stringify(body), {
18
+ status,
19
+ headers: { 'Content-Type': 'application/json' },
20
+ }),
21
+ ),
22
+ );
23
+ };
24
+
25
+ describe('apiFetch', () => {
26
+ let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
27
+
28
+ beforeEach(() => {
29
+ // `ApiError` logs to `console.error` on construction - not relevant to what these tests assert on.
30
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
31
+ });
32
+
33
+ afterEach(() => {
34
+ consoleErrorSpy.mockRestore();
35
+ vi.unstubAllGlobals();
36
+ });
37
+
38
+ it('returns a `{ data, error }` tuple on success by default', async () => {
39
+ stubFetchResponse(200, { id: 1, name: 'Alice' });
40
+
41
+ const result = await apiFetch<{ id: number; name: string }>(USERS_URL, {
42
+ errorSchema: ApiErrorSchema,
43
+ output: UserSchema,
44
+ });
45
+
46
+ expect(result).toEqual({ data: { id: 1, name: 'Alice' }, error: null });
47
+ });
48
+
49
+ it('resolves directly with the data when `throw: true`', async () => {
50
+ stubFetchResponse(200, { id: 1, name: 'Alice' });
51
+
52
+ const data = await apiFetch<{ id: number; name: string }>(USERS_URL, {
53
+ errorSchema: ApiErrorSchema,
54
+ output: UserSchema,
55
+ throw: true,
56
+ });
57
+
58
+ expect(data).toEqual({ id: 1, name: 'Alice' });
59
+ });
60
+
61
+ it('returns the raw, unparsed error in the tuple when the request fails and `throw` is not set', async () => {
62
+ stubFetchResponse(404, { message: 'Not Found' });
63
+
64
+ const result = await apiFetch('https://api.test/users/999', { errorSchema: ApiErrorSchema });
65
+
66
+ expect(result.data).toBeNull();
67
+ expect(result.error).toMatchObject({ message: 'Not Found', status: 404 });
68
+ });
69
+
70
+ it('throws a normalized `ApiError` when the request fails and `throw: true`', async () => {
71
+ stubFetchResponse(400, INVALID_NAME_ERROR);
72
+
73
+ await expect(
74
+ apiFetch(CREATE_USER_URL, { errorSchema: ApiErrorSchema, throw: true }),
75
+ ).rejects.toThrow(ApiError);
76
+
77
+ stubFetchResponse(400, INVALID_NAME_ERROR);
78
+
79
+ const error = await apiFetch<never>(CREATE_USER_URL, {
80
+ errorSchema: ApiErrorSchema,
81
+ throw: true,
82
+ }).catch((caught: unknown) => caught as ApiError);
83
+
84
+ expect(error).toBeInstanceOf(ApiError);
85
+ expect(error.message).toBe(INVALID_NAME_ERROR.message);
86
+ expect(error.details).toBe(INVALID_NAME_ERROR.details);
87
+ expect(error.status).toBe(400);
88
+ });
89
+
90
+ it('normalizes a network-level failure into an `ApiError`, regardless of `throw`', async () => {
91
+ vi.stubGlobal(
92
+ 'fetch',
93
+ vi.fn(async () => {
94
+ throw new TypeError('fetch failed');
95
+ }),
96
+ );
97
+
98
+ await expect(apiFetch(USERS_URL, { errorSchema: ApiErrorSchema })).rejects.toThrow(ApiError);
99
+ });
100
+ });
package/src/http/fetch.ts CHANGED
@@ -4,12 +4,14 @@ import z from 'zod/v4';
4
4
  import {
5
5
  createSchema as betterFetchCreateSchema,
6
6
  createFetch as betterFetchCreateFetch,
7
+ betterFetch,
7
8
  type CreateFetchOption as BetterFetchCreateFetchOption,
8
9
  type Schema,
9
10
  type FetchSchema,
10
11
  ValidationError,
11
12
  BetterFetchError,
12
13
  type BetterFetch,
14
+ type BetterFetchResponse,
13
15
  type StandardSchemaV1,
14
16
  type BetterFetchPlugin,
15
17
  } from '@better-fetch/fetch';
@@ -263,3 +265,41 @@ export const createFetch = <
263
265
  }
264
266
  }) as typeof instance;
265
267
  };
268
+
269
+ /**
270
+ * Performs a single, one-off `better-fetch` request without needing a fetch schema or a
271
+ * `createFetch` instance - useful for requests that don't belong to any particular API/schema.
272
+ *
273
+ * Applies the same defaults as {@link createFetch} (basic auth from environment variables, the
274
+ * toolkit plugin) and normalizes any thrown error into an {@link ApiError}.
275
+ *
276
+ * As with plain `better-fetch`, pass `throw: true` in `options` to have a failed request throw
277
+ * (normalized to an {@link ApiError}) instead of resolving to a `{ data, error }` tuple - the
278
+ * `error` side of that tuple is whatever the server responded with, unparsed, same as plain
279
+ * `better-fetch` (only the *thrown* error path is normalized/validated against `errorSchema`).
280
+ */
281
+ type ApiFetch = {
282
+ // `throw: true` - resolves directly with the data, or throws a normalized `ApiError`.
283
+ <TRes = unknown, Option extends CreateFetchOption = CreateFetchOption>(
284
+ url: string,
285
+ options: Option & { throw: true },
286
+ ): Promise<TRes>;
287
+
288
+ // Default (`throw` unset/`false`) - resolves with a `{ data, error }` tuple.
289
+ <TRes = unknown, Option extends CreateFetchOption = CreateFetchOption>(
290
+ url: string,
291
+ options: Option,
292
+ ): Promise<BetterFetchResponse<TRes, unknown, false>>;
293
+ };
294
+
295
+ export const apiFetch: ApiFetch = async (url: string, options: CreateFetchOption) => {
296
+ try {
297
+ return await betterFetch(url, {
298
+ auth: getAuth(),
299
+ ...options,
300
+ plugins: [...(options.plugins ?? []), TOOLKIT_PLUGIN],
301
+ });
302
+ } catch (error) {
303
+ throw await normalizeError(error, options);
304
+ }
305
+ };
@@ -0,0 +1,366 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Suspense } from 'react';
3
+
4
+ import { ErrorBoundary } from 'react-error-boundary';
5
+ import z from 'zod/v4';
6
+
7
+ import { type BetterFetch, type CreateFetchOption } from '@better-fetch/fetch';
8
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
9
+
10
+ import { HttpMethod } from './constants';
11
+ import { createSchema } from './fetch';
12
+ import { reactQueryBetterFetch } from './query';
13
+ import { ApiError } from '../errors';
14
+ import { render, renderHook, screen, userEvent, waitFor } from '../test/renderers';
15
+
16
+ const Route = {
17
+ USERS: '/users',
18
+ USER_BY_ID: '/users/:id',
19
+ RAW_USERS: '/raw-users',
20
+ CREATE_USER: '/create-user',
21
+ } as const;
22
+
23
+ const UserSchema = z.object({ id: z.number(), name: z.string() });
24
+ const CreateUserInputSchema = z.object({ name: z.string() });
25
+
26
+ // A schema with a mix of routes to exercise every branch of the hook overloads:
27
+ // - a route with a schema-defined `output` (`RoutesWithOutput`)
28
+ // - a route with a schema-defined `output` AND a `params` schema (`RoutesWithInput`)
29
+ // - a route with no `output` at all (`RoutesWithoutOutput`, requires a manual generic)
30
+ // - a route with a schema-defined `input` (`RoutesWithInput`, used for mutations)
31
+ const testSchema = createSchema({
32
+ [Route.USERS]: {
33
+ method: HttpMethod.Get,
34
+ output: z.array(UserSchema),
35
+ },
36
+ [Route.USER_BY_ID]: {
37
+ method: HttpMethod.Get,
38
+ output: UserSchema,
39
+ params: z.object({ id: z.string() }),
40
+ },
41
+ [Route.RAW_USERS]: {
42
+ method: HttpMethod.Get,
43
+ },
44
+ [Route.CREATE_USER]: {
45
+ method: HttpMethod.Post,
46
+ input: CreateUserInputSchema,
47
+ output: UserSchema,
48
+ },
49
+ });
50
+
51
+ type TestOption = CreateFetchOption & { schema: typeof testSchema };
52
+
53
+ // `reactQueryBetterFetch` only cares about `instance` as a callable matching `BetterFetch<Option>` -
54
+ // the actual fetching is entirely mocked here, so we don't need a real network layer to test the
55
+ // hooks' schema-driven typing, query/mutation key building, and React Query wiring.
56
+ const createTestInstance = () => {
57
+ const fetchMock = vi.fn();
58
+ const instance = fetchMock as unknown as BetterFetch<TestOption>;
59
+
60
+ return { fetchMock, instance };
61
+ };
62
+
63
+ const createTestQueryClient = () =>
64
+ new QueryClient({
65
+ defaultOptions: {
66
+ queries: { retry: false },
67
+ mutations: { retry: false },
68
+ },
69
+ });
70
+
71
+ const createQueryWrapper = (client: QueryClient) => {
72
+ const Wrapper = ({ children }: { children: ReactNode }) => (
73
+ <QueryClientProvider client={client}>{children}</QueryClientProvider>
74
+ );
75
+
76
+ return Wrapper;
77
+ };
78
+
79
+ // Renders the fetched user's name - shared between the suspense tests below, which only differ in
80
+ // how the fetch mock resolves/rejects and what's wrapped around this component.
81
+ const createUserNameComponent = (
82
+ useBetterSuspenseQuery: ReturnType<
83
+ typeof reactQueryBetterFetch<TestOption>
84
+ >['useBetterSuspenseQuery'],
85
+ ) => {
86
+ const UserName = () => {
87
+ const { data } = useBetterSuspenseQuery(Route.USER_BY_ID, { params: { id: '1' } });
88
+
89
+ return <p>{data.name}</p>;
90
+ };
91
+
92
+ return UserName;
93
+ };
94
+
95
+ describe('reactQueryBetterFetch', () => {
96
+ let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
97
+
98
+ beforeEach(() => {
99
+ // `ApiError` logs to `console.error` on construction, and React logs errors caught by an
100
+ // `ErrorBoundary` - neither is relevant to what these tests assert on.
101
+ consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
102
+ });
103
+
104
+ afterEach(() => {
105
+ consoleErrorSpy.mockRestore();
106
+ });
107
+
108
+ it('builds a strict, schema-validated route map for the test fixture', () => {
109
+ expect(Object.keys(testSchema.schema)).toEqual(Object.values(Route));
110
+ expect(testSchema.config).toEqual({ strict: true });
111
+ });
112
+
113
+ describe('useBetterQuerySafe', () => {
114
+ it('fetches data for a route with a schema-defined output', async () => {
115
+ const { fetchMock, instance } = createTestInstance();
116
+
117
+ fetchMock.mockResolvedValue([{ id: 1, name: 'Alice' }]);
118
+
119
+ const { useBetterQuerySafe } = reactQueryBetterFetch<TestOption>(instance);
120
+ const queryClient = createTestQueryClient();
121
+
122
+ const { result } = renderHook(() => useBetterQuerySafe(Route.USERS), {
123
+ wrapper: createQueryWrapper(queryClient),
124
+ });
125
+
126
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
127
+
128
+ expect(result.current.data).toEqual([{ id: 1, name: 'Alice' }]);
129
+ expect(fetchMock).toHaveBeenCalledWith(Route.USERS, undefined);
130
+ });
131
+
132
+ it('supports a manually specified output type for routes without a schema output', async () => {
133
+ const { fetchMock, instance } = createTestInstance();
134
+
135
+ fetchMock.mockResolvedValue([{ id: 9, extra: true }]);
136
+
137
+ const { useBetterQuerySafe } = reactQueryBetterFetch<TestOption>(instance);
138
+ const queryClient = createTestQueryClient();
139
+
140
+ const { result } = renderHook(
141
+ () => useBetterQuerySafe<{ id: number; extra: boolean }[]>(Route.RAW_USERS),
142
+ { wrapper: createQueryWrapper(queryClient) },
143
+ );
144
+
145
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
146
+
147
+ expect(result.current.data).toEqual([{ id: 9, extra: true }]);
148
+ });
149
+
150
+ it('builds the query key from the route and fetch options, refetching when they change', async () => {
151
+ const { fetchMock, instance } = createTestInstance();
152
+
153
+ fetchMock.mockImplementation(async (_route: string, options: { params: { id: string } }) => ({
154
+ id: Number(options.params.id),
155
+ name: `User ${options.params.id}`,
156
+ }));
157
+
158
+ const { useBetterQuerySafe } = reactQueryBetterFetch<TestOption>(instance);
159
+ const queryClient = createTestQueryClient();
160
+
161
+ const { result, rerender } = renderHook(
162
+ ({ id }: { id: string }) => useBetterQuerySafe(Route.USER_BY_ID, { params: { id } }),
163
+ { wrapper: createQueryWrapper(queryClient), initialProps: { id: '1' } },
164
+ );
165
+
166
+ await waitFor(() => expect(result.current.data).toEqual({ id: 1, name: 'User 1' }));
167
+
168
+ rerender({ id: '2' });
169
+
170
+ await waitFor(() => expect(result.current.data).toEqual({ id: 2, name: 'User 2' }));
171
+
172
+ expect(fetchMock).toHaveBeenCalledTimes(2);
173
+ });
174
+
175
+ it('applies a `select` transform, exposing the transformed shape as `data`', async () => {
176
+ const { fetchMock, instance } = createTestInstance();
177
+
178
+ fetchMock.mockResolvedValue({ id: 1, name: 'Alice' });
179
+
180
+ const { useBetterQuerySafe } = reactQueryBetterFetch<TestOption>(instance);
181
+ const queryClient = createTestQueryClient();
182
+
183
+ const { result } = renderHook(
184
+ () =>
185
+ useBetterQuerySafe(
186
+ Route.USER_BY_ID,
187
+ { params: { id: '1' } },
188
+ { select: (user) => user.name },
189
+ ),
190
+ { wrapper: createQueryWrapper(queryClient) },
191
+ );
192
+
193
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
194
+
195
+ expect(result.current.data).toBe('Alice');
196
+ });
197
+
198
+ it('returns the error instead of throwing when the request fails', async () => {
199
+ const apiError = ApiError.notFound('user not found');
200
+ const { fetchMock, instance } = createTestInstance();
201
+
202
+ fetchMock.mockRejectedValue(apiError);
203
+
204
+ const { useBetterQuerySafe } = reactQueryBetterFetch<TestOption>(instance);
205
+ const queryClient = createTestQueryClient();
206
+
207
+ const { result } = renderHook(
208
+ () => useBetterQuerySafe(Route.USER_BY_ID, { params: { id: '1' } }),
209
+ { wrapper: createQueryWrapper(queryClient) },
210
+ );
211
+
212
+ await waitFor(() => expect(result.current.isError).toBe(true));
213
+
214
+ expect(result.current.error).toBe(apiError);
215
+ });
216
+ });
217
+
218
+ describe('useBetterSuspenseQuery', () => {
219
+ it('suspends while loading and resolves with the schema-derived data', async () => {
220
+ const { fetchMock, instance } = createTestInstance();
221
+
222
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
223
+ let resolveFetch: (value: unknown) => void = () => {};
224
+
225
+ fetchMock.mockReturnValue(
226
+ new Promise((resolve) => {
227
+ resolveFetch = resolve;
228
+ }),
229
+ );
230
+
231
+ const { useBetterSuspenseQuery } = reactQueryBetterFetch<TestOption>(instance);
232
+ const queryClient = createTestQueryClient();
233
+ const UserName = createUserNameComponent(useBetterSuspenseQuery);
234
+
235
+ render(
236
+ <QueryClientProvider client={queryClient}>
237
+ <Suspense fallback={<p>loading...</p>}>
238
+ <UserName />
239
+ </Suspense>
240
+ </QueryClientProvider>,
241
+ );
242
+
243
+ expect(screen.getByText('loading...')).toBeInTheDocument();
244
+
245
+ resolveFetch({ id: 1, name: 'Alice' });
246
+
247
+ expect(await screen.findByText('Alice')).toBeInTheDocument();
248
+ });
249
+
250
+ it('throws to the nearest error boundary when the request fails', async () => {
251
+ const apiError = ApiError.internalServerError('boom');
252
+ const { fetchMock, instance } = createTestInstance();
253
+
254
+ fetchMock.mockRejectedValue(apiError);
255
+
256
+ const { useBetterSuspenseQuery } = reactQueryBetterFetch<TestOption>(instance);
257
+ const queryClient = createTestQueryClient();
258
+ const onError = vi.fn();
259
+ const UserName = createUserNameComponent(useBetterSuspenseQuery);
260
+
261
+ render(
262
+ <QueryClientProvider client={queryClient}>
263
+ <ErrorBoundary fallback={<p>failed</p>} onError={onError}>
264
+ <Suspense fallback={<p>loading...</p>}>
265
+ <UserName />
266
+ </Suspense>
267
+ </ErrorBoundary>
268
+ </QueryClientProvider>,
269
+ );
270
+
271
+ expect(await screen.findByText('failed')).toBeInTheDocument();
272
+ expect(onError).toHaveBeenCalledWith(apiError, expect.anything());
273
+ });
274
+
275
+ it('supports a manually specified output type for routes without a schema output', async () => {
276
+ const { fetchMock, instance } = createTestInstance();
277
+
278
+ fetchMock.mockResolvedValue({ id: 9, extra: true });
279
+
280
+ const { useBetterSuspenseQuery } = reactQueryBetterFetch<TestOption>(instance);
281
+ const queryClient = createTestQueryClient();
282
+
283
+ const { result } = renderHook(
284
+ () => useBetterSuspenseQuery<{ id: number; extra: boolean }>(Route.RAW_USERS),
285
+ { wrapper: createQueryWrapper(queryClient) },
286
+ );
287
+
288
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
289
+
290
+ expect(result.current.data).toEqual({ id: 9, extra: true });
291
+ });
292
+ });
293
+
294
+ describe('useBetterMutation', () => {
295
+ it('sends the merged fetch and mutation input and returns the schema output', async () => {
296
+ const { fetchMock, instance } = createTestInstance();
297
+
298
+ fetchMock.mockResolvedValue({ id: 5, name: 'Bob' });
299
+
300
+ const { useBetterMutation } = reactQueryBetterFetch<TestOption>(instance);
301
+ const queryClient = createTestQueryClient();
302
+
303
+ const { result } = renderHook(() => useBetterMutation(Route.CREATE_USER), {
304
+ wrapper: createQueryWrapper(queryClient),
305
+ });
306
+
307
+ const data = await result.current.mutateAsync({ body: { name: 'Bob' } });
308
+
309
+ expect(data).toEqual({ id: 5, name: 'Bob' });
310
+ expect(fetchMock).toHaveBeenCalledWith(Route.CREATE_USER, { body: { name: 'Bob' } });
311
+ });
312
+
313
+ it('throws the error during render so the nearest error boundary catches it', async () => {
314
+ const user = userEvent.setup();
315
+ const apiError = ApiError.badRequest('invalid name');
316
+ const { fetchMock, instance } = createTestInstance();
317
+
318
+ fetchMock.mockRejectedValue(apiError);
319
+
320
+ const { useBetterMutation } = reactQueryBetterFetch<TestOption>(instance);
321
+ const queryClient = createTestQueryClient();
322
+ const onError = vi.fn();
323
+
324
+ const CreateUserButton = () => {
325
+ const { mutate } = useBetterMutation(Route.CREATE_USER);
326
+
327
+ return <button onClick={() => mutate({ body: { name: '' } })}>create</button>;
328
+ };
329
+
330
+ render(
331
+ <QueryClientProvider client={queryClient}>
332
+ <ErrorBoundary fallback={<p>failed</p>} onError={onError}>
333
+ <CreateUserButton />
334
+ </ErrorBoundary>
335
+ </QueryClientProvider>,
336
+ );
337
+
338
+ await user.click(screen.getByRole('button', { name: 'create' }));
339
+
340
+ expect(await screen.findByText('failed')).toBeInTheDocument();
341
+ expect(onError).toHaveBeenCalledWith(apiError, expect.anything());
342
+ });
343
+ });
344
+
345
+ describe('useBetterMutationSafe', () => {
346
+ it('does not throw on error, exposing it via the mutation result instead', async () => {
347
+ const apiError = ApiError.badRequest('invalid name');
348
+ const { fetchMock, instance } = createTestInstance();
349
+
350
+ fetchMock.mockRejectedValue(apiError);
351
+
352
+ const { useBetterMutationSafe } = reactQueryBetterFetch<TestOption>(instance);
353
+ const queryClient = createTestQueryClient();
354
+
355
+ const { result } = renderHook(() => useBetterMutationSafe(Route.CREATE_USER), {
356
+ wrapper: createQueryWrapper(queryClient),
357
+ });
358
+
359
+ result.current.mutate({ body: { name: '' } });
360
+
361
+ await waitFor(() => expect(result.current.isError).toBe(true));
362
+
363
+ expect(result.current.error).toBe(apiError);
364
+ });
365
+ });
366
+ });
package/src/http/query.ts CHANGED
@@ -9,9 +9,12 @@ import {
9
9
  type BetterFetchOption,
10
10
  } from '@better-fetch/fetch';
11
11
  import {
12
+ useMutation,
12
13
  useQuery,
13
14
  useSuspenseQuery,
14
15
  type QueryKey,
16
+ type UseMutationOptions,
17
+ type UseMutationResult,
15
18
  type UseQueryOptions,
16
19
  type UseQueryResult,
17
20
  type UseSuspenseQueryOptions,
@@ -84,8 +87,8 @@ type SchemaRoutesOf<Option extends CreateFetchOption> = Option extends {
84
87
  ? Routes
85
88
  : undefined;
86
89
 
87
- // We provide the `queryFn` and `queryKeys` automatically, so no need to let the user provide them.
88
- type OmitReactQueryKeys = 'queryKey' | 'queryFn';
90
+ // We provide these keys automatically, so no need to let the user provide them.
91
+ type OmitReactQueryKeys = 'queryKey' | 'queryFn' | 'mutationFn' | 'mutationKey';
89
92
 
90
93
  /**
91
94
  * A higher-order type that creates React Query hooks (`useBetterQuerySafe` and `useBetterSuspenseQuery`)
@@ -127,30 +130,32 @@ type ReactQueryBetterFetch = <
127
130
  useBetterQuerySafe<
128
131
  Error = ApiError,
129
132
  Route extends RoutesWithOutput<Routes> = RoutesWithOutput<Routes>,
133
+ Output = SchemaRouteOutput<Routes, Route>,
134
+ // The type actually returned as `data`. Defaults to `Output` (i.e. `select` is the identity
135
+ // function), but can be inferred as something else when a transforming `select` is passed.
136
+ Data = Output,
130
137
  >(
131
138
  route: Route,
132
139
  // If the route requires an input, then expect options that provide that input, otherwise just expect options without inputs.
133
140
  fetchOptions?: Route extends RoutesWithInput<Routes>
134
141
  ? OptionsWithInput<Routes, Route>
135
142
  : OptionsNoInputOutput,
136
- queryOptions?: Omit<
137
- UseQueryOptions<unknown, Error, SchemaRouteOutput<Routes, Route>, QueryKey>,
138
- OmitReactQueryKeys
139
- >,
140
- ): UseQueryResult<SchemaRouteOutput<Routes, Route>, Error>;
143
+ queryOptions?: Omit<UseQueryOptions<Output, Error, Data, QueryKey>, OmitReactQueryKeys>,
144
+ ): UseQueryResult<Data, Error>;
141
145
 
142
146
  // Overload that allows manual output type instead of schema (`RoutesWithoutOutput`)
143
147
  useBetterQuerySafe<
144
148
  Output,
145
149
  Error = ApiError,
146
150
  Route extends RoutesWithoutOutput<Routes> = RoutesWithoutOutput<Routes>,
151
+ Data = Output,
147
152
  >(
148
153
  route: Route,
149
154
  fetchOptions?: Route extends RoutesWithInput<Routes>
150
155
  ? OptionsWithInput<Routes, Route>
151
156
  : OptionsNoInputOutput,
152
- queryOptions?: Omit<UseQueryOptions<unknown, Error, Output, QueryKey>, OmitReactQueryKeys>,
153
- ): UseQueryResult<Output, Error>;
157
+ queryOptions?: Omit<UseQueryOptions<Output, Error, Data, QueryKey>, OmitReactQueryKeys>,
158
+ ): UseQueryResult<Data, Error>;
154
159
 
155
160
  // Suspense query (error thrown)
156
161
 
@@ -158,31 +163,96 @@ type ReactQueryBetterFetch = <
158
163
  useBetterSuspenseQuery<
159
164
  Route extends RoutesWithOutput<Routes>,
160
165
  Output = SchemaRouteOutput<Routes, Route>,
166
+ Data = Output,
161
167
  >(
162
168
  route: Route,
163
169
  fetchOptions?: Route extends RoutesWithInput<Routes>
164
170
  ? OptionsWithInput<Routes, Route>
165
171
  : OptionsNoInputOutput,
166
- queryOptions?: Omit<
167
- UseSuspenseQueryOptions<unknown, never, Output, QueryKey>,
168
- OmitReactQueryKeys
169
- >,
170
- ): UseSuspenseQueryResult<Output>;
172
+ queryOptions?: Omit<UseSuspenseQueryOptions<Output, never, Data, QueryKey>, OmitReactQueryKeys>,
173
+ ): UseSuspenseQueryResult<Data>;
171
174
 
172
175
  // Overload that allows manual output type instead of schema (`RoutesWithoutOutput`)
173
176
  useBetterSuspenseQuery<
174
177
  Output,
175
178
  Route extends RoutesWithoutOutput<Routes> = RoutesWithoutOutput<Routes>,
179
+ Data = Output,
176
180
  >(
177
181
  route: Route,
178
182
  fetchOptions?: Route extends RoutesWithInput<Routes>
179
183
  ? OptionsWithInput<Routes, Route>
180
184
  : OptionsNoInputOutput,
185
+ queryOptions?: Omit<UseSuspenseQueryOptions<Output, never, Data, QueryKey>, OmitReactQueryKeys>,
186
+ ): UseSuspenseQueryResult<Data>;
187
+
188
+ // Mutation query (error thrown)
189
+
190
+ // Overload that uses schema output (`RoutesWithOutput`)
191
+ useBetterMutation<
192
+ Error = ApiError,
193
+ Route extends RoutesWithOutput<Routes> = RoutesWithOutput<Routes>,
194
+ >(
195
+ route: Route,
196
+ // If the route requires an input, then expect options that provide that input, otherwise just expect options without inputs.
197
+ fetchOptions?: OptionsNoInputOutput,
198
+ queryOptions?: Omit<
199
+ UseMutationOptions<SchemaRouteOutput<Routes, Route>, Error, unknown, unknown>,
200
+ OmitReactQueryKeys
201
+ >,
202
+ ): UseMutationResult<
203
+ SchemaRouteOutput<Routes, Route>,
204
+ Error,
205
+ Route extends RoutesWithInput<Routes> ? OptionsWithInput<Routes, Route> : void
206
+ >;
207
+
208
+ // Overload that allows manual output type instead of schema (`RoutesWithoutOutput`)
209
+ useBetterMutation<
210
+ Output,
211
+ Error = ApiError,
212
+ Route extends RoutesWithoutOutput<Routes> = RoutesWithoutOutput<Routes>,
213
+ >(
214
+ route: Route,
215
+ fetchOptions?: OptionsNoInputOutput,
216
+ queryOptions?: Omit<UseMutationOptions<Output, Error, unknown, unknown>, OmitReactQueryKeys>,
217
+ ): UseMutationResult<
218
+ Output,
219
+ Error,
220
+ Route extends RoutesWithInput<Routes> ? OptionsWithInput<Routes, Route> : void
221
+ >;
222
+
223
+ // Safe mutation (error NOT thrown)
224
+
225
+ // Overload that uses schema output (`RoutesWithOutput`)
226
+ useBetterMutationSafe<
227
+ Error = ApiError,
228
+ Route extends RoutesWithOutput<Routes> = RoutesWithOutput<Routes>,
229
+ >(
230
+ route: Route,
231
+ fetchOptions?: OptionsNoInputOutput,
181
232
  queryOptions?: Omit<
182
- UseSuspenseQueryOptions<unknown, never, Output, QueryKey>,
233
+ UseMutationOptions<SchemaRouteOutput<Routes, Route>, Error, unknown, unknown>,
183
234
  OmitReactQueryKeys
184
235
  >,
185
- ): UseSuspenseQueryResult<Output>;
236
+ ): UseMutationResult<
237
+ SchemaRouteOutput<Routes, Route>,
238
+ Error,
239
+ Route extends RoutesWithInput<Routes> ? OptionsWithInput<Routes, Route> : void
240
+ >;
241
+
242
+ // Overload that allows manual output type instead of schema (`RoutesWithoutOutput`)
243
+ useBetterMutationSafe<
244
+ Output,
245
+ Error = ApiError,
246
+ Route extends RoutesWithoutOutput<Routes> = RoutesWithoutOutput<Routes>,
247
+ >(
248
+ route: Route,
249
+ fetchOptions?: OptionsNoInputOutput,
250
+ queryOptions?: Omit<UseMutationOptions<Output, Error, unknown, unknown>, OmitReactQueryKeys>,
251
+ ): UseMutationResult<
252
+ Output,
253
+ Error,
254
+ Route extends RoutesWithInput<Routes> ? OptionsWithInput<Routes, Route> : void
255
+ >;
186
256
  };
187
257
 
188
258
  /**
@@ -283,5 +353,50 @@ export const reactQueryBetterFetch: ReactQueryBetterFetch = <
283
353
  });
284
354
  };
285
355
 
286
- return { useBetterQuerySafe, useBetterSuspenseQuery };
356
+ const useBetterMutation = <Route extends keyof Routes>(
357
+ route: Route,
358
+ fetchOptions?: InferOptions<FetchSchema, ''>,
359
+ queryOptions?: Omit<
360
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
361
+ UseMutationOptions<any, any, any, unknown>,
362
+ OmitReactQueryKeys | 'throwOnError'
363
+ >,
364
+ ) => {
365
+ return useMutation({
366
+ ...queryOptions,
367
+ mutationKey: [
368
+ route,
369
+ fetchOptions?.body,
370
+ fetchOptions?.params,
371
+ fetchOptions?.query,
372
+ fetchOptions?.headers,
373
+ ],
374
+ mutationFn: (inputs) => anyInstance(route, { ...fetchOptions, ...inputs }),
375
+ throwOnError: true,
376
+ });
377
+ };
378
+
379
+ const useBetterMutationSafe = <Route extends keyof Routes>(
380
+ route: Route,
381
+ fetchOptions?: InferOptions<FetchSchema, ''>,
382
+ queryOptions?: Omit<
383
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
384
+ UseMutationOptions<any, any, any, unknown>,
385
+ OmitReactQueryKeys | 'throwOnError'
386
+ >,
387
+ ) => {
388
+ return useMutation({
389
+ ...queryOptions,
390
+ mutationKey: [
391
+ route,
392
+ fetchOptions?.body,
393
+ fetchOptions?.params,
394
+ fetchOptions?.query,
395
+ fetchOptions?.headers,
396
+ ],
397
+ mutationFn: (inputs) => anyInstance(route, { ...fetchOptions, ...inputs }),
398
+ });
399
+ };
400
+
401
+ return { useBetterQuerySafe, useBetterMutationSafe, useBetterMutation, useBetterSuspenseQuery };
287
402
  };
File without changes