Skip to content

Commit 92ef3b0

Browse files
committed
Adds async action example
Introduces async actions, pseudo-async API, middleware, and types for all of it. See: https://rjzaworski.com/2015/09/typescript-redux-async-actions
1 parent af1cf7e commit 92ef3b0

File tree

12 files changed

+229
-22
lines changed

12 files changed

+229
-22
lines changed

karma.conf.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ module.exports = function(config) {
1212
basePath: '',
1313
frameworks: ['jasmine', 'sinon'],
1414
files: [
15+
// polyfill features for phantom
16+
'node_modules/es6-promise/dist/es6-promise.js',
17+
18+
// source files
1519
'src/**/__tests__/*spec.ts',
1620
'src/**/__tests__/*spec.tsx'
1721
],

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"redux": "^3.5.2"
2323
},
2424
"devDependencies": {
25+
"es6-promise": "^3.2.1",
2526
"jasmine-core": "2.4.1",
2627
"karma": "^1.1.2",
2728
"karma-jasmine": "1.0.2",

src/actions/index.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
1-
export type Action = {
2-
type: 'INCREMENT_COUNTER',
3-
delta: number,
4-
} | {
5-
type: 'RESET_COUNTER',
6-
}
1+
type Q<T> = { request: T }
2+
type S<T> = { response: T }
3+
type E = { error: Error }
4+
5+
type QEmpty = Q<null>
6+
type QValue = Q<{ value: number }>
7+
8+
export type Action =
9+
// UI actions
10+
{ type: 'INCREMENT_COUNTER', delta: number }
11+
| { type: 'RESET_COUNTER' }
12+
13+
// API Requests
14+
| ({ type: 'SAVE_COUNT_REQUEST' } & QValue)
15+
| ({ type: 'SAVE_COUNT_SUCCESS' } & QValue & S<{}>)
16+
| ({ type: 'SAVE_COUNT_ERROR' } & QValue & E)
17+
18+
| ({ type: 'LOAD_COUNT_REQUEST' } & QEmpty)
19+
| ({ type: 'LOAD_COUNT_SUCCESS' } & QEmpty & S<{ value: number }>)
20+
| ({ type: 'LOAD_COUNT_ERROR' } & QEmpty & E)
721

822
export const incrementCounter = (delta: number): Action => ({
923
type: 'INCREMENT_COUNTER',
@@ -13,3 +27,27 @@ export const incrementCounter = (delta: number): Action => ({
1327
export const resetCounter = (): Action => ({
1428
type: 'RESET_COUNTER',
1529
})
30+
31+
export type ApiActionGroup<_Q, _S> = {
32+
request: (q?: _Q) => Action & Q<_Q>
33+
success: (s: _S, q?: _Q) => Action & Q<_Q> & S<_S>
34+
error: (e: Error, q?: _Q) => Action & Q<_Q> & E
35+
}
36+
37+
export const saveCount: ApiActionGroup<{ value: number }, {}> = {
38+
request: (request) =>
39+
({ type: 'SAVE_COUNT_REQUEST', request }),
40+
success: (response, request) =>
41+
({ type: 'SAVE_COUNT_SUCCESS', request, response }),
42+
error: (error, request) =>
43+
({ type: 'SAVE_COUNT_ERROR', request, error }),
44+
}
45+
46+
export const loadCount: ApiActionGroup<null, { value: number }> = {
47+
request: (request) =>
48+
({ type: 'LOAD_COUNT_REQUEST', request: null }),
49+
success: (response, request) =>
50+
({ type: 'LOAD_COUNT_SUCCESS', request: null, response }),
51+
error: (error, request) =>
52+
({ type: 'LOAD_COUNT_ERROR', request: null, error }),
53+
}

src/api.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export const api = {
2+
save: (counter: { value: number }): Promise<null> => {
3+
try {
4+
localStorage.setItem('__counterValue', counter.value.toString())
5+
return Promise.resolve(null)
6+
}
7+
catch (e) {
8+
return Promise.reject(e)
9+
}
10+
},
11+
load: (): Promise<{ value: number }> => {
12+
try {
13+
const value = parseInt(localStorage.getItem('__counterValue'), 10)
14+
return Promise.resolve({ value })
15+
}
16+
catch (e) {
17+
return Promise.reject(e)
18+
}
19+
},
20+
}

src/components/__tests__/counter_spec.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,10 @@ describe('components/Counter', () => {
4545

4646
beforeEach(() => {
4747
counter = setup()
48-
const buttonEl = TestUtils.findRenderedDOMComponentWithTag(counter, 'button')
49-
TestUtils.Simulate.click(buttonEl)
50-
TestUtils.Simulate.click(buttonEl)
51-
TestUtils.Simulate.click(buttonEl)
48+
const [ increment ] = TestUtils.scryRenderedDOMComponentsWithTag(counter, 'button')
49+
TestUtils.Simulate.click(increment)
50+
TestUtils.Simulate.click(increment)
51+
TestUtils.Simulate.click(increment)
5252
})
5353

5454
it('increments counter', () => {

src/components/counter.tsx

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import * as React from 'react'
22
import * as redux from 'redux'
33
import { connect } from 'react-redux'
44

5-
import { incrementCounter } from '../actions'
5+
import {
6+
incrementCounter,
7+
saveCount,
8+
loadCount,
9+
} from '../actions'
10+
611
import { Store } from '../reducers'
712

813
type OwnProps = {
@@ -15,16 +20,21 @@ type ConnectedState = {
1520

1621
type ConnectedDispatch = {
1722
increment: (n: number) => void
23+
save: (n: number) => void
24+
load: () => void
1825
}
1926

2027
const mapStateToProps = (state: Store.All, ownProps: OwnProps): ConnectedState => ({
2128
counter: state.counter,
2229
})
2330

2431
const mapDispatchToProps = (dispatch: redux.Dispatch<Store.All>): ConnectedDispatch => ({
25-
increment: (n: number): void => {
26-
dispatch(incrementCounter(n))
27-
},
32+
increment: (n: number) =>
33+
dispatch(incrementCounter(n)),
34+
load: () =>
35+
dispatch(loadCount.request()),
36+
save: (value: number) =>
37+
dispatch(saveCount.request({ value })),
2838
})
2939

3040
class CounterComponent extends React.Component<ConnectedState & ConnectedDispatch & OwnProps, {}> {
@@ -34,12 +44,24 @@ class CounterComponent extends React.Component<ConnectedState & ConnectedDispatc
3444
this.props.increment(1)
3545
}
3646

47+
_onClickSave = (e: React.SyntheticEvent) => {
48+
e.preventDefault()
49+
this.props.save(this.props.counter.value)
50+
}
51+
52+
_onClickLoad = (e: React.SyntheticEvent) => {
53+
e.preventDefault()
54+
this.props.load()
55+
}
56+
3757
render () {
3858
const { counter, label } = this.props
3959
return <div>
4060
<label>{label}</label>
4161
<pre>counter = {counter.value}</pre>
4262
<button ref='increment' onClick={this._onClickIncrement}>click me!</button>
63+
<button ref='save' onClick={this._onClickSave}>save</button>
64+
<button ref='load' onClick={this._onClickLoad}>load</button>
4365
</div>
4466
}
4567
}

src/index.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react' // tslint:disable-line
22
import * as ReactDOM from 'react-dom'
3-
import * as Redux from 'redux'
3+
import * as redux from 'redux'
44
import { Provider } from 'react-redux'
55

66
import {
@@ -10,7 +10,13 @@ import {
1010

1111
import { Counter } from './components/counter'
1212

13-
let store: Redux.Store<Store.All> = Redux.createStore(reducers)
13+
import { apiMiddleware } from './middleware'
14+
15+
const middleware = redux.applyMiddleware(
16+
apiMiddleware
17+
)
18+
19+
let store: redux.Store<Store.All> = redux.createStore(reducers, {} as Store.All, middleware)
1420

1521
// Commented out ("let HTML app be HTML app!")
1622
window.addEventListener('DOMContentLoaded', () => {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import * as redux from 'redux'
2+
3+
import { apiMiddleware } from '../'
4+
5+
import { api } from '../../api'
6+
7+
import {
8+
Action,
9+
loadCount,
10+
saveCount,
11+
} from '../../actions'
12+
13+
const empty = () => {}
14+
15+
const mockDispatch = (dispatch: (a: Action) => void): redux.MiddlewareAPI<any> =>
16+
({ dispatch, getState: empty })
17+
18+
describe('apiMiddleware', () => {
19+
20+
describe('when SAVE_COUNT_REQUEST succeeds', () => {
21+
22+
it('includes request { value }', (done) => {
23+
const saveStub = sinon.stub(api, 'save')
24+
.returns(Promise.resolve({}))
25+
26+
apiMiddleware(mockDispatch((actual: Action) => {
27+
expect(saveStub.firstCall.args[0].value).toEqual(13)
28+
saveStub.restore()
29+
done()
30+
}))(empty)(saveCount.request({ value: 13 }))
31+
})
32+
33+
it('fires SAVE_COUNT_SUCCESS', (done) => {
34+
const saveStub = sinon.stub(api, 'save')
35+
.returns(Promise.resolve({}))
36+
37+
apiMiddleware(mockDispatch((actual: Action) => {
38+
saveStub.restore()
39+
expect(actual.type).toEqual('SAVE_COUNT_SUCCESS')
40+
done()
41+
}))(empty)(saveCount.request())
42+
})
43+
44+
})
45+
46+
describe('when LOAD_COUNT_REQUEST succeeds', () => {
47+
48+
it('fires LOAD_COUNT_SUCCESS', (done) => {
49+
const loadStub = sinon.stub(api, 'load')
50+
.returns(Promise.resolve({ value: 42 }))
51+
52+
apiMiddleware(mockDispatch((actual: Action) => {
53+
loadStub.restore()
54+
55+
if (actual.type === 'LOAD_COUNT_SUCCESS') {
56+
expect(42).toEqual(actual.response.value)
57+
done()
58+
}
59+
else {
60+
done.fail('types don\'t match')
61+
}
62+
}))(empty)(loadCount.request())
63+
})
64+
})
65+
66+
67+
})

src/middleware/index.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import * as redux from 'redux'
2+
3+
import { api } from '../api'
4+
5+
import {
6+
Action,
7+
saveCount,
8+
loadCount,
9+
} from '../actions'
10+
11+
export const apiMiddleware = ({ dispatch }: redux.MiddlewareAPI<any>) =>
12+
(next: redux.Dispatch<any>) =>
13+
(action: Action) => {
14+
switch (action.type) {
15+
16+
case 'SAVE_COUNT_REQUEST':
17+
api.save(action.request)
18+
.then(() => dispatch(saveCount.success({}, action.request)))
19+
.catch((e) => dispatch(saveCount.error(e, action.request)))
20+
break
21+
22+
case 'LOAD_COUNT_REQUEST':
23+
api.load()
24+
.then(({ value }) => dispatch(loadCount.success({ value }, action.request)))
25+
.catch((e) => dispatch(loadCount.error(e, action.request)))
26+
break
27+
}
28+
29+
return next(action)
30+
}

src/reducers/__tests__/index_spec.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { createStore } from 'redux'
22

33
import { reducers } from '../index'
4-
import { incrementCounter } from '../../actions'
4+
import {
5+
incrementCounter,
6+
loadCount,
7+
} from '../../actions'
58

69
describe('reducers/counter', () => {
710
it('starts at 0', () => {
@@ -19,4 +22,15 @@ describe('reducers/counter', () => {
1922
})
2023
store.dispatch(incrementCounter(3))
2124
})
25+
26+
it('restores state', (done) => {
27+
const store = createStore(reducers)
28+
store.subscribe(() => {
29+
const { counter } = store.getState()
30+
expect(counter.value).toEqual(14)
31+
done()
32+
})
33+
store.dispatch(loadCount.success({ value: 14 }))
34+
})
35+
2236
})

src/reducers/index.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,20 @@ const initialState: Store.Counter = {
1616
}
1717

1818
function counter (state: Store.Counter = initialState, action: Action): Store.Counter {
19-
const { value } = state
2019
switch (action.type) {
2120
case 'INCREMENT_COUNTER':
2221
const { delta } = action
23-
return { value: value + delta }
22+
return { value: state.value + delta }
2423

2524
case 'RESET_COUNTER':
2625
return { value: 0 }
27-
}
2826

29-
return state
27+
case 'LOAD_COUNT_SUCCESS':
28+
return { value: action.response.value }
29+
30+
default:
31+
return state
32+
}
3033
}
3134

3235
export const reducers = combineReducers<Store.All>({

typings.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
{
22
"name": "typescript-react-redux",
33
"globalDependencies": {
4+
"es6-promise": "registry:dt/es6-promise#0.0.0+20160614011821",
45
"jasmine": "registry:dt/jasmine#2.2.0+20160621224255",
56
"react": "registry:dt/react#0.14.0+20160805125551",
67
"react-addons-test-utils": "registry:dt/react-addons-test-utils#0.14.0+20160427035638",
7-
"react-dom": "registry:dt/react-dom#0.14.0+20160412154040"
8+
"react-dom": "registry:dt/react-dom#0.14.0+20160412154040",
9+
"sinon": "registry:dt/sinon#1.16.0+20160924120326"
810
},
911
"dependencies": {
1012
"react-redux": "registry:npm/react-redux#4.4.0+20160614222153"

0 commit comments

Comments
 (0)