Skip to content

Commit

Permalink
Adds async action example
Browse files Browse the repository at this point in the history
Introduces async actions, pseudo-async API, middleware, and types for
all of it.

See: https://rjzaworski.com/2015/09/typescript-redux-async-actions
  • Loading branch information
rjz committed Sep 26, 2016
1 parent af1cf7e commit 92ef3b0
Show file tree
Hide file tree
Showing 12 changed files with 229 additions and 22 deletions.
4 changes: 4 additions & 0 deletions karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ module.exports = function(config) {
basePath: '',
frameworks: ['jasmine', 'sinon'],
files: [
// polyfill features for phantom
'node_modules/es6-promise/dist/es6-promise.js',

// source files
'src/**/__tests__/*spec.ts',
'src/**/__tests__/*spec.tsx'
],
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"redux": "^3.5.2"
},
"devDependencies": {
"es6-promise": "^3.2.1",
"jasmine-core": "2.4.1",
"karma": "^1.1.2",
"karma-jasmine": "1.0.2",
Expand Down
50 changes: 44 additions & 6 deletions src/actions/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
export type Action = {
type: 'INCREMENT_COUNTER',
delta: number,
} | {
type: 'RESET_COUNTER',
}
type Q<T> = { request: T }
type S<T> = { response: T }
type E = { error: Error }

type QEmpty = Q<null>
type QValue = Q<{ value: number }>

export type Action =
// UI actions
{ type: 'INCREMENT_COUNTER', delta: number }
| { type: 'RESET_COUNTER' }

// API Requests
| ({ type: 'SAVE_COUNT_REQUEST' } & QValue)
| ({ type: 'SAVE_COUNT_SUCCESS' } & QValue & S<{}>)
| ({ type: 'SAVE_COUNT_ERROR' } & QValue & E)

| ({ type: 'LOAD_COUNT_REQUEST' } & QEmpty)
| ({ type: 'LOAD_COUNT_SUCCESS' } & QEmpty & S<{ value: number }>)
| ({ type: 'LOAD_COUNT_ERROR' } & QEmpty & E)

export const incrementCounter = (delta: number): Action => ({
type: 'INCREMENT_COUNTER',
Expand All @@ -13,3 +27,27 @@ export const incrementCounter = (delta: number): Action => ({
export const resetCounter = (): Action => ({
type: 'RESET_COUNTER',
})

export type ApiActionGroup<_Q, _S> = {
request: (q?: _Q) => Action & Q<_Q>
success: (s: _S, q?: _Q) => Action & Q<_Q> & S<_S>
error: (e: Error, q?: _Q) => Action & Q<_Q> & E
}

export const saveCount: ApiActionGroup<{ value: number }, {}> = {
request: (request) =>
({ type: 'SAVE_COUNT_REQUEST', request }),
success: (response, request) =>
({ type: 'SAVE_COUNT_SUCCESS', request, response }),
error: (error, request) =>
({ type: 'SAVE_COUNT_ERROR', request, error }),
}

export const loadCount: ApiActionGroup<null, { value: number }> = {
request: (request) =>
({ type: 'LOAD_COUNT_REQUEST', request: null }),
success: (response, request) =>
({ type: 'LOAD_COUNT_SUCCESS', request: null, response }),
error: (error, request) =>
({ type: 'LOAD_COUNT_ERROR', request: null, error }),
}
20 changes: 20 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export const api = {
save: (counter: { value: number }): Promise<null> => {
try {
localStorage.setItem('__counterValue', counter.value.toString())
return Promise.resolve(null)
}
catch (e) {
return Promise.reject(e)
}
},
load: (): Promise<{ value: number }> => {
try {
const value = parseInt(localStorage.getItem('__counterValue'), 10)
return Promise.resolve({ value })
}
catch (e) {
return Promise.reject(e)
}
},
}
8 changes: 4 additions & 4 deletions src/components/__tests__/counter_spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ describe('components/Counter', () => {

beforeEach(() => {
counter = setup()
const buttonEl = TestUtils.findRenderedDOMComponentWithTag(counter, 'button')
TestUtils.Simulate.click(buttonEl)
TestUtils.Simulate.click(buttonEl)
TestUtils.Simulate.click(buttonEl)
const [ increment ] = TestUtils.scryRenderedDOMComponentsWithTag(counter, 'button')
TestUtils.Simulate.click(increment)
TestUtils.Simulate.click(increment)
TestUtils.Simulate.click(increment)
})

it('increments counter', () => {
Expand Down
30 changes: 26 additions & 4 deletions src/components/counter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import * as React from 'react'
import * as redux from 'redux'
import { connect } from 'react-redux'

import { incrementCounter } from '../actions'
import {
incrementCounter,
saveCount,
loadCount,
} from '../actions'

import { Store } from '../reducers'

type OwnProps = {
Expand All @@ -15,16 +20,21 @@ type ConnectedState = {

type ConnectedDispatch = {
increment: (n: number) => void
save: (n: number) => void
load: () => void
}

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

const mapDispatchToProps = (dispatch: redux.Dispatch<Store.All>): ConnectedDispatch => ({
increment: (n: number): void => {
dispatch(incrementCounter(n))
},
increment: (n: number) =>
dispatch(incrementCounter(n)),
load: () =>
dispatch(loadCount.request()),
save: (value: number) =>
dispatch(saveCount.request({ value })),
})

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

_onClickSave = (e: React.SyntheticEvent) => {
e.preventDefault()
this.props.save(this.props.counter.value)
}

_onClickLoad = (e: React.SyntheticEvent) => {
e.preventDefault()
this.props.load()
}

render () {
const { counter, label } = this.props
return <div>
<label>{label}</label>
<pre>counter = {counter.value}</pre>
<button ref='increment' onClick={this._onClickIncrement}>click me!</button>
<button ref='save' onClick={this._onClickSave}>save</button>
<button ref='load' onClick={this._onClickLoad}>load</button>
</div>
}
}
Expand Down
10 changes: 8 additions & 2 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react' // tslint:disable-line
import * as ReactDOM from 'react-dom'
import * as Redux from 'redux'
import * as redux from 'redux'
import { Provider } from 'react-redux'

import {
Expand All @@ -10,7 +10,13 @@ import {

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

let store: Redux.Store<Store.All> = Redux.createStore(reducers)
import { apiMiddleware } from './middleware'

const middleware = redux.applyMiddleware(
apiMiddleware
)

let store: redux.Store<Store.All> = redux.createStore(reducers, {} as Store.All, middleware)

// Commented out ("let HTML app be HTML app!")
window.addEventListener('DOMContentLoaded', () => {
Expand Down
67 changes: 67 additions & 0 deletions src/middleware/__tests__/index_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import * as redux from 'redux'

import { apiMiddleware } from '../'

import { api } from '../../api'

import {
Action,
loadCount,
saveCount,
} from '../../actions'

const empty = () => {}

const mockDispatch = (dispatch: (a: Action) => void): redux.MiddlewareAPI<any> =>
({ dispatch, getState: empty })

describe('apiMiddleware', () => {

describe('when SAVE_COUNT_REQUEST succeeds', () => {

it('includes request { value }', (done) => {
const saveStub = sinon.stub(api, 'save')
.returns(Promise.resolve({}))

apiMiddleware(mockDispatch((actual: Action) => {
expect(saveStub.firstCall.args[0].value).toEqual(13)
saveStub.restore()
done()
}))(empty)(saveCount.request({ value: 13 }))
})

it('fires SAVE_COUNT_SUCCESS', (done) => {
const saveStub = sinon.stub(api, 'save')
.returns(Promise.resolve({}))

apiMiddleware(mockDispatch((actual: Action) => {
saveStub.restore()
expect(actual.type).toEqual('SAVE_COUNT_SUCCESS')
done()
}))(empty)(saveCount.request())
})

})

describe('when LOAD_COUNT_REQUEST succeeds', () => {

it('fires LOAD_COUNT_SUCCESS', (done) => {
const loadStub = sinon.stub(api, 'load')
.returns(Promise.resolve({ value: 42 }))

apiMiddleware(mockDispatch((actual: Action) => {
loadStub.restore()

if (actual.type === 'LOAD_COUNT_SUCCESS') {
expect(42).toEqual(actual.response.value)
done()
}
else {
done.fail('types don\'t match')
}
}))(empty)(loadCount.request())
})
})


})
30 changes: 30 additions & 0 deletions src/middleware/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as redux from 'redux'

import { api } from '../api'

import {
Action,
saveCount,
loadCount,
} from '../actions'

export const apiMiddleware = ({ dispatch }: redux.MiddlewareAPI<any>) =>
(next: redux.Dispatch<any>) =>
(action: Action) => {
switch (action.type) {

case 'SAVE_COUNT_REQUEST':
api.save(action.request)
.then(() => dispatch(saveCount.success({}, action.request)))
.catch((e) => dispatch(saveCount.error(e, action.request)))
break

case 'LOAD_COUNT_REQUEST':
api.load()
.then(({ value }) => dispatch(loadCount.success({ value }, action.request)))
.catch((e) => dispatch(loadCount.error(e, action.request)))
break
}

return next(action)
}
16 changes: 15 additions & 1 deletion src/reducers/__tests__/index_spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { createStore } from 'redux'

import { reducers } from '../index'
import { incrementCounter } from '../../actions'
import {
incrementCounter,
loadCount,
} from '../../actions'

describe('reducers/counter', () => {
it('starts at 0', () => {
Expand All @@ -19,4 +22,15 @@ describe('reducers/counter', () => {
})
store.dispatch(incrementCounter(3))
})

it('restores state', (done) => {
const store = createStore(reducers)
store.subscribe(() => {
const { counter } = store.getState()
expect(counter.value).toEqual(14)
done()
})
store.dispatch(loadCount.success({ value: 14 }))
})

})
11 changes: 7 additions & 4 deletions src/reducers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,20 @@ const initialState: Store.Counter = {
}

function counter (state: Store.Counter = initialState, action: Action): Store.Counter {
const { value } = state
switch (action.type) {
case 'INCREMENT_COUNTER':
const { delta } = action
return { value: value + delta }
return { value: state.value + delta }

case 'RESET_COUNTER':
return { value: 0 }
}

return state
case 'LOAD_COUNT_SUCCESS':
return { value: action.response.value }

default:
return state
}
}

export const reducers = combineReducers<Store.All>({
Expand Down
4 changes: 3 additions & 1 deletion typings.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
{
"name": "typescript-react-redux",
"globalDependencies": {
"es6-promise": "registry:dt/es6-promise#0.0.0+20160614011821",
"jasmine": "registry:dt/jasmine#2.2.0+20160621224255",
"react": "registry:dt/react#0.14.0+20160805125551",
"react-addons-test-utils": "registry:dt/react-addons-test-utils#0.14.0+20160427035638",
"react-dom": "registry:dt/react-dom#0.14.0+20160412154040"
"react-dom": "registry:dt/react-dom#0.14.0+20160412154040",
"sinon": "registry:dt/sinon#1.16.0+20160924120326"
},
"dependencies": {
"react-redux": "registry:npm/react-redux#4.4.0+20160614222153"
Expand Down

0 comments on commit 92ef3b0

Please sign in to comment.