State,Side Effects,and ReduxOh my!Jeremy Fairbank@elpapapollo / jfairbank
View Slide
sigient.com
State
ControllerViewModelModelModelModelModelViewViewViewViewMVC
ViewModelModelModelModelModelViewModelViewModelViewModelViewModelModelTwo-way Data Binding
Disorganized Changes{...}
?
ReduxUnidirectionalOrganized changesRead-only state
ReducerViewStateActions
ReducerViewReact,Angular,Vue,Vanilla JS,etc.StateActions
ReducerViewStateActionsDispatch
ReducerViewStateActionsRepeat
StoreReducerStateView
StoreReducerStateViewAction
let state = 0;incrementBtn.addEventListener('click', () => {state += 1;});decrementBtn.addEventListener('click', () => {state -= 1;});
let state = 0;state += 1;state += 1;state -= 1;console.log(state); // 1
let state = 0;state += 1;state += 1;state -= 1;console.log(state); // 1{ type: 'INCREMENT' };{ type: 'INCREMENT' };{ type: 'DECREMENT' };
let state = 0;state += 1;state += 1;state -= 1;console.log(state); // 1{ type: 'INCREMENT' };{ type: 'INCREMENT' };{ type: 'DECREMENT' };Action
Reducer
let state = 0;state = reducer(state, { type: 'INCREMENT' });state = reducer(state, { type: 'INCREMENT' });state = reducer(state, { type: 'DECREMENT' });console.log(state); // 1
let state = 0;state = reducer(state, { type: 'INCREMENT' });state = reducer(state, { type: 'INCREMENT' });state = reducer(state, { type: 'DECREMENT' });console.log(state); // 1Action
function reducer(state = 0, action) {switch (action.type) {case 'INCREMENT':return state + 1;case 'DECREMENT':return state - 1;default:return state;}}
Store
import { createStore } from 'redux';const store = createStore(reducer);store.getState(); // 0
store.dispatch({ type: 'INCREMENT' });store.dispatch({ type: 'INCREMENT' });store.dispatch({ type: 'DECREMENT' });store.getState(); // 1
store.subscribe(() => {console.log('state =', store.getState());});store.dispatch({ type: 'INCREMENT' });store.dispatch({ type: 'INCREMENT' });store.dispatch({ type: 'DECREMENT' });// state = 1// state = 2// state = 1
ActionCreators
const increment = () => ({type: 'INCREMENT',});const decrement = () => ({type: 'DECREMENT',});
const unsubscribe = store.subscribe(() => {console.log('state =', store.getState());});store.dispatch(increment());store.dispatch(increment());store.dispatch(decrement());// state = 1// state = 2// state = 1
import { bindActionCreators } from 'redux';const actions = bindActionCreators({ increment, decrement },store.dispatch);actions.increment();actions.increment();actions.decrement();// state = 1// state = 2// state = 1
incrementBtn.addEventListener('click', actions.increment);decrementBtn.addEventListener('click', actions.decrement);store.subscribe(() => {counterElement.innerHTML = store.getState();});incrementBtn.click();incrementBtn.click();decrementBtn.click();console.log(counterElement.innerHTML); // 1
ImmutableObject State
const initialState = {counter: 0,car: {color: 'red',},};
function reducer(state = initialState, action) {switch (action.type) {case 'INCREMENT':return { ...state, counter: state.counter + 1 };case 'DECREMENT':return { ...state, counter: state.counter - 1 };case 'CHANGE_COLOR':return { ...state, car: { color: action.payload } };default:return state;}}
const increment = () => ({type: 'INCREMENT',});const decrement = () => ({type: 'DECREMENT',});const changeColor = color => ({type: 'CHANGE_COLOR',payload: color,});
store.subscribe(() => {console.log('state =', store.getState());});store.dispatch(increment());store.dispatch(changeColor('green'));// state = { counter: 1, car: { color: 'red' } }// state = { counter: 1, car: { color: 'green' } }
Middleware
ReducerViewStateActionsMiddleware
ReducerViewStateActionsMiddlewareIntercept
const logMiddleware = api => next => action => {console.log('dispatch', action);const result = next(action);console.log('state =', api.getState());return result;};
import { applyMiddleware } from 'redux';const store = createStore(reducer,applyMiddleware(logMiddleware));store.dispatch(increment());store.dispatch(changeColor('green'));// dispatch { type: 'INCREMENT' }// state = { counter: 1, car: { color: 'red' } }// dispatch { type: 'CHANGE_COLOR', payload: 'green' }// state = { counter: 1, car: { color: 'green' } }
Side EffectsI/OMutableState
const initialState = {users: [],isFetching: false,};
function reducer(state = initialState, action) {switch (action.type) {case 'REQUEST_USERS':return { ...state, isFetching: true };case 'RECEIVE_USERS':return {...state,isFetching: false,users: action.payload,};default:return state;}}
const requestUsers = () => ({type: 'REQUEST_USERS',});const receiveUsers = users => ({type: 'RECEIVE_USERS',payload: users,});
const thunkMiddleware = api => next => action => {if (typeof action === 'function') {return action(api.dispatch);}return next(action);};const store = createStore(reducer,applyMiddleware(thunkMiddleware));
function fetchUsers() {return dispatch => {dispatch(requestUsers());return axios.get('/users').then(({ data }) => {dispatch(receiveUsers(data));});};}store.dispatch(fetchUsers());
function fetchUsers() {return dispatch => {dispatch(requestUsers());return axios.get('/users').then(({ data }) => {dispatch(receiveUsers(data));});};}store.dispatch(fetchUsers());Action Creator
function fetchUsers() {return dispatch => {dispatch(requestUsers());return axios.get('/users').then(({ data }) => {dispatch(receiveUsers(data));});};}store.dispatch(fetchUsers());Action
ReducerViewStateActionsMiddlewareReduxSaga
Effect Descriptorstake('SOME_ACTION_TYPE')call(someAsyncFunction)put(someAction())fork(someSagaFunction)Just values
Redux SagaSagas
Redux SagaSagasIOAPIsConsoleDBcall, apply
Redux SagaSagasIOAPIsConsoleDBcall, applycall, apply,fork, spawn,join, cancel
Redux SagaSagasIOAPIsConsoleDBcall, applycall, apply,fork, spawn,join, cancelReduxStoreput, select, take
GeneratorFunctions
function* myGenerator() {yield 'hello';const value = yield 'world';return value * 2;}
function* myGenerator() {yield 'hello';const value = yield 'world';return value * 2;}const iterator = myGenerator();iterator.next();// { value: 'hello', done: false }
function* myGenerator() {yield 'hello';const value = yield 'world';return value * 2;}iterator.next();// { value: 'world', done: false }
function* myGenerator() {yield 'hello';const value = yield 'world';return value * 2;}iterator.next(21);// { value: 42, done: true }
function* myGenerator() {yield 'hello';const value = yield 'world';return value * 2;}iterator.next();// { value: undefined, done: true }
import { call, put, take } from 'redux-saga/effects';function* fetchUsersSaga() {yield take('REQUEST_USERS');const response = yield call(axios.get, '/users');yield put(receiveUsers(response.data));}store.dispatch(requestUsers());
SagaRedux Sagayield take('REQUEST_USERS');1. Wait for action
SagaRedux SagaViewMiddlewareyield take('REQUEST_USERS');2. Receive action
SagaRedux Sagayield call(axios.get, '/users');3. Call API
const response =yield call(axios.get, '/users');SagaRedux Saga4. Receive response
SagaRedux SagaReducerReduxStoreyield put(receiveUsers(response.data));5. Dispatch (put) action
Set Up
import createSagaMiddleware from 'redux-saga';const sagaMiddleware = createSagaMiddleware();const store = createStore(reducer,applyMiddleware(sagaMiddleware));sagaMiddleware.run(fetchUsersSaga);
Why Sagas?
Business Logic Spread OutComponentThunkServiceServiceComponentReducer
Business Logic Spread OutComponentThunkServiceServiceComponent×Reducer
Business Logic ConsolidatedSaga Saga SagaSagaSagaSaga✓
Testing
function* fetchUsersSaga() {yield take('REQUEST_USERS');const response = yield call(axios.get, '/users');yield put(receiveUsers(response.data));}const saga = fetchUsersSaga();it('waits for REQUEST_USERS', () => {const actual = saga.next().value;const expected = take('REQUEST_USERS');expect(actual).toEqual(expected);});
function* fetchUsersSaga() {yield take('REQUEST_USERS');const response = yield call(axios.get, '/users');yield put(receiveUsers(response.data));}// ...it('fetches users', () => {const actual = saga.next().value;const expected = call(axios.get, '/users');expect(actual).toEqual(expected);});
function* fetchUsersSaga() {yield take('REQUEST_USERS');const response = yield call(axios.get, '/users');yield put(receiveUsers(response.data));}it('dispatches the users', () => {const users = [{ name: 'Bob' }, { name: 'Alice' }];const response = { data: users };const actual = saga.next(response).value;const expected = put(receiveUsers(users));expect(actual).toEqual(expected);});
ErrorHandling
const initialState = {users: [],isFetching: false,error: null,};
const failUsers = error => ({type: 'FAIL_USERS',error,});
function reducer(state = initialState, action) {switch (action.type) {// ...case 'FAIL_USERS':return { ...state, error: action.error };// ...}}
function fetchUsers() {return dispatch => {dispatch(requestUsers());return axios.get('/users').then(({ data }) => {dispatch(receiveUsers(data));}).catch((e) => {dispatch(failUsers(e));});};}
function fetchUsers() {return dispatch => {dispatch(requestUsers());return axios.get('/users').then(({ data }) => {dispatch(receiveUsers(data));}).catch((e) => {dispatch(failUsers(e));});};}Swallowedwithout catch
function* fetchUsersSaga() {yield take('REQUEST_USERS');const response = yield call(axios.get, '/users');yield put(receiveUsers(response.data));}
function* fetchUsersSaga() {yield take('REQUEST_USERS');const response = yield call(axios.get, '/users');yield put(receiveUsers(response.data));}No swallowed errors
function* fetchUsersSaga() {try {yield take('REQUEST_USERS');const response = yield call(axios.get, '/users');yield put(receiveUsers(response.data));} catch (e) {yield put(failUsers(e));}}
MultipleRequests
function* fetchUsersSaga() {yield take('REQUEST_USERS');const responses = yield [call(axios.get, '/users/1'),call(axios.get, '/users/2'),call(axios.get, '/users/3'),];const users = responses.map(resp => resp.data);yield put(receiveUsers(users));}
Sagayield [call(axios.get, '/users/1'),call(axios.get, '/users/2'),call(axios.get, '/users/3'),];// ...
Sagayield [call(axios.get, '/users/1'),call(axios.get, '/users/2'),call(axios.get, '/users/3'),];// ...123
Sagayield [call(axios.get, '/users/1'),call(axios.get, '/users/2'),call(axios.get, '/users/3'),];// ...13
Sagayield [call(axios.get, '/users/1'),call(axios.get, '/users/2'),call(axios.get, '/users/3'),];// ...3
Saga Helpers
function* fetchUsersSaga() {const response = yield call(axios.get, '/users');yield put(receiveUsers(response.data));}function* mainSaga() {while (true) {yield take('REQUEST_USERS');yield call(fetchUsersSaga);}}
import { takeEvery } from 'redux-saga/effects';function* fetchUsersSaga() {const response = yield call(axios.get, '/users');yield put(receiveUsers(response.data));}function* mainSaga() {yield takeEvery('REQUEST_USERS', fetchUsersSaga);}
mainSagafetchUsersSagayield takeEvery('REQUEST_USERS',fetchUsersSaga);
ViewMiddlewarestore.dispatch(requestUsers());mainSagafetchUsersSaga
mainSagafetchUsersSagafork
mainSagafetchUsersSagayield call(axios.get,'/users');
mainSagafetchUsersSagaconst response =yield call(axios.get,'/users');
mainSagafetchUsersSagayield put(receiveUsers(response.data));ReducerReduxStore
mainSagafetchUsersSagayield takeEvery('REQUEST_USERS',fetchUsersSaga);And repeat…
Data Race IssuesUse alternative takeLatest
Forking
function* mainSaga() {yield take('REQUEST_USERS');yield call(fetchUsersSaga);yield take('LOGOUT');yield call(logoutSaga);}store.dispatch(requestUsers());store.dispatch(logout());
function* mainSaga() {yield take('REQUEST_USERS');yield call(fetchUsersSaga);yield take('LOGOUT');yield call(logoutSaga);}store.dispatch(requestUsers());store.dispatch(logout());Missed
function* mainSaga() {yield take('REQUEST_USERS');yield call(fetchUsersSaga);yield take('LOGOUT');yield call(logoutSaga);}store.dispatch(requestUsers());store.dispatch(logout());MissedNeverreached
mainSagayield take('REQUEST_USERS');
ViewMiddlewarestore.dispatch(requestUsers());mainSaga
mainSagafetchUsersSagacall
mainSagafetchUsersSagacallyield call(fetchUsersSaga);
mainSagafetchUsersSagacallViewMiddlewarestore.dispatch(logout());×
import { fork } from 'redux-saga/effects';function* fetchUsersSaga() {yield take('REQUEST_USERS');const response = yield call(axios.get, '/users');yield put(receiveUsers(response.data));}function* mainSaga() {yield fork(fetchUsersSaga);yield take('LOGOUT');yield call(logoutSaga);}
import { fork } from 'redux-saga/effects';function* fetchUsersSaga() {yield take('REQUEST_USERS');const response = yield call(axios.get, '/users');yield put(receiveUsers(response.data));}function* mainSaga() {yield fork(fetchUsersSaga);yield take('LOGOUT');yield call(logoutSaga);}Nonblocking
import { fork } from 'redux-saga/effects';function* fetchUsersSaga() {yield take('REQUEST_USERS');const response = yield call(axios.get, '/users');yield put(receiveUsers(response.data));}function* mainSaga() {yield fork(fetchUsersSaga);yield take('LOGOUT');yield call(logoutSaga);}Won’t miss now
mainSaga
mainSagafetchUsersSagaforkyield fork(fetchUsersSaga);
mainSagafetchUsersSagayield take('LOGOUT');
mainSagafetchUsersSagaViewMiddlewarestore.dispatch(logout());
Miscellaneous Patterns
Miscellaneous Patterns• Autosaving background tasks
Miscellaneous Patterns• Autosaving background tasks• Races for timeouts
Miscellaneous Patterns• Autosaving background tasks• Races for timeouts• Task cancellation
Miscellaneous Patterns• Autosaving background tasks• Races for timeouts• Task cancellation• Throttling and debouncing
Miscellaneous Patterns• Autosaving background tasks• Races for timeouts• Task cancellation• Throttling and debouncing• Hook up to other IO sources
Resources•Redux• redux.js.org• egghead.io/courses/getting-started-with-redux•React• github.com/reactjs/react-redux•Redux Saga• redux-saga.github.io/redux-saga• Testing• github.com/jfairbank/redux-saga-test-plan
Thanks!Code:github.com/jfairbank/state-side-effects-and-reduxSlides:bit.ly/jazzcon-reduxJeremy Fairbank@elpapapollo / jfairbank