Redux reducers handle state transitions, but they must be handled synchronously.
But what about Async events like User interactions, ajax calls (with cancellation), web sockets or animations?
See more in my presentation async-reduc-observable
What is an observable ?
- a set of events
- 0, 1 or more values
- over any amount of time
- cancellable and lazy
What is RxJS ? It contains Observables and functions to create and compose Observables, also known as "Lodash for async". RxJS combines the Observer and Iterator patterns, functional programming and collections in an ideal way to manage sequences of events.
of('hello')
from ([1, 2, 3, 4])
interval(1000)
ajax('http://example.com')
webSocket('ws://echo.websocket.com')
- many more
myObservable.subscribe(
value => console.log('next', value),
err => console.roor('error', err),
() => console.info('complete!')
);
An Epic is the core primitive of redux-observable.
It is a function that takes a stream of all actions dispatched and returns a stream of new actions to dispatch.
To prepare next step, we have to change the PLAY action :
actions.ts
export const PLAY = 'PLAY';
export const PLAYED = 'PLAYED';
export const PAUSED = 'PAUSED';
Now, in the reducer, PLAY becomes PLAYED. (PLAY will be used for the loop)
export default (state: MainState = initialState, action: Action) => {
switch (action.type) {
case PLAYED: {
const finalState = play(state);
return { ...finalState };
}
default:
return state;
}
};
First, we have to install packages:
yarn add rxjs redux-observable
Types are included in each package. We don't have to add any @types/rxjs
or @types/redux-observable
Let's code our first test: epic.spec.ts
configure({ adapter: new Adapter() });
jest.useFakeTimers();
const epicMiddleware = createEpicMiddleware(epic);
const mockStore = configureStore([epicMiddleware]);
let store: MockStore<{}>;
describe('Epic', () => {
beforeEach(() => {
store = mockStore({});
});
afterEach(() => {
epicMiddleware.replaceEpic(epic);
});
test('Dispatch played when launched', () => {
store.dispatch({ type: PLAY });
jest.runOnlyPendingTimers();
expect(store.getActions()).toEqual([
{ type: PLAY },
{ type: PLAYED }
]);
});
});
That makes an epic like this:
export default (action$: ActionsObservable<Action>) =>
action$.ofType(PLAY)
.switchMap(() =>
Observable.interval(50)
.mapTo({ type: PLAYED })
);
A
switchMap
is a comination of a switchAll and a map.
switchAll
subscribes and produces values only from the most recent inner sequence ignoring previous streams.In the diagram below you can see the
H
higher-order stream that produces two inner streamsA
andB
. TheswitchAll
operator takes values from the A stream first and then from the streamB
and passes them through the resulting sequence.Here is the code example that demonstrates the setup shown by the above diagram:
const a = stream(‘a’, 200, 3); const b = stream(‘b’, 200, 3); const h = interval(100).pipe(take(2), map(i => [a, b][i])); h.pipe(switchAll()).subscribe(fullObserver(‘switchAll’));If we use directly switchMap :
const a = stream(‘a’, 200, 3); const b = stream(‘b’, 200, 3); const h = interval(100).pipe(take(2), switchMap(i => [a, b][i])); h.subscribe(fullObserver(‘switchAll’));for more informations, please refer to the Max NgWizard K's post on medium
A second test:
test('Dispatch cancelled when paused', () => {
store.dispatch({ type: PLAY });
jest.runOnlyPendingTimers();
store.dispatch({ type: PAUSED });
jest.runOnlyPendingTimers();
expect(store.getActions()).toEqual([
{ type: PLAY },
{ type: PLAYED },
{ type: PLAYED },
{ type: PAUSED }
]);
});
Our new epic:
export default (action$: ActionsObservable<Action>) =>
action$.ofType(PLAY)
.switchMap(() =>
Observable.interval(50)
.takeUntil(action$.ofType(PAUSED))
.mapTo({ type: PLAYED })
);
We have to change our store index.ts:
import { createStore, applyMiddleware } from 'redux';
import { createEpicMiddleware } from 'redux-observable';
import reducer from './reducer';
import { compose } from 'recompose';
import epic from './epic';
// tslint:disable-next-line:no-any
const composeEnhancers =
// tslint:disable-next-line:no-any
(window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const epicMiddleware = createEpicMiddleware(epic, {
dependencies: { }
});
export const configureStore = () => (
createStore(
reducer,
composeEnhancers(applyMiddleware(epicMiddleware))
)
);
export {MainState, Ant} from './reducer';
Now, we are using compose to enhance previous configuration with epicMiddleWare (redux-observable).
Our mapDispatchToProps doesn't need interval anymore. we will use dispatch to call redux an epic middleware :
const mapDispatchToProps: MapDispatchToProps<AppEventProps, AppProps> = (dispatch, ownProps) => ({
onPlay: () => {
dispatch({ type: PLAY } as Action);
},
onPause: () => {
dispatch({ type: PAUSED } as Action);
}
});
But with this our test won't work, we make one too many dispatch call when the button is clicked:
test('Pause button stop dispatchs', async () => {
// tslint:disable-next-line:no-any
(store.dispatch as any).mockClear();
const wrapper =
mount(<Provider store={store}><MemoryRouter initialEntries={['/']}><App /></MemoryRouter></Provider>);
await wrapper.find(AvPlayArrow).simulate('click');
jest.runOnlyPendingTimers();
expect(store.dispatch).toHaveBeenCalled();
await wrapper.find(AvPause).simulate('click');
jest.runOnlyPendingTimers();
expect(store.dispatch).toHaveBeenCalledTimes(2);
});
test('stop stopped should not make exception', async () => {
// tslint:disable-next-line:no-any
(store.dispatch as any).mockClear();
const wrapper =
mount(<Provider store={store}><MemoryRouter initialEntries={['/']}><App /></MemoryRouter></Provider>);
await wrapper.find(AvPause).simulate('click');
jest.runOnlyPendingTimers();
expect(store.dispatch).toHaveBeenCalledTimes(1);
});
If you want to begin here, you can download this sources
Please try over 900 movements... Your ant needs a bigger grid. So, now we need a dynamic size for our grid.
If your Ant is on the border of the grid:
- add one line above
- add one line below
- add one column before
- add one column after
- update the Ant coordinates
When you're done, you can go to the next step : Advanced Typescript
5 Steps to reproduce every cycle:
- Add a new test
- Run all tests and verify if the new test fails
- Write code to pass the new test to green
- Run all tests and verify all are green
- Refactor
Before each test, launch a five minutes timer.
- If the code compiles and the tests are green, commit!
- Otherwise, revert!
All of your code must be covered by unit tests.
We'll avoid any
as much as possible (implicit or not).