Skip to content

Instantly share code, notes, and snippets.

@nanha
Forked from kotarella1110/App.tsx
Created November 20, 2020 09:42
Show Gist options
  • Save nanha/1e6d56735d122971108fe3e2acc2977e to your computer and use it in GitHub Desktop.
Save nanha/1e6d56735d122971108fe3e2acc2977e to your computer and use it in GitHub Desktop.
Counter app example - TypeScript + React + Redux + redux-saga
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
);
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,
});
});
});
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;
}
};
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