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 +63 -0
- package/README.md +266 -0
- package/SKILL.md +240 -0
- package/dist/configure.d.ts +36 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +22 -0
- package/dist/configure.js.map +1 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +9 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +59 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/use-api.d.ts +3 -0
- package/dist/use-api.d.ts.map +1 -0
- package/dist/use-api.js +176 -0
- package/dist/use-api.js.map +1 -0
- package/package.json +62 -0
- package/src/configure.ts +57 -0
- package/src/errors.ts +10 -0
- package/src/index.ts +16 -0
- package/src/types.ts +67 -0
- package/src/use-api.ts +203 -0
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"}
|
package/dist/errors.d.ts
ADDED
|
@@ -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 @@
|
|
|
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"}
|
package/dist/index.d.ts
ADDED
|
@@ -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"}
|