Skip to content

Commit

Permalink
allow passing a callback to useFetch's run to modify init (#76)
Browse files Browse the repository at this point in the history
* 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'.
  • Loading branch information
phryneas authored and ghengeveld committed Aug 23, 2019
1 parent dda874a commit bca0187
Show file tree
Hide file tree
Showing 8 changed files with 184 additions and 81 deletions.
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,25 @@ const MyComponent = () => {
const headers = { Accept: "application/json" }
const { data, error, isLoading, run } = useFetch("/api/example", { headers }, options)
// This will setup a promiseFn with a fetch request and JSON deserialization.

// you can later call `run` with an optional callback argument to
// last-minute modify the `init` parameter that is passed to `fetch`
function clickHandler() {
run(init => ({
...init,
headers: {
...init.headers,
authentication: "...",
},
}))
}

// alternatively, you can also just use an object that will be spread over `init`.
// please note that this is not deep-merged, so you might override properties present in the
// original `init` parameter
function clickHandler2() {
run({ body: JSON.stringify(formValues) })
}
}
```

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

When used with `useFetch`, `run` has a different signature:

> `function(init: Object | (init: Object) => Object): void`
This runs the `fetch` request using the provided `init`. If it's an object it will be spread over the default `init`
(`useFetch`'s 2nd argument). If it's a function it will be invoked with the default `init` and should return a new
`init` object. This way you can either extend or override the value of `init`, for example to set request headers.

#### `reload`

> `function(): void`
Expand Down
2 changes: 2 additions & 0 deletions examples/with-typescript/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Async, {
} from "react-async"
import DevTools from "react-async-devtools"
import "./App.css"
import { FetchHookExample } from "./FetchHookExample"

const loadFirstName: PromiseFn<string> = ({ userId }) =>
fetch(`https://reqres.in/api/users/${userId}`)
Expand Down Expand Up @@ -50,6 +51,7 @@ class App extends Component {
<CustomAsync.Resolved>{data => <>{data}</>}</CustomAsync.Resolved>
</CustomAsync>
<UseAsync />
<FetchHookExample />
</header>
</div>
)
Expand Down
52 changes: 52 additions & 0 deletions examples/with-typescript/src/FetchHookExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as React from "react"
import { useFetch } from "react-async"

export function FetchHookExample() {
const result = useFetch<{ token: string }>("https://reqres.in/api/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
})
const { run } = result

return (
<>
<h2>with fetch hook:</h2>
<button onClick={run}>just run it without login data</button>
<button
onClick={() =>
run(init => ({
...init,
body: JSON.stringify({
email: "eve.holt@reqres.in",
password: "cityslicka",
}),
}))
}
>
run it with valid login data (init callback)
</button>
<button
onClick={() =>
run({
body: JSON.stringify({
email: "eve.holt@reqres.in",
password: "cityslicka",
}),
})
}
>
run it with valid login data (init object)
</button>
<br />
Status:
<br />
{result.isInitial && "initial"}
{result.isLoading && "loading"}
{result.isRejected && "rejected"}
{result.isResolved && `token: ${result.data.token}`}
</>
)
}
8 changes: 3 additions & 5 deletions examples/with-typescript/src/index.css
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
}
17 changes: 16 additions & 1 deletion packages/react-async/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,21 @@ export function useFetch<T>(
input: RequestInfo,
init?: RequestInit,
options?: FetchOptions<T>
): AsyncState<T>
): AsyncInitialWithout<"run", T> & FetchRun<T>

// unfortunately, we cannot just omit K from AsyncInitial as that would unbox the Discriminated Union
type AsyncInitialWithout<K extends keyof AsyncInitial<T>, T> =
| Omit<AsyncInitial<T>, K>
| Omit<AsyncPending<T>, K>
| Omit<AsyncFulfilled<T>, K>
| Omit<AsyncRejected<T>, K>

type FetchRun<T> = {
run(overrideInit: (init: RequestInit) => RequestInit): void
run(overrideInit: Partial<RequestInit>): void
run(ignoredEvent: React.SyntheticEvent): void
run(ignoredEvent: Event): void
run(): void
}

export default Async
23 changes: 16 additions & 7 deletions packages/react-async/src/useAsync.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,23 +156,32 @@ const useAsync = (arg1, arg2) => {

const parseResponse = (accept, json) => res => {
if (!res.ok) return Promise.reject(res)
if (json === false) return res
if (json === true || accept === "application/json") return res.json()
return res
if (typeof json === "boolean") return json ? res.json() : res
return accept === "application/json" ? res.json() : res
}

const useAsyncFetch = (input, init, { defer, json, ...options } = {}) => {
const method = input.method || (init && init.method)
const headers = input.headers || (init && init.headers) || {}
const accept = headers["Accept"] || headers["accept"] || (headers.get && headers.get("accept"))
const doFetch = (input, init) => globalScope.fetch(input, init).then(parseResponse(accept, json))
const isDefer = defer === true || ~["POST", "PUT", "PATCH", "DELETE"].indexOf(method)
const fn = defer === false || !isDefer ? "promiseFn" : "deferFn"
const identity = JSON.stringify({ input, init })
const isDefer =
typeof defer === "boolean" ? defer : ["POST", "PUT", "PATCH", "DELETE"].indexOf(method) !== -1
const fn = isDefer ? "deferFn" : "promiseFn"
const identity = JSON.stringify({ input, init, isDefer })
const state = useAsync({
...options,
[fn]: useCallback(
(_, props, ctrl) => doFetch(input, { signal: ctrl ? ctrl.signal : props.signal, ...init }),
(arg1, arg2, arg3) => {
const [override, signal] = arg3 ? [arg1[0], arg3.signal] : [undefined, arg2.signal]
if (typeof override === "object" && "preventDefault" in override) {
// Don't spread Events or SyntheticEvents
return doFetch(input, { signal, ...init })
}
return typeof override === "function"
? doFetch(input, { signal, ...override(init) })
: doFetch(input, { signal, ...init, ...override })
},
[identity] // eslint-disable-line react-hooks/exhaustive-deps
),
})
Expand Down
48 changes: 48 additions & 0 deletions packages/react-async/src/useAsync.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,4 +202,52 @@ describe("useFetch", () => {
await Promise.resolve()
expect(json).toHaveBeenCalled()
})

test("calling `run` with a method argument allows to override `init` parameters", () => {
const component = (
<Fetch input="/test" init={{ method: "POST" }}>
{({ run }) => (
<button onClick={() => run(init => ({ ...init, body: '{"name":"test"}' }))}>run</button>
)}
</Fetch>
)
const { getByText } = render(component)
expect(globalScope.fetch).not.toHaveBeenCalled()
fireEvent.click(getByText("run"))
expect(globalScope.fetch).toHaveBeenCalledWith(
"/test",
expect.objectContaining({ method: "POST", signal: abortCtrl.signal, body: '{"name":"test"}' })
)
})

test("calling `run` with an object as argument allows to override `init` parameters", () => {
const component = (
<Fetch input="/test" init={{ method: "POST" }}>
{({ run }) => <button onClick={() => run({ body: '{"name":"test"}' })}>run</button>}
</Fetch>
)
const { getByText } = render(component)
expect(globalScope.fetch).not.toHaveBeenCalled()
fireEvent.click(getByText("run"))
expect(globalScope.fetch).toHaveBeenCalledWith(
"/test",
expect.objectContaining({ method: "POST", signal: abortCtrl.signal, body: '{"name":"test"}' })
)
})

test("passing `run` directly as a click handler will not spread the event over init", () => {
const component = (
<Fetch input="/test" init={{ method: "POST" }}>
{({ run }) => <button onClick={run}>run</button>}
</Fetch>
)
const { getByText } = render(component)
expect(globalScope.fetch).not.toHaveBeenCalled()
fireEvent.click(getByText("run"))
expect(globalScope.fetch).toHaveBeenCalledWith("/test", expect.any(Object))
expect(globalScope.fetch).not.toHaveBeenCalledWith(
"/test",
expect.objectContaining({ preventDefault: expect.any(Function) })
)
})
})
Loading

0 comments on commit bca0187

Please sign in to comment.