Skip to content

Commit bca0187

Browse files
phryneasghengeveld
authored andcommitted
allow passing a callback to useFetch's run to modify init (#76)
* allow passing a callback to useFetch's `run` to modify init * useFetch: allow passing an object to `run` to be spread over `init` * better typings for `run`, add example * Upgrade storybook. * run() now returns void. Fixed code style. * Improve readability of parseResponse function. * Improve readability of isDefer. * Avoid nested ternaries. * Code style. * Extend docs on 'run'.
1 parent dda874a commit bca0187

File tree

8 files changed

+184
-81
lines changed

8 files changed

+184
-81
lines changed

Diff for: README.md

+27
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,25 @@ const MyComponent = () => {
261261
const headers = { Accept: "application/json" }
262262
const { data, error, isLoading, run } = useFetch("/api/example", { headers }, options)
263263
// This will setup a promiseFn with a fetch request and JSON deserialization.
264+
265+
// you can later call `run` with an optional callback argument to
266+
// last-minute modify the `init` parameter that is passed to `fetch`
267+
function clickHandler() {
268+
run(init => ({
269+
...init,
270+
headers: {
271+
...init.headers,
272+
authentication: "...",
273+
},
274+
}))
275+
}
276+
277+
// alternatively, you can also just use an object that will be spread over `init`.
278+
// please note that this is not deep-merged, so you might override properties present in the
279+
// original `init` parameter
280+
function clickHandler2() {
281+
run({ body: JSON.stringify(formValues) })
282+
}
264283
}
265284
```
266285

@@ -655,6 +674,14 @@ chainable alternative to the `onResolve` / `onReject` callbacks.
655674
656675
Runs the `deferFn`, passing any arguments provided as an array.
657676

677+
When used with `useFetch`, `run` has a different signature:
678+
679+
> `function(init: Object | (init: Object) => Object): void`
680+
681+
This runs the `fetch` request using the provided `init`. If it's an object it will be spread over the default `init`
682+
(`useFetch`'s 2nd argument). If it's a function it will be invoked with the default `init` and should return a new
683+
`init` object. This way you can either extend or override the value of `init`, for example to set request headers.
684+
658685
#### `reload`
659686

660687
> `function(): void`

Diff for: examples/with-typescript/src/App.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import Async, {
99
} from "react-async"
1010
import DevTools from "react-async-devtools"
1111
import "./App.css"
12+
import { FetchHookExample } from "./FetchHookExample"
1213

1314
const loadFirstName: PromiseFn<string> = ({ userId }) =>
1415
fetch(`https://reqres.in/api/users/${userId}`)
@@ -50,6 +51,7 @@ class App extends Component {
5051
<CustomAsync.Resolved>{data => <>{data}</>}</CustomAsync.Resolved>
5152
</CustomAsync>
5253
<UseAsync />
54+
<FetchHookExample />
5355
</header>
5456
</div>
5557
)

Diff for: examples/with-typescript/src/FetchHookExample.tsx

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import * as React from "react"
2+
import { useFetch } from "react-async"
3+
4+
export function FetchHookExample() {
5+
const result = useFetch<{ token: string }>("https://reqres.in/api/login", {
6+
method: "POST",
7+
headers: {
8+
"Content-Type": "application/json",
9+
Accept: "application/json",
10+
},
11+
})
12+
const { run } = result
13+
14+
return (
15+
<>
16+
<h2>with fetch hook:</h2>
17+
<button onClick={run}>just run it without login data</button>
18+
<button
19+
onClick={() =>
20+
run(init => ({
21+
...init,
22+
body: JSON.stringify({
23+
24+
password: "cityslicka",
25+
}),
26+
}))
27+
}
28+
>
29+
run it with valid login data (init callback)
30+
</button>
31+
<button
32+
onClick={() =>
33+
run({
34+
body: JSON.stringify({
35+
36+
password: "cityslicka",
37+
}),
38+
})
39+
}
40+
>
41+
run it with valid login data (init object)
42+
</button>
43+
<br />
44+
Status:
45+
<br />
46+
{result.isInitial && "initial"}
47+
{result.isLoading && "loading"}
48+
{result.isRejected && "rejected"}
49+
{result.isResolved && `token: ${result.data.token}`}
50+
</>
51+
)
52+
}

Diff for: examples/with-typescript/src/index.css

+3-5
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
body {
22
margin: 0;
33
padding: 0;
4-
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
5-
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
6-
sans-serif;
4+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
5+
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
76
-webkit-font-smoothing: antialiased;
87
-moz-osx-font-smoothing: grayscale;
98
}
109

1110
code {
12-
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
13-
monospace;
11+
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
1412
}

Diff for: packages/react-async/src/index.d.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,21 @@ export function useFetch<T>(
226226
input: RequestInfo,
227227
init?: RequestInit,
228228
options?: FetchOptions<T>
229-
): AsyncState<T>
229+
): AsyncInitialWithout<"run", T> & FetchRun<T>
230+
231+
// unfortunately, we cannot just omit K from AsyncInitial as that would unbox the Discriminated Union
232+
type AsyncInitialWithout<K extends keyof AsyncInitial<T>, T> =
233+
| Omit<AsyncInitial<T>, K>
234+
| Omit<AsyncPending<T>, K>
235+
| Omit<AsyncFulfilled<T>, K>
236+
| Omit<AsyncRejected<T>, K>
237+
238+
type FetchRun<T> = {
239+
run(overrideInit: (init: RequestInit) => RequestInit): void
240+
run(overrideInit: Partial<RequestInit>): void
241+
run(ignoredEvent: React.SyntheticEvent): void
242+
run(ignoredEvent: Event): void
243+
run(): void
244+
}
230245

231246
export default Async

Diff for: packages/react-async/src/useAsync.js

+16-7
Original file line numberDiff line numberDiff line change
@@ -156,23 +156,32 @@ const useAsync = (arg1, arg2) => {
156156

157157
const parseResponse = (accept, json) => res => {
158158
if (!res.ok) return Promise.reject(res)
159-
if (json === false) return res
160-
if (json === true || accept === "application/json") return res.json()
161-
return res
159+
if (typeof json === "boolean") return json ? res.json() : res
160+
return accept === "application/json" ? res.json() : res
162161
}
163162

164163
const useAsyncFetch = (input, init, { defer, json, ...options } = {}) => {
165164
const method = input.method || (init && init.method)
166165
const headers = input.headers || (init && init.headers) || {}
167166
const accept = headers["Accept"] || headers["accept"] || (headers.get && headers.get("accept"))
168167
const doFetch = (input, init) => globalScope.fetch(input, init).then(parseResponse(accept, json))
169-
const isDefer = defer === true || ~["POST", "PUT", "PATCH", "DELETE"].indexOf(method)
170-
const fn = defer === false || !isDefer ? "promiseFn" : "deferFn"
171-
const identity = JSON.stringify({ input, init })
168+
const isDefer =
169+
typeof defer === "boolean" ? defer : ["POST", "PUT", "PATCH", "DELETE"].indexOf(method) !== -1
170+
const fn = isDefer ? "deferFn" : "promiseFn"
171+
const identity = JSON.stringify({ input, init, isDefer })
172172
const state = useAsync({
173173
...options,
174174
[fn]: useCallback(
175-
(_, props, ctrl) => doFetch(input, { signal: ctrl ? ctrl.signal : props.signal, ...init }),
175+
(arg1, arg2, arg3) => {
176+
const [override, signal] = arg3 ? [arg1[0], arg3.signal] : [undefined, arg2.signal]
177+
if (typeof override === "object" && "preventDefault" in override) {
178+
// Don't spread Events or SyntheticEvents
179+
return doFetch(input, { signal, ...init })
180+
}
181+
return typeof override === "function"
182+
? doFetch(input, { signal, ...override(init) })
183+
: doFetch(input, { signal, ...init, ...override })
184+
},
176185
[identity] // eslint-disable-line react-hooks/exhaustive-deps
177186
),
178187
})

Diff for: packages/react-async/src/useAsync.spec.js

+48
Original file line numberDiff line numberDiff line change
@@ -202,4 +202,52 @@ describe("useFetch", () => {
202202
await Promise.resolve()
203203
expect(json).toHaveBeenCalled()
204204
})
205+
206+
test("calling `run` with a method argument allows to override `init` parameters", () => {
207+
const component = (
208+
<Fetch input="/test" init={{ method: "POST" }}>
209+
{({ run }) => (
210+
<button onClick={() => run(init => ({ ...init, body: '{"name":"test"}' }))}>run</button>
211+
)}
212+
</Fetch>
213+
)
214+
const { getByText } = render(component)
215+
expect(globalScope.fetch).not.toHaveBeenCalled()
216+
fireEvent.click(getByText("run"))
217+
expect(globalScope.fetch).toHaveBeenCalledWith(
218+
"/test",
219+
expect.objectContaining({ method: "POST", signal: abortCtrl.signal, body: '{"name":"test"}' })
220+
)
221+
})
222+
223+
test("calling `run` with an object as argument allows to override `init` parameters", () => {
224+
const component = (
225+
<Fetch input="/test" init={{ method: "POST" }}>
226+
{({ run }) => <button onClick={() => run({ body: '{"name":"test"}' })}>run</button>}
227+
</Fetch>
228+
)
229+
const { getByText } = render(component)
230+
expect(globalScope.fetch).not.toHaveBeenCalled()
231+
fireEvent.click(getByText("run"))
232+
expect(globalScope.fetch).toHaveBeenCalledWith(
233+
"/test",
234+
expect.objectContaining({ method: "POST", signal: abortCtrl.signal, body: '{"name":"test"}' })
235+
)
236+
})
237+
238+
test("passing `run` directly as a click handler will not spread the event over init", () => {
239+
const component = (
240+
<Fetch input="/test" init={{ method: "POST" }}>
241+
{({ run }) => <button onClick={run}>run</button>}
242+
</Fetch>
243+
)
244+
const { getByText } = render(component)
245+
expect(globalScope.fetch).not.toHaveBeenCalled()
246+
fireEvent.click(getByText("run"))
247+
expect(globalScope.fetch).toHaveBeenCalledWith("/test", expect.any(Object))
248+
expect(globalScope.fetch).not.toHaveBeenCalledWith(
249+
"/test",
250+
expect.objectContaining({ preventDefault: expect.any(Function) })
251+
)
252+
})
205253
})

0 commit comments

Comments
 (0)