-
-
Save nanha/1e6d56735d122971108fe3e2acc2977e to your computer and use it in GitHub Desktop.
Counter app example - TypeScript + React + Redux + redux-saga
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as React from 'react'; | |
import { Store, createStore, combineReducers, applyMiddleware } from 'redux'; | |
import createSagaMiddleware from 'redux-saga'; | |
import * as ReactDOM from 'react-dom'; | |
import { Provider } from 'react-redux'; | |
import { reducer as counter, rootSaga } from './counter'; | |
import Counter from './Counter'; | |
const sagaMiddleware = createSagaMiddleware(); | |
const store: Store = createStore( | |
combineReducers({ | |
counter, | |
}), | |
applyMiddleware(sagaMiddleware) | |
); | |
sagaMiddleware.run(rootSaga); | |
interface IProps { | |
compiler: string; | |
framework: string; | |
} | |
const App: React.SFC<IProps> = () => ( | |
<Provider store={store}> | |
<Counter title="Counter" /> | |
</Provider> | |
); | |
ReactDOM.render( | |
<App compiler="TypeScript" framework="React" />, | |
document.getElementById('app') as HTMLElement | |
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { actions, asyncActions, reducer } from './counter'; | |
describe('counter actions', () => { | |
it('increment should create counter/INCREMENT action', () => { | |
expect(actions.increment()).toEqual({ | |
type: 'counter/INCREMENT', | |
}); | |
}); | |
it('decrement should create counter/DECREMENT action', () => { | |
expect(actions.decrement()).toEqual({ | |
type: 'counter/DECREMENT', | |
}); | |
}); | |
}); | |
describe('counter async actions', () => { | |
it('asyncActions.incrementStarted should create counter/INCREMENT_ASYNC_STARTED action', () => { | |
expect(asyncActions.incrementStarted()).toEqual({ | |
type: 'counter/INCREMENT_ASYNC_STARTED', | |
}); | |
}); | |
it('asyncActions.incrementDone should create counter/INCREMENT_ASYNC_DONE action', () => { | |
expect(asyncActions.incrementDone()).toEqual({ | |
type: 'counter/INCREMENT_ASYNC_DONE', | |
}); | |
}); | |
it('asyncActions.incrementFailed should create counter/INCREMENT_ASYNC_FAILED action', () => { | |
expect(asyncActions.incrementFailed()).toEqual({ | |
type: 'counter/INCREMENT_ASYNC_FAILED', | |
}); | |
}); | |
it('asyncActions.decrementStarted should create counter/DECREMENT_ASYNC_STARTED action', () => { | |
expect(asyncActions.decrementStarted()).toEqual({ | |
type: 'counter/DECREMENT_ASYNC_STARTED', | |
}); | |
}); | |
it('asyncActions.decrementDone should create counter/DECREMENT_ASYNC_DONE action', () => { | |
expect(asyncActions.decrementDone()).toEqual({ | |
type: 'counter/DECREMENT_ASYNC_DONE', | |
}); | |
}); | |
it('asyncActions.decrementFailed should create counter/DECREMENT_ASYNC_FAILED action', () => { | |
expect(asyncActions.decrementFailed()).toEqual({ | |
type: 'counter/DECREMENT_ASYNC_FAILED', | |
}); | |
}); | |
}); | |
describe('counter reducer', () => { | |
it('should handle counter/INCREMENT_ASYNC_STARTED', () => { | |
expect( | |
reducer( | |
{ | |
isLoading: false, | |
errorMessage: 'Request failed', | |
count: 0, | |
}, | |
asyncActions.incrementStarted() | |
) | |
).toEqual({ | |
isLoading: true, | |
errorMessage: '', | |
count: 0, | |
}); | |
}); | |
it('should handle counter/INCREMENT_ASYNC_DONE', () => { | |
expect( | |
reducer( | |
{ | |
isLoading: true, | |
errorMessage: '', | |
count: 0, | |
}, | |
asyncActions.incrementDone() | |
) | |
).toEqual({ | |
isLoading: false, | |
errorMessage: '', | |
count: 1, | |
}); | |
}); | |
it('should handle counter/INCREMENT_ASYNC_FAILED', () => { | |
expect( | |
reducer( | |
{ | |
isLoading: true, | |
errorMessage: '', | |
count: 1, | |
}, | |
asyncActions.incrementFailed() | |
) | |
).toEqual({ | |
isLoading: false, | |
errorMessage: 'Request failed', | |
count: 1, | |
}); | |
}); | |
it('should handle counter/DECREMENT_ASYNC_STARTED', () => { | |
expect( | |
reducer( | |
{ | |
isLoading: false, | |
errorMessage: 'Request failed', | |
count: 1, | |
}, | |
asyncActions.decrementStarted() | |
) | |
).toEqual({ | |
isLoading: true, | |
errorMessage: '', | |
count: 1, | |
}); | |
}); | |
it('should handle counter/DECREMENT_ASYNC_DONE', () => { | |
expect( | |
reducer( | |
{ | |
isLoading: true, | |
errorMessage: '', | |
count: 1, | |
}, | |
asyncActions.decrementDone() | |
) | |
).toEqual({ | |
isLoading: false, | |
errorMessage: '', | |
count: 0, | |
}); | |
}); | |
it('should handle counter/DECREMENT_ASYNC_FAILED', () => { | |
expect( | |
reducer( | |
{ | |
isLoading: true, | |
errorMessage: '', | |
count: 0, | |
}, | |
asyncActions.decrementFailed() | |
) | |
).toEqual({ | |
isLoading: false, | |
errorMessage: 'Request failed', | |
count: 0, | |
}); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Action as AnyAction, Reducer } from 'redux'; | |
import { SagaIterator, delay } from 'redux-saga'; | |
import { takeEvery, call, put, cancelled } from 'redux-saga/effects'; | |
export type Meta = null | { [key: string]: any }; | |
export interface FSA<Type extends string, Payload = null> extends AnyAction { | |
type: Type; | |
payload?: Payload; | |
error?: boolean; | |
meta?: Meta; | |
} | |
enum ActionType { | |
increment = 'counter/INCREMENT', | |
decrement = 'counter/DECREMENT', | |
} | |
enum AsyncActionType { | |
incrementStarted = 'counter/INCREMENT_ASYNC_STARTED', | |
incrementDone = 'counter/INCREMENT_ASYNC_DONE', | |
incrementFailed = 'counter/INCREMENT_ASYNC_FAILED', | |
decrementStarted = 'counter/DECREMENT_ASYNC_STARTED', | |
decrementDone = 'counter/DECREMENT_ASYNC_DONE', | |
decrementFailed = 'counter/DECREMENT_ASYNC_FAILED', | |
} | |
export type Action = FSA<ActionType.increment> | FSA<ActionType.decrement>; | |
export type AsyncAction = | |
| FSA<AsyncActionType.incrementStarted> | |
| FSA<AsyncActionType.incrementDone> | |
| FSA<AsyncActionType.incrementFailed> | |
| FSA<AsyncActionType.decrementStarted> | |
| FSA<AsyncActionType.decrementDone> | |
| FSA<AsyncActionType.decrementFailed>; | |
const increment = (): Action => { | |
return { type: ActionType.increment }; | |
}; | |
const decrement = (): Action => { | |
return { type: ActionType.decrement }; | |
}; | |
const incrementStarted = (): AsyncAction => { | |
return { type: AsyncActionType.incrementStarted }; | |
}; | |
const incrementDone = (): AsyncAction => { | |
return { type: AsyncActionType.incrementDone }; | |
}; | |
const incrementFailed = (): AsyncAction => { | |
return { type: AsyncActionType.incrementFailed }; | |
}; | |
const decrementStarted = (): AsyncAction => { | |
return { type: AsyncActionType.decrementStarted }; | |
}; | |
const decrementDone = (): AsyncAction => { | |
return { type: AsyncActionType.decrementDone }; | |
}; | |
const decrementFailed = (): AsyncAction => { | |
return { type: AsyncActionType.decrementFailed }; | |
}; | |
export const actions = { increment, decrement }; | |
export const asyncActions = { | |
incrementStarted, | |
incrementDone, | |
incrementFailed, | |
decrementStarted, | |
decrementDone, | |
decrementFailed, | |
}; | |
export function* incrementAsyncWorker(): SagaIterator { | |
yield put(asyncActions.incrementStarted()); | |
try { | |
yield call(delay, 1000); | |
if (Math.random() > 0.8) { | |
throw new Error(); | |
} | |
yield put(asyncActions.incrementDone()); | |
} catch { | |
yield put(asyncActions.incrementFailed()); | |
} finally { | |
if (yield cancelled()) { | |
yield put(asyncActions.incrementFailed()); | |
} | |
} | |
} | |
export function* decrementAsyncWorker(): SagaIterator { | |
yield put(asyncActions.decrementStarted()); | |
try { | |
yield call(delay, 1000); | |
if (Math.random() > 0.8) { | |
throw new Error(); | |
} | |
yield put(asyncActions.decrementDone()); | |
} catch { | |
yield put(asyncActions.decrementFailed()); | |
} finally { | |
if (yield cancelled()) { | |
yield put(asyncActions.decrementFailed()); | |
} | |
} | |
} | |
export function* rootSaga(): SagaIterator { | |
yield takeEvery(ActionType.increment, incrementAsyncWorker); | |
yield takeEvery(ActionType.decrement, decrementAsyncWorker); | |
} | |
export interface CounterState { | |
readonly isLoading: boolean; | |
readonly errorMessage: string; | |
readonly count: number; | |
} | |
export interface State { | |
readonly counter: CounterState; | |
} | |
const initialState: CounterState = { | |
isLoading: false, | |
errorMessage: '', | |
count: 0, | |
}; | |
export const reducer: Reducer<State['counter'], Action | AsyncAction> = ( | |
state = initialState, | |
action | |
) => { | |
switch (action.type) { | |
case AsyncActionType.incrementStarted: | |
case AsyncActionType.decrementStarted: | |
return { | |
...state, | |
isLoading: true, | |
errorMessage: '', | |
}; | |
case AsyncActionType.incrementFailed: | |
case AsyncActionType.decrementFailed: | |
return { | |
...state, | |
isLoading: false, | |
errorMessage: 'Request failed', | |
}; | |
case AsyncActionType.incrementDone: | |
return { | |
...state, | |
isLoading: false, | |
count: state.count + 1, | |
}; | |
case AsyncActionType.decrementDone: | |
return { | |
...state, | |
isLoading: false, | |
count: state.count - 1, | |
}; | |
default: | |
return state; | |
} | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as React from 'react'; | |
import { Dispatch, bindActionCreators } from 'redux'; | |
import { connect } from 'react-redux'; | |
import { Action, State, CounterState, actions } from './counter'; | |
export interface IProps { | |
readonly title: string; | |
} | |
export interface IStateProps { | |
counter: CounterState; | |
} | |
export interface IDispatchProps extends ReturnType<typeof mapDispatchToProps> {} | |
const Counter: React.SFC<IProps & IStateProps & IDispatchProps> = ({ | |
title, | |
counter, | |
increment, | |
decrement, | |
}): JSX.Element => { | |
const handleIncrement = (e: React.MouseEvent<HTMLButtonElement>) => | |
increment(); | |
const handleDecrement = (e: React.MouseEvent<HTMLButtonElement>) => | |
decrement(); | |
return ( | |
<div> | |
<h1>{title}</h1> | |
<p>Clicked: {counter.count} times</p> | |
<button disabled={counter.isLoading} onClick={handleIncrement}> | |
+ | |
</button> | |
<button disabled={counter.isLoading} onClick={handleDecrement}> | |
- | |
</button> | |
{counter.errorMessage && <p>{counter.errorMessage}</p>} | |
</div> | |
); | |
}; | |
const mapStateToProps = (state: State): IStateProps => ({ | |
counter: state.counter, | |
}); | |
const mapDispatchToProps = (dispatch: Dispatch<Action>) => | |
bindActionCreators(actions, dispatch); | |
export default connect( | |
mapStateToProps, | |
mapDispatchToProps | |
)(Counter); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment