inertiajs-use-api 0.0.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/AGENTS.md ADDED
@@ -0,0 +1,63 @@
1
+ # Agent guidance — inertiajs-use-api
2
+
3
+ A React hook for calling JSON API endpoints from Inertia.js apps. Pure-ESM library, no runtime deps, two peer deps (`react`, `@inertiajs/core`).
4
+
5
+ ## Project layout
6
+
7
+ ```
8
+ src/
9
+ ├── index.ts # barrel exports
10
+ ├── use-api.ts # the hook
11
+ ├── configure.ts # global config (parseErrors, toast handlers, XSRF, baseUrl)
12
+ ├── errors.ts # ApiError class
13
+ └── types.ts # public types
14
+ tests/ # vitest + jsdom + @testing-library/react
15
+ SKILL.md # Anthropic Skills file — for consumers' AI agents
16
+ ```
17
+
18
+ ## Development
19
+
20
+ ```sh
21
+ bun install
22
+ bun run typecheck # tsc --noEmit
23
+ bun run test:run # vitest run — NOT `bun test`, which uses Bun's own runner
24
+ bun run test # vitest watch
25
+ bun run build # tsc → dist/
26
+ ```
27
+
28
+ ## Conventions to follow when modifying
29
+
30
+ - **The hook stays minimal.** Native `fetch` under the hood. Inertia's `router` is touched only for `intoProp` (`router.replaceProp`) and `reloadProps` (`router.reload`).
31
+ - **No app-specific behavior in the hook.** Anything backend-specific (error envelope shape, toast system, auth-token plumbing) goes through `configureUseApi`.
32
+ - **Don't import from `@inertiajs/react`.** It is not a peer dep. The library only depends on `@inertiajs/core`.
33
+ - **Use the public `replaceProp` API.** Don't reach into Inertia internals (`setPage`, the `page` singleton, etc.). The public API is stable since Inertia v2.
34
+ - **ESM only.** `tsconfig.json` uses `moduleResolution: Bundler`. Internal imports use `.js` suffixes so the emitted `dist/` works for both bundlers and Node ESM.
35
+ - **Add a test for every behavior change.** Tests mock both `globalThis.fetch` and `@inertiajs/core`'s `router` via `vi.mock`.
36
+
37
+ ## When adding a new option
38
+
39
+ If you add a new `SubmitOptions` field or `configureUseApi` key, update:
40
+
41
+ 1. `src/types.ts` (or `src/configure.ts` for global config)
42
+ 2. `src/use-api.ts` (or the consumer)
43
+ 3. `tests/use-api.test.tsx` — add a test that exercises it
44
+ 4. `README.md` — both the prose example and the reference table
45
+ 5. `SKILL.md` — only if the change is decision-shaping for an agent (new mode, gotcha, common mistake)
46
+
47
+ ## Release flow
48
+
49
+ `.github/workflows/release.yml` runs on merged PRs:
50
+
51
+ 1. `test` job — `bun install --frozen-lockfile`, `bun run typecheck`, `bun run test:run`
52
+ 2. `release` job — `needs: test`. Uses `offload-project/release-champion` to publish to GitHub Packages.
53
+
54
+ `.github/workflows/test.yml` runs the same checks plus `bun run build` on every push and PR.
55
+
56
+ A failed `test` job blocks the publish.
57
+
58
+ ## Things to avoid
59
+
60
+ - Adding runtime dependencies. The hook should stay zero-dep.
61
+ - Re-introducing toast/error logic that lived in the original `use-api.ts` (it was app-specific). Pluggable handlers only.
62
+ - Calling `setState` after the component unmounts. The existing `processing` reset already guards via in-flight refcounting; don't add unrelated state writes outside that pattern.
63
+ - Making the hook async-iterable or returning observables. Out of scope.
package/README.md ADDED
@@ -0,0 +1,266 @@
1
+ <p align="center">
2
+ <a href="https://packagist.org/packages/offload-project/inertiajs-use-api"><img src="https://img.shields.io/packagist/v/offload-project/inertiajs-use-api.svg?style=flat-square" alt="Latest Version on Packagist"></a>
3
+ <a href="https://github.com/offload-project/inertiajs-use-api/actions"><img src="https://img.shields.io/github/actions/workflow/status/offload-project/inertiajs-use-api/tests.yml?branch=main&style=flat-square" alt="GitHub Tests Action Status"></a>
4
+ <a href="https://packagist.org/packages/offload-project/inertiajs-use-api"><img src="https://img.shields.io/packagist/dt/offload-project/inertiajs-use-api.svg?style=flat-square" alt="Total Downloads"></a>
5
+ </p>
6
+
7
+ # inertiajs-use-api
8
+
9
+ A React hook for calling JSON API endpoints from [Inertia.js](https://inertiajs.com) apps. Like Inertia's `useForm`, but for plain JSON routes — with optional piping of responses back into Inertia page props.
10
+
11
+ ```ts
12
+ const api = useApi<{ name: string }, User[]>({ name: "" });
13
+
14
+ // Pipe the response straight into page.props.users (client-side, no roundtrip):
15
+ await api.get("/api/users", { intoProp: "users" });
16
+
17
+ // Or trigger a partial reload from the server:
18
+ await api.post("/users", { reloadProps: ["users", "stats"] });
19
+ ```
20
+
21
+ ## Why
22
+
23
+ Inertia's built-in `router` and `useForm` are great for navigating between Inertia pages, but they expect Inertia responses. For plain JSON API routes (`/api/*`), you typically drop down to `fetch` and lose the ergonomics — processing flag, field errors, abort, lifecycle callbacks, etc.
24
+
25
+ `useApi` gives you that ergonomics back, and lets you pipe responses into Inertia page props when you want them to live in `usePage()` alongside server-rendered data.
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ npm install inertiajs-use-api
31
+ ```
32
+
33
+ Peer dependencies:
34
+
35
+ - `@inertiajs/core@^2 || ^3`
36
+ - `react@^18 || ^19`
37
+
38
+ ## Quick start
39
+
40
+ ```tsx
41
+ import { useApi } from "inertiajs-use-api";
42
+
43
+ type Form = { name: string; email: string };
44
+ type User = { id: number; name: string; email: string };
45
+
46
+ function CreateUserForm() {
47
+ const api = useApi<Form, User>({ name: "", email: "" });
48
+
49
+ const submit = async (e: React.FormEvent) => {
50
+ e.preventDefault();
51
+ try {
52
+ const user = await api.post("/api/users", {
53
+ successToast: "User created",
54
+ reloadProps: ["users"],
55
+ });
56
+ // user is the typed response body
57
+ } catch {
58
+ // ApiError thrown — api.errors is populated
59
+ }
60
+ };
61
+
62
+ return (
63
+ <form onSubmit={submit}>
64
+ <input
65
+ value={api.data.name}
66
+ onChange={(e) => api.setData("name", e.target.value)}
67
+ />
68
+ {api.errors.name && <span>{api.errors.name}</span>}
69
+ <button disabled={api.processing}>Create</button>
70
+ </form>
71
+ );
72
+ }
73
+ ```
74
+
75
+ ## Inertia prop integration
76
+
77
+ ### `intoProp` — write the response into `page.props` client-side
78
+
79
+ Uses `router.replaceProp` under the hood (no server roundtrip). The data flows straight into `usePage().props`.
80
+
81
+ ```tsx
82
+ import { useApi } from "inertiajs-use-api";
83
+ import { usePage } from "@inertiajs/react";
84
+
85
+ function UserList() {
86
+ const api = useApi<{}, User[]>({});
87
+
88
+ useEffect(() => {
89
+ api.get("/api/users", { intoProp: "users" });
90
+ }, []);
91
+
92
+ const { users } = usePage<{ users: User[] }>().props;
93
+ return <ul>{users?.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
94
+ }
95
+ ```
96
+
97
+ You can also map a single response into multiple props:
98
+
99
+ ```tsx
100
+ await api.get("/api/dashboard", {
101
+ intoProp: (res) => ({ users: res.users, stats: res.stats }),
102
+ });
103
+ ```
104
+
105
+ ### `reloadProps` — refresh props from the server
106
+
107
+ Uses `router.reload({ only })`. The server is the source of truth.
108
+
109
+ ```tsx
110
+ await api.post("/api/users", { reloadProps: ["users", "stats"] });
111
+ ```
112
+
113
+ Pass extra options via `reloadOptions` (anything Inertia's `ReloadOptions` accepts, except `only`):
114
+
115
+ ```tsx
116
+ await api.post("/api/users", {
117
+ reloadProps: "users",
118
+ reloadOptions: { preserveScroll: true },
119
+ });
120
+ ```
121
+
122
+ ### Choosing between them
123
+
124
+ | Use `intoProp` when… | Use `reloadProps` when… |
125
+ |--------------------------------------------------------------|---------------------------------------------------------|
126
+ | The API endpoint already returns the data you want in props. | The server computes the props (auth filters, scoping…). |
127
+ | You want to avoid an extra HTTP request. | You want server-side validation of what the user sees. |
128
+ | You're piping into client-only state. | You want Inertia's normal partial-reload semantics. |
129
+
130
+ ## Configuration
131
+
132
+ Configure the library once at app boot — the hook reads from a global config so calls stay terse.
133
+
134
+ ```ts
135
+ // app.tsx
136
+ import { configureUseApi } from "inertiajs-use-api";
137
+
138
+ configureUseApi({
139
+ // Optional base URL for relative paths
140
+ baseUrl: "/",
141
+
142
+ // Parse your backend's error envelope into a flat { field: message } map
143
+ parseErrors: (body) => {
144
+ const errs = (body as { errors?: Record<string, string[] | string> }).errors ?? {};
145
+ const flat: Record<string, string> = {};
146
+ for (const [k, v] of Object.entries(errs)) {
147
+ flat[k] = Array.isArray(v) ? v[0]! : String(v);
148
+ }
149
+ return flat;
150
+ },
151
+
152
+ // Extract a human-readable message (used as the default error toast text)
153
+ parseMessage: (body) => (body as { message?: string }).message ?? null,
154
+
155
+ // Wire your toast/notification system
156
+ onSuccessToast: (toast) => fireToast({ type: "success", message: toast as string }),
157
+ onErrorToast: (toast) => fireToast({ type: "error", message: toast as string }),
158
+
159
+ // Optional: inspect every response body (e.g. to extract a server-side toast envelope)
160
+ onResponse: (body, status, ok) => {
161
+ const alerts = (body as { toastAlerts?: ToastMessage[] })?.toastAlerts;
162
+ if (alerts?.length) fireToasts(alerts);
163
+ },
164
+ });
165
+ ```
166
+
167
+ ### Config reference
168
+
169
+ | Option | Type | Default |
170
+ |------------------|--------------------------------------------|---------------------------|
171
+ | `baseUrl` | `string` | `undefined` |
172
+ | `defaultHeaders` | `Record<string, string>` | `undefined` |
173
+ | `getXsrfToken` | `() => string \| null` | reads `XSRF-TOKEN` cookie |
174
+ | `xsrfHeaderName` | `string` | `"X-XSRF-TOKEN"` |
175
+ | `parseErrors` | `(body, status) => Record<string, string>` | returns `{}` |
176
+ | `parseMessage` | `(body, status) => string \| null` | returns `null` |
177
+ | `onSuccessToast` | `(toast: unknown) => void` | no-op |
178
+ | `onErrorToast` | `(toast: unknown) => void` | no-op |
179
+ | `onResponse` | `(body, status, ok) => void` | no-op |
180
+
181
+ ## API
182
+
183
+ ### `useApi<TForm, TResponse>(initialData?)`
184
+
185
+ Returns an object with:
186
+
187
+ | Field | Type |
188
+ |-----------------------------|------------------------------------------------------------|
189
+ | `data` | `TForm` |
190
+ | `setData` | `(field \| partial, value?) => void` |
191
+ | `errors` | `Partial<Record<keyof TForm \| string, string>>` |
192
+ | `hasErrors` | `boolean` |
193
+ | `processing` | `boolean` |
194
+ | `response` | `TResponse \| null` |
195
+ | `wasSuccessful` | `boolean` |
196
+ | `status` | `number \| null` |
197
+ | `reset` | `() => void` |
198
+ | `clearErrors` | `() => void` |
199
+ | `cancel` | `() => void` — aborts all in-flight requests for this hook |
200
+ | `submit` | `(method, url, options?) => Promise<TResponse>` |
201
+ | `get/post/put/patch/delete` | `(url, options?) => Promise<TResponse>` |
202
+
203
+ ### `SubmitOptions`
204
+
205
+ | Option | Type | Notes |
206
+ |-----------------|--------------------------------------------------------------------|-------------------------------------------------------------------------|
207
+ | `data` | `Partial<TForm>` | Overrides the hook's `data` body |
208
+ | `params` | `Record<string, string \| number \| boolean \| null \| undefined>` | Query string. `null`/`undefined` values are skipped |
209
+ | `headers` | `Record<string, string>` | Merged on top of defaults |
210
+ | `signal` | `AbortSignal` | Aborting it (or calling `cancel()`) aborts the request |
211
+ | `intoProp` | `string \| (response) => Record<string, unknown>` | Writes into `page.props` via `router.replaceProp` |
212
+ | `reloadProps` | `string \| string[]` | After success, triggers `router.reload({ only })` |
213
+ | `reloadOptions` | `Omit<ReloadOptions, "only">` | Extra options forwarded to `router.reload` |
214
+ | `successToast` | `unknown` | Forwarded to `onSuccessToast` |
215
+ | `errorToast` | `unknown \| false` | Forwarded to `onErrorToast`. `false` suppresses the toast for this call |
216
+ | `onBefore` | `() => void` | |
217
+ | `onSuccess` | `(response: TResponse) => void` | |
218
+ | `onError` | `(errors, raw, status) => void` | |
219
+ | `onFinish` | `() => void` | Always called |
220
+
221
+ ### `ApiError`
222
+
223
+ Thrown on non-2xx responses.
224
+
225
+ ```ts
226
+ import { ApiError } from "inertiajs-use-api";
227
+
228
+ try {
229
+ await api.post("/api/users");
230
+ } catch (e) {
231
+ if (e instanceof ApiError) {
232
+ console.log(e.status, e.message, e.body);
233
+ }
234
+ }
235
+ ```
236
+
237
+ ## Notes
238
+
239
+ - The hook uses native `fetch` and reads/writes Inertia state only when you ask it to (`intoProp`, `reloadProps`).
240
+ - `@inertiajs/react` is **not** a peer dep — only `@inertiajs/core` is. Use whichever Inertia adapter your app uses to read updated props.
241
+ - All in-flight requests for a given hook share `processing` and can be cancelled together via `cancel()`.
242
+
243
+ ## For AI agents
244
+
245
+ This package ships an [Anthropic Skill](https://docs.claude.com/en/docs/agents-and-tools/skills) at the package root so coding agents (Claude Code, Claude.ai) can use the library correctly without needing to grep through source.
246
+
247
+ The skill covers when to reach for `useApi` vs `useForm`, the `intoProp` vs `reloadProps` decision, the one-time `configureUseApi` wiring an app needs, and common Laravel/XSRF gotchas.
248
+
249
+ **To install it for Claude Code:**
250
+
251
+ ```sh
252
+ mkdir -p ~/.claude/skills/inertiajs-use-api
253
+ cp node_modules/inertiajs-use-api/SKILL.md ~/.claude/skills/inertiajs-use-api/SKILL.md
254
+ ```
255
+
256
+ Or symlink it so it stays in sync with package upgrades:
257
+
258
+ ```sh
259
+ ln -s "$PWD/node_modules/inertiajs-use-api/SKILL.md" ~/.claude/skills/inertiajs-use-api/SKILL.md
260
+ ```
261
+
262
+ Contributors hacking on the library itself should read [`AGENTS.md`](./AGENTS.md) — picked up automatically by Cursor, Windsurf, Cline, and similar in-repo agents.
263
+
264
+ ## License
265
+
266
+ MIT
package/SKILL.md ADDED
@@ -0,0 +1,240 @@
1
+ ---
2
+ name: inertiajs-use-api
3
+ description: Use when writing or reviewing code that calls JSON API routes (typically /api/*) from an Inertia.js + React app. Triggers on the `useApi` hook from the `inertiajs-use-api` package, or when the user wants form-like state (data/errors/processing/cancel) for a non-Inertia endpoint and optionally wants to pipe the response into Inertia page props readable via `usePage()`. Covers the one-time `configureUseApi` setup, the `intoProp` vs `reloadProps` decision, and common Laravel/XSRF gotchas.
4
+ ---
5
+
6
+ # inertiajs-use-api
7
+
8
+ A React hook for calling JSON API endpoints from Inertia.js apps. Like Inertia's `useForm`, but for plain JSON routes.
9
+
10
+ ## When to reach for this
11
+
12
+ - The user is in an Inertia + React project.
13
+ - The endpoint returns JSON (not a full Inertia page) — typically `/api/*`.
14
+ - They want `useForm`-style ergonomics: `data`, `errors`, `processing`, `cancel`, lifecycle callbacks.
15
+ - Optionally: they want the response to land in `usePage().props` so the rest of the page reads it like any other Inertia prop.
16
+
17
+ ## When NOT to use it
18
+
19
+ - **Endpoint returns an Inertia page** (`Inertia::render(...)` on the server): use `useForm` or `router.post/visit` from `@inertiajs/react`.
20
+ - **Non-React adapter** (Vue, Svelte): this library is React-only.
21
+ - **You need caching, refetching, dedup, query keys**: TanStack Query / SWR fit better. `useApi` is one-shot per call.
22
+
23
+ ## Quick start
24
+
25
+ Three steps to a working call:
26
+
27
+ **1. Install**
28
+
29
+ ```sh
30
+ npm install inertiajs-use-api
31
+ # peer deps you should already have: @inertiajs/core@^2||^3, react@^18||^19
32
+ ```
33
+
34
+ **2. Wire `configureUseApi` once at app boot** (e.g. `resources/js/app.tsx`)
35
+
36
+ Without this, `api.errors` will stay empty on validation failures.
37
+
38
+ ```ts
39
+ import { configureUseApi } from "inertiajs-use-api";
40
+
41
+ configureUseApi({
42
+ parseErrors: (body) => {
43
+ const errs = (body as { errors?: Record<string, string[] | string> }).errors ?? {};
44
+ const flat: Record<string, string> = {};
45
+ for (const [k, v] of Object.entries(errs)) {
46
+ flat[k] = Array.isArray(v) ? v[0]! : String(v);
47
+ }
48
+ return flat;
49
+ },
50
+ parseMessage: (body) => (body as { message?: string }).message ?? null,
51
+ });
52
+ ```
53
+
54
+ **3. Use the hook in a component**
55
+
56
+ ```tsx
57
+ import { useApi } from "inertiajs-use-api";
58
+
59
+ type Form = { name: string };
60
+ type User = { id: number; name: string };
61
+
62
+ function CreateUser() {
63
+ const api = useApi<Form, User>({ name: "" });
64
+
65
+ return (
66
+ <form
67
+ onSubmit={async (e) => {
68
+ e.preventDefault();
69
+ try {
70
+ const user = await api.post("/api/users");
71
+ // `user` is typed as User; api.response is also set
72
+ } catch {
73
+ // ApiError thrown; api.errors is populated for UI
74
+ }
75
+ }}
76
+ >
77
+ <input
78
+ value={api.data.name}
79
+ onChange={(e) => api.setData("name", e.target.value)}
80
+ />
81
+ {api.errors.name && <span>{api.errors.name}</span>}
82
+ <button disabled={api.processing}>Create</button>
83
+ </form>
84
+ );
85
+ }
86
+ ```
87
+
88
+ That's it — everything below is options on top of this base.
89
+
90
+ ## The two prop-integration modes
91
+
92
+ `useApi` exposes two ways to update Inertia page props after a successful response. Pick based on where the source of truth lives:
93
+
94
+ | Use `intoProp` (client-side write) when… | Use `reloadProps` (server roundtrip) when… |
95
+ | ----------------------------------------------------- | ------------------------------------------------------- |
96
+ | The API response already contains exactly the value | The server computes the prop (auth scoping, derivation) |
97
+ | You want zero extra HTTP | You want server-side authorization on what's visible |
98
+ | Throwaway / UI-only data | Persistent page state |
99
+
100
+ Under the hood:
101
+ - `intoProp` → `router.replaceProp(name, () => response)` — public Inertia v2+ API, no roundtrip.
102
+ - `reloadProps` → `router.reload({ only: [...] })` — partial reload, normal Inertia semantics.
103
+
104
+ ## One-time setup the user must do
105
+
106
+ `useApi` reads from a global config. **By default it does not parse errors.** If field errors don't show up in `api.errors`, this is almost always why.
107
+
108
+ Wire this once at app boot (usually `resources/js/app.tsx`):
109
+
110
+ ```ts
111
+ import { configureUseApi } from "inertiajs-use-api";
112
+
113
+ configureUseApi({
114
+ // Laravel-style 422 envelope; adjust for your backend
115
+ parseErrors: (body) => {
116
+ const errs = (body as { errors?: Record<string, string[] | string> }).errors ?? {};
117
+ const flat: Record<string, string> = {};
118
+ for (const [k, v] of Object.entries(errs)) {
119
+ flat[k] = Array.isArray(v) ? v[0]! : String(v);
120
+ }
121
+ return flat;
122
+ },
123
+ parseMessage: (body) => (body as { message?: string }).message ?? null,
124
+
125
+ // Wire to whatever toast library the app uses
126
+ onSuccessToast: (toast) => /* fireToast({ type: "success", message: toast as string }) */,
127
+ onErrorToast: (toast) => /* fireToast({ type: "error", message: toast as string }) */,
128
+ });
129
+ ```
130
+
131
+ Other useful config keys: `baseUrl`, `defaultHeaders`, `getXsrfToken`, `xsrfHeaderName`, `onResponse`.
132
+
133
+ ## The hook shape
134
+
135
+ ```ts
136
+ const api = useApi<TForm, TResponse>(initialData);
137
+
138
+ // State
139
+ api.data // TForm
140
+ api.errors // Partial<Record<keyof TForm | string, string>>
141
+ api.hasErrors // boolean
142
+ api.processing // boolean — true while ANY request is in flight
143
+ api.response // TResponse | null — last successful response body
144
+ api.wasSuccessful // boolean
145
+ api.status // number | null — HTTP status of last response
146
+
147
+ // Mutations
148
+ api.setData(field, value); // or setData(partial)
149
+ api.reset(); // back to initialData, clears state
150
+ api.clearErrors();
151
+ api.cancel(); // aborts all in-flight requests
152
+
153
+ // Requests — return Promise<TResponse>, throw ApiError on non-2xx
154
+ await api.get (url, options?);
155
+ await api.post (url, options?);
156
+ await api.put (url, options?);
157
+ await api.patch (url, options?);
158
+ await api.delete(url, options?);
159
+ await api.submit("post", url, options?);
160
+ ```
161
+
162
+ ## SubmitOptions
163
+
164
+ ```ts
165
+ {
166
+ data?: Partial<TForm>; // overrides the hook's data for this call
167
+ params?: Record<string, string | number | boolean | null | undefined>;
168
+ headers?: Record<string, string>;
169
+ signal?: AbortSignal;
170
+
171
+ intoProp?: string | ((response) => Record<string, unknown>);
172
+ reloadProps?: string | string[];
173
+ reloadOptions?: Omit<ReloadOptions, "only">;
174
+
175
+ successToast?: unknown; // forwarded to configured onSuccessToast
176
+ errorToast?: unknown | false; // false suppresses error toast for this call
177
+
178
+ onBefore?: () => void;
179
+ onSuccess?: (response: TResponse) => void;
180
+ onError?: (errors, raw, status) => void;
181
+ onFinish?: () => void; // always runs
182
+ }
183
+ ```
184
+
185
+ ## Typical component example
186
+
187
+ ```tsx
188
+ import { useApi } from "inertiajs-use-api";
189
+
190
+ type Form = { name: string; email: string };
191
+ type User = { id: number; name: string; email: string };
192
+
193
+ function CreateUserForm() {
194
+ const api = useApi<Form, User>({ name: "", email: "" });
195
+
196
+ return (
197
+ <form
198
+ onSubmit={async (e) => {
199
+ e.preventDefault();
200
+ try {
201
+ await api.post("/api/users", {
202
+ successToast: "User created",
203
+ reloadProps: ["users"],
204
+ });
205
+ } catch {
206
+ // ApiError thrown — api.errors is populated; UI re-renders
207
+ }
208
+ }}
209
+ >
210
+ <input
211
+ value={api.data.name}
212
+ onChange={(e) => api.setData("name", e.target.value)}
213
+ />
214
+ {api.errors.name && <span>{api.errors.name}</span>}
215
+ <button disabled={api.processing}>Create</button>
216
+ </form>
217
+ );
218
+ }
219
+ ```
220
+
221
+ ## Common gotchas
222
+
223
+ - **Peer-dep is `@inertiajs/core@^2 || ^3`.** `router.replaceProp` was added in v2. Inertia v1 apps can't use `intoProp` (the rest of the hook still works).
224
+ - **`@inertiajs/react` is NOT a peer dep.** The library only depends on `@inertiajs/core`. Read updated props via `usePage()` from whatever adapter the app uses.
225
+ - **XSRF token defaults to the `XSRF-TOKEN` cookie** (Laravel convention), sent as `X-XSRF-TOKEN`. Override via `getXsrfToken` / `xsrfHeaderName` in `configureUseApi`.
226
+ - **All in-flight requests share `processing`.** `cancel()` aborts them all.
227
+ - **`router` is invoked only on success.** A failing request never calls `replaceProp` or `reload`.
228
+ - **`bun test` won't run the suite** — it uses Bun's runner, not vitest. Use `bun run test` (or `npm test`).
229
+ - **`ApiError` is thrown on non-2xx.** Wrap in try/catch only when you need to act beyond the populated `errors` (e.g. specific status handling).
230
+
231
+ ## Quick decision tree
232
+
233
+ ```
234
+ Need to call a JSON API route from an Inertia+React component?
235
+ ├─ Just need the response in local state? → await api.get(url) → use api.response
236
+ ├─ Need it visible to other components via usePage()?
237
+ │ ├─ Server should compute the value (auth, scoping)? → reloadProps
238
+ │ └─ API response IS the value, want no extra roundtrip? → intoProp
239
+ └─ Need both refresh + local response? → use both options together
240
+ ```
@@ -0,0 +1,36 @@
1
+ export interface UseApiConfig {
2
+ /** Prepended to relative URLs (e.g. "/api"). Absolute URLs pass through. */
3
+ baseUrl?: string;
4
+ /** Merged onto every request before per-call headers. */
5
+ defaultHeaders?: Record<string, string>;
6
+ /**
7
+ * Reads the CSRF token. Defaults to decoding the `XSRF-TOKEN` cookie.
8
+ * Return `null` for no token.
9
+ */
10
+ getXsrfToken?: () => string | null;
11
+ /** Header name for the CSRF token. Defaults to `X-XSRF-TOKEN`. */
12
+ xsrfHeaderName?: string;
13
+ /**
14
+ * Parses the response body into a flat `{ field: message }` map on a non-2xx response.
15
+ * If unset, no field errors are populated.
16
+ */
17
+ parseErrors?: (body: unknown, status: number) => Record<string, string>;
18
+ /**
19
+ * Extracts a human-readable error message from the body (used as the default error toast).
20
+ */
21
+ parseMessage?: (body: unknown, status: number) => string | null;
22
+ /** Invoked with the `successToast` value the caller passed. */
23
+ onSuccessToast?: (toast: unknown) => void;
24
+ /** Invoked with the resolved error toast value (caller's or the fallback message). */
25
+ onErrorToast?: (toast: unknown) => void;
26
+ /**
27
+ * Inspect every response body (after JSON parse) regardless of status.
28
+ * Useful for pulling out server-side toast envelopes.
29
+ */
30
+ onResponse?: (body: unknown, status: number, ok: boolean) => void;
31
+ }
32
+ export declare function configureUseApi(next: UseApiConfig): void;
33
+ export declare function getUseApiConfig(): UseApiConfig;
34
+ export declare function resetUseApiConfig(): void;
35
+ export declare function readXsrfToken(): string | null;
36
+ //# sourceMappingURL=configure.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"configure.d.ts","sourceRoot":"","sources":["../src/configure.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,YAAY;IAC5B,4EAA4E;IAC5E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,yDAAyD;IACzD,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxC;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IACnC,kEAAkE;IAClE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxE;;OAEG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;IAChE,+DAA+D;IAC/D,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IAC1C,sFAAsF;IACtF,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACxC;;;OAGG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,OAAO,KAAK,IAAI,CAAC;CAClE;AAID,wBAAgB,eAAe,CAAC,IAAI,EAAE,YAAY,GAAG,IAAI,CAExD;AAED,wBAAgB,eAAe,IAAI,YAAY,CAE9C;AAED,wBAAgB,iBAAiB,IAAI,IAAI,CAIxC;AAQD,wBAAgB,aAAa,IAAI,MAAM,GAAG,IAAI,CAE7C"}
@@ -0,0 +1,22 @@
1
+ const config = {};
2
+ export function configureUseApi(next) {
3
+ Object.assign(config, next);
4
+ }
5
+ export function getUseApiConfig() {
6
+ return config;
7
+ }
8
+ export function resetUseApiConfig() {
9
+ for (const key of Object.keys(config)) {
10
+ delete config[key];
11
+ }
12
+ }
13
+ function defaultXsrfReader() {
14
+ if (typeof document === "undefined")
15
+ return null;
16
+ const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]*)/);
17
+ return match && match[1] !== undefined ? decodeURIComponent(match[1]) : null;
18
+ }
19
+ export function readXsrfToken() {
20
+ return (config.getXsrfToken ?? defaultXsrfReader)();
21
+ }
22
+ //# sourceMappingURL=configure.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"configure.js","sourceRoot":"","sources":["../src/configure.ts"],"names":[],"mappings":"AAgCA,MAAM,MAAM,GAAiB,EAAE,CAAC;AAEhC,MAAM,UAAU,eAAe,CAAC,IAAkB;IACjD,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;AAC7B,CAAC;AAED,MAAM,UAAU,eAAe;IAC9B,OAAO,MAAM,CAAC;AACf,CAAC;AAED,MAAM,UAAU,iBAAiB;IAChC,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,MAAM,CAA2B,EAAE,CAAC;QACjE,OAAO,MAAM,CAAC,GAAG,CAAC,CAAC;IACpB,CAAC;AACF,CAAC;AAED,SAAS,iBAAiB;IACzB,IAAI,OAAO,QAAQ,KAAK,WAAW;QAAE,OAAO,IAAI,CAAC;IACjD,MAAM,KAAK,GAAG,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,4BAA4B,CAAC,CAAC;IAClE,OAAO,KAAK,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC,CAAC,kBAAkB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AAC9E,CAAC;AAED,MAAM,UAAU,aAAa;IAC5B,OAAO,CAAC,MAAM,CAAC,YAAY,IAAI,iBAAiB,CAAC,EAAE,CAAC;AACrD,CAAC"}
@@ -0,0 +1,6 @@
1
+ export declare class ApiError extends Error {
2
+ readonly status: number;
3
+ readonly body: unknown;
4
+ constructor(status: number, message: string, body: unknown);
5
+ }
6
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,qBAAa,QAAS,SAAQ,KAAK;aAEjB,MAAM,EAAE,MAAM;aAEd,IAAI,EAAE,OAAO;gBAFb,MAAM,EAAE,MAAM,EAC9B,OAAO,EAAE,MAAM,EACC,IAAI,EAAE,OAAO;CAK9B"}
package/dist/errors.js ADDED
@@ -0,0 +1,9 @@
1
+ export class ApiError extends Error {
2
+ constructor(status, message, body) {
3
+ super(message);
4
+ this.status = status;
5
+ this.body = body;
6
+ this.name = "ApiError";
7
+ }
8
+ }
9
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,MAAM,OAAO,QAAS,SAAQ,KAAK;IAClC,YACiB,MAAc,EAC9B,OAAe,EACC,IAAa;QAE7B,KAAK,CAAC,OAAO,CAAC,CAAC;QAJC,WAAM,GAAN,MAAM,CAAQ;QAEd,SAAI,GAAJ,IAAI,CAAS;QAG7B,IAAI,CAAC,IAAI,GAAG,UAAU,CAAC;IACxB,CAAC;CACD"}
@@ -0,0 +1,6 @@
1
+ export type { UseApiConfig } from "./configure.js";
2
+ export { configureUseApi, getUseApiConfig, resetUseApiConfig, } from "./configure.js";
3
+ export { ApiError } from "./errors.js";
4
+ export type { FieldErrors, IntoProp, Method, QueryParams, SubmitOptions, UseApi, } from "./types.js";
5
+ export { useApi } from "./use-api.js";
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACnD,OAAO,EACN,eAAe,EACf,eAAe,EACf,iBAAiB,GACjB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACvC,YAAY,EACX,WAAW,EACX,QAAQ,EACR,MAAM,EACN,WAAW,EACX,aAAa,EACb,MAAM,GACN,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC"}