Skip to content

Commit 6daaf40

Browse files
authored
Allow control over internal state using a state reducer and dispatcher (#46)
* Add 'reducer' and 'dispatcher' options to allow external control over internal state. * Re-enable all tests. * Invoke the promiseFn from the dispatcher so we can control its invocation.
1 parent 896a05f commit 6daaf40

File tree

7 files changed

+212
-49
lines changed

7 files changed

+212
-49
lines changed

README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,8 @@ These can be passed in an object to `useAsync()`, or as props to `<Async>` and c
298298
- `initialValue` Provide initial data or error for server-side rendering.
299299
- `onResolve` Callback invoked when Promise resolves.
300300
- `onReject` Callback invoked when Promise rejects.
301+
- `reducer` State reducer to control internal state updates.
302+
- `dispatcher` Action dispatcher to control internal action dispatching.
301303

302304
`useFetch` additionally takes these options:
303305

@@ -371,6 +373,21 @@ Callback function invoked when a promise resolves, receives data as argument.
371373
372374
Callback function invoked when a promise rejects, receives rejection reason (error) as argument.
373375

376+
#### `reducer`
377+
378+
> `function(state: any, action: Object, internalReducer: function(state: any, action: Object))`
379+
380+
State reducer to take full control over state updates by wrapping the internal reducer. It receives the current state,
381+
the dispatched action and the internal reducer. You probably want to invoke the internal reducer at some point.
382+
383+
#### `dispatcher`
384+
385+
> `function(action: Object, internalDispatch: function(action: Object), props: Object)`
386+
387+
Action dispatcher to take full control over action dispatching by wrapping the internal dispatcher. It receives the
388+
original action, the internal dispatcher and all component props (or options). You probably want to invoke the internal
389+
dispatcher at some point.
390+
374391
#### `defer`
375392

376393
> `boolean`
@@ -602,7 +619,9 @@ Alias: `<Async.Resolved>`
602619
```
603620

604621
```jsx
605-
<Async.Fulfilled>{(data, { finishedAt }) => `Last updated ${finishedAt.toISOString()}`}</Async.Fulfilled>
622+
<Async.Fulfilled>
623+
{(data, { finishedAt }) => `Last updated ${finishedAt.toISOString()}`}
624+
</Async.Fulfilled>
606625
```
607626

608627
### `<Async.Rejected>`

src/Async.js

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from "react"
2-
import { actionTypes, init, reducer } from "./reducer"
2+
import { actionTypes, init, dispatchMiddleware, reducer as asyncReducer } from "./reducer"
33

44
let PropTypes
55
try {
@@ -49,7 +49,16 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => {
4949
setData: this.setData,
5050
setError: this.setError,
5151
}
52-
this.dispatch = (action, callback) => this.setState(state => reducer(state, action), callback)
52+
53+
const _reducer = props.reducer || defaultProps.reducer
54+
const _dispatcher = props.dispatcher || defaultProps.dispatcher
55+
const reducer = _reducer
56+
? (state, action) => _reducer(state, action, asyncReducer)
57+
: asyncReducer
58+
const dispatch = dispatchMiddleware((action, callback) => {
59+
this.setState(state => reducer(state, action), callback)
60+
})
61+
this.dispatch = _dispatcher ? action => _dispatcher(action, dispatch, props) : dispatch
5362
}
5463

5564
componentDidMount() {
@@ -79,26 +88,38 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => {
7988
this.mounted = false
8089
}
8190

82-
start() {
91+
getMeta(meta) {
92+
return {
93+
counter: this.counter,
94+
...meta,
95+
}
96+
}
97+
98+
start(promiseFn) {
8399
if ("AbortController" in window) {
84100
this.abortController.abort()
85101
this.abortController = new window.AbortController()
86102
}
87103
this.counter++
88-
this.mounted && this.dispatch({ type: actionTypes.start, meta: { counter: this.counter } })
104+
return new Promise((resolve, reject) => {
105+
if (!this.mounted) return
106+
const executor = () => promiseFn().then(resolve, reject)
107+
this.dispatch({ type: actionTypes.start, payload: executor, meta: this.getMeta() })
108+
})
89109
}
90110

91111
load() {
92112
const promise = this.props.promise
93113
if (promise) {
94-
this.start()
95-
return promise.then(this.onResolve(this.counter), this.onReject(this.counter))
114+
return this.start(() => promise).then(
115+
this.onResolve(this.counter),
116+
this.onReject(this.counter)
117+
)
96118
}
97-
98119
const promiseFn = this.props.promiseFn || defaultProps.promiseFn
99120
if (promiseFn) {
100-
this.start()
101-
return promiseFn(this.props, this.abortController).then(
121+
const props = { ...defaultProps, ...this.props }
122+
return this.start(() => promiseFn(props, this.abortController)).then(
102123
this.onResolve(this.counter),
103124
this.onReject(this.counter)
104125
)
@@ -109,8 +130,8 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => {
109130
const deferFn = this.props.deferFn || defaultProps.deferFn
110131
if (deferFn) {
111132
this.args = args
112-
this.start()
113-
return deferFn(args, { ...defaultProps, ...this.props }, this.abortController).then(
133+
const props = { ...defaultProps, ...this.props }
134+
return this.start(() => deferFn(args, props, this.abortController)).then(
114135
this.onResolve(this.counter),
115136
this.onReject(this.counter)
116137
)
@@ -120,7 +141,7 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => {
120141
cancel() {
121142
this.counter++
122143
this.abortController.abort()
123-
this.mounted && this.dispatch({ type: actionTypes.cancel, meta: { counter: this.counter } })
144+
this.mounted && this.dispatch({ type: actionTypes.cancel, meta: this.getMeta() })
124145
}
125146

126147
onResolve(counter) {
@@ -144,13 +165,17 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => {
144165
}
145166

146167
setData(data, callback) {
147-
this.mounted && this.dispatch({ type: actionTypes.fulfill, payload: data }, callback)
168+
this.mounted &&
169+
this.dispatch({ type: actionTypes.fulfill, payload: data, meta: this.getMeta() }, callback)
148170
return data
149171
}
150172

151173
setError(error, callback) {
152174
this.mounted &&
153-
this.dispatch({ type: actionTypes.reject, payload: error, error: true }, callback)
175+
this.dispatch(
176+
{ type: actionTypes.reject, payload: error, error: true, meta: this.getMeta() },
177+
callback
178+
)
154179
return error
155180
}
156181

@@ -177,6 +202,8 @@ export const createInstance = (defaultProps = {}, displayName = "Async") => {
177202
initialValue: PropTypes.any,
178203
onResolve: PropTypes.func,
179204
onReject: PropTypes.func,
205+
reducer: PropTypes.func,
206+
dispatcher: PropTypes.func,
180207
}
181208
}
182209

src/Async.spec.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
withPromise,
1313
withPromiseFn,
1414
withDeferFn,
15+
withReducer,
16+
withDispatcher,
1517
} from "./specs"
1618

1719
const abortCtrl = { abort: jest.fn() }
@@ -25,6 +27,8 @@ describe("Async", () => {
2527
describe("with `promise`", withPromise(Async, abortCtrl))
2628
describe("with `promiseFn`", withPromiseFn(Async, abortCtrl))
2729
describe("with `deferFn`", withDeferFn(Async, abortCtrl))
30+
describe("with `reducer`", withReducer(Async, abortCtrl))
31+
describe("with `dispatcher`", withDispatcher(Async, abortCtrl))
2832

2933
test("an unrelated change in props does not update the Context", async () => {
3034
let one

src/reducer.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,14 @@ export const reducer = (state, { type, payload, meta }) => {
5353
finishedAt: new Date(),
5454
...getStatusProps(statusTypes.rejected),
5555
}
56+
default:
57+
return state
58+
}
59+
}
60+
61+
export const dispatchMiddleware = dispatch => (action, ...args) => {
62+
dispatch(action, ...args)
63+
if (action.type === actionTypes.start && typeof action.payload === "function") {
64+
action.payload()
5665
}
5766
}

src/specs.js

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const resolveTo = resolveIn(0)
99
export const rejectIn = ms => err =>
1010
new Promise((resolve, reject) => setTimeout(reject, ms, new Error(err)))
1111
export const rejectTo = rejectIn(0)
12+
export const sleep = ms => resolveIn(ms)()
1213

1314
export const common = Async => () => {
1415
test("passes `data`, `error`, metadata and methods as render props", async () => {
@@ -99,22 +100,22 @@ export const withPromise = Async => () => {
99100
test("invokes `onResolve` callback when the promise resolves", async () => {
100101
const onResolve = jest.fn()
101102
render(<Async promise={resolveTo("ok")} onResolve={onResolve} />)
102-
await resolveTo()
103+
await sleep(10)
103104
expect(onResolve).toHaveBeenCalledWith("ok")
104105
})
105106

106107
test("invokes `onReject` callback when the promise rejects", async () => {
107108
const onReject = jest.fn()
108109
render(<Async promise={rejectTo("err")} onReject={onReject} />)
109-
await resolveTo()
110+
await sleep(10)
110111
expect(onReject).toHaveBeenCalledWith(new Error("err"))
111112
})
112113

113114
test("cancels a pending promise when unmounted", async () => {
114115
const onResolve = jest.fn()
115116
const { unmount } = render(<Async promise={resolveTo("ok")} onResolve={onResolve} />)
116117
unmount()
117-
await resolveTo()
118+
await sleep(10)
118119
expect(onResolve).not.toHaveBeenCalled()
119120
})
120121

@@ -124,7 +125,7 @@ export const withPromise = Async => () => {
124125
const onResolve = jest.fn()
125126
const { rerender } = render(<Async promise={promise1} onResolve={onResolve} />)
126127
rerender(<Async promise={promise2} onResolve={onResolve} />)
127-
await resolveTo()
128+
await sleep(10)
128129
expect(onResolve).not.toHaveBeenCalledWith("one")
129130
expect(onResolve).toHaveBeenCalledWith("two")
130131
})
@@ -133,7 +134,7 @@ export const withPromise = Async => () => {
133134
const onResolve = jest.fn()
134135
const { rerender } = render(<Async promise={resolveTo()} onResolve={onResolve} />)
135136
rerender(<Async onResolve={onResolve} />)
136-
await resolveTo()
137+
await sleep(10)
137138
expect(onResolve).not.toHaveBeenCalled()
138139
})
139140

@@ -191,14 +192,14 @@ export const withPromiseFn = (Async, abortCtrl) => () => {
191192
test("invokes `onResolve` callback when the promise resolves", async () => {
192193
const onResolve = jest.fn()
193194
render(<Async promiseFn={() => resolveTo("ok")} onResolve={onResolve} />)
194-
await resolveTo()
195+
await sleep(10)
195196
expect(onResolve).toHaveBeenCalledWith("ok")
196197
})
197198

198199
test("invokes `onReject` callback when the promise rejects", async () => {
199200
const onReject = jest.fn()
200201
render(<Async promiseFn={() => rejectTo("err")} onReject={onReject} />)
201-
await resolveTo()
202+
await sleep(10)
202203
expect(onReject).toHaveBeenCalledWith(new Error("err"))
203204
})
204205

@@ -280,7 +281,7 @@ export const withPromiseFn = (Async, abortCtrl) => () => {
280281
const onResolve = jest.fn()
281282
const { unmount } = render(<Async promiseFn={() => resolveTo("ok")} onResolve={onResolve} />)
282283
unmount()
283-
await resolveTo()
284+
await sleep(10)
284285
expect(onResolve).not.toHaveBeenCalled()
285286
expect(abortCtrl.abort).toHaveBeenCalledTimes(1)
286287
})
@@ -291,7 +292,7 @@ export const withPromiseFn = (Async, abortCtrl) => () => {
291292
const onResolve = jest.fn()
292293
const { rerender } = render(<Async promiseFn={promiseFn1} onResolve={onResolve} />)
293294
rerender(<Async promiseFn={promiseFn2} onResolve={onResolve} />)
294-
await resolveTo()
295+
await sleep(10)
295296
expect(onResolve).not.toHaveBeenCalledWith("one")
296297
expect(onResolve).toHaveBeenCalledWith("two")
297298
expect(abortCtrl.abort).toHaveBeenCalledTimes(1)
@@ -301,15 +302,15 @@ export const withPromiseFn = (Async, abortCtrl) => () => {
301302
const onResolve = jest.fn()
302303
const { rerender } = render(<Async promiseFn={() => resolveTo()} onResolve={onResolve} />)
303304
rerender(<Async onResolve={onResolve} />)
304-
await resolveTo()
305+
await sleep(10)
305306
expect(onResolve).not.toHaveBeenCalled()
306307
expect(abortCtrl.abort).toHaveBeenCalledTimes(1)
307308
})
308309

309310
test("does not run `promiseFn` on mount when `initialValue` is provided", async () => {
310311
const promiseFn = jest.fn().mockReturnValue(resolveTo())
311312
render(<Async promiseFn={promiseFn} initialValue={{}} />)
312-
await resolveTo()
313+
await sleep(10)
313314
expect(promiseFn).not.toHaveBeenCalled()
314315
})
315316

@@ -415,3 +416,81 @@ export const withDeferFn = (Async, abortCtrl) => () => {
415416
await waitForElement(() => getByText("done"))
416417
})
417418
}
419+
420+
export const withReducer = Async => () => {
421+
test("receives state, action and the original reducer", async () => {
422+
const promise = resolveTo("done")
423+
const reducer = jest.fn((state, action, asyncReducer) => asyncReducer(state, action))
424+
const { getByText } = render(
425+
<Async promise={promise} reducer={reducer}>
426+
{({ data }) => data || null}
427+
</Async>
428+
)
429+
await waitForElement(() => getByText("done"))
430+
expect(reducer).toHaveBeenCalledWith(
431+
expect.objectContaining({ status: expect.any(String) }),
432+
expect.objectContaining({ type: expect.any(String) }),
433+
expect.any(Function)
434+
)
435+
})
436+
437+
test("allows overriding state updates", async () => {
438+
const promise = resolveTo("done")
439+
const reducer = (state, action, asyncReducer) => {
440+
if (action.type === "fulfill") action.payload = "cool"
441+
return asyncReducer(state, action)
442+
}
443+
const { getByText } = render(
444+
<Async promise={promise} reducer={reducer}>
445+
{({ data }) => data || null}
446+
</Async>
447+
)
448+
await waitForElement(() => getByText("cool"))
449+
})
450+
}
451+
452+
export const withDispatcher = Async => () => {
453+
test("receives action, the original dispatch method and options", async () => {
454+
const promise = resolveTo("done")
455+
const dispatcher = jest.fn((action, dispatch) => dispatch(action))
456+
const props = { promise, dispatcher }
457+
const { getByText } = render(<Async {...props}>{({ data }) => data || null}</Async>)
458+
await waitForElement(() => getByText("done"))
459+
expect(dispatcher).toHaveBeenCalledWith(
460+
expect.objectContaining({ type: expect.any(String) }),
461+
expect.any(Function),
462+
expect.objectContaining(props)
463+
)
464+
})
465+
466+
test("allows overriding actions before dispatch", async () => {
467+
const promise = resolveTo("done")
468+
const dispatcher = (action, dispatch) => {
469+
if (action.type === "fulfill") action.payload = "cool"
470+
dispatch(action)
471+
}
472+
const { getByText } = render(
473+
<Async promise={promise} dispatcher={dispatcher}>
474+
{({ data }) => data || null}
475+
</Async>
476+
)
477+
await waitForElement(() => getByText("cool"))
478+
})
479+
480+
test("allows dispatching additional actions", async () => {
481+
const promise = resolveTo("done")
482+
const customAction = { type: "custom" }
483+
const dispatcher = (action, dispatch) => {
484+
dispatch(action)
485+
dispatch(customAction)
486+
}
487+
const reducer = jest.fn((state, action, asyncReducer) => asyncReducer(state, action))
488+
const { getByText } = render(
489+
<Async promise={promise} dispatcher={dispatcher} reducer={reducer}>
490+
{({ data }) => data || null}
491+
</Async>
492+
)
493+
await waitForElement(() => getByText("done"))
494+
expect(reducer).toHaveBeenCalledWith(expect.anything(), customAction, expect.anything())
495+
})
496+
}

0 commit comments

Comments
 (0)