Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Scenic City Summit 2017: Get Started with Redux

Scenic City Summit 2017: Get Started with Redux

Jeremy Fairbank

July 28, 2017
Tweet

More Decks by Jeremy Fairbank

Other Decks in Programming

Transcript

  1. <div data-id="42" data-name="Tucker"> ... </div> var $el = $('[data-id="42"]'); var

    currentName = $el.data('name'); $el.data('name', currentName.toUpperCase()); Data in the DOM
  2. View Model Model Model Model Model View Model View Model

    View Model View Model Model Two-way Data Binding
  3. Predictable Old State REDUCER New State • State changes in

    one place • State changes in well-defined ways
  4. Predictable Old State REDUCER New State • State changes in

    one place • State changes in well-defined ways • Changes are serialized
  5. incrementBtn.addEventListener('click', () => { counter.innerText = Number(counter.innerText) + 1; });

    decrementBtn.addEventListener('click', () => { counter.innerText = Number(counter.innerText) - 1; });
  6. let state = 0; function render() { counter.innerText = state;

    } incrementBtn.addEventListener('click', () => { state += 1; render(); }); decrementBtn.addEventListener('click', () => { state -= 1; render(); });
  7. let state = 0; function render() { counter.innerText = state;

    } incrementBtn.addEventListener('click', () => { state += 1; render(); }); decrementBtn.addEventListener('click', () => { state -= 1; render(); });
  8. let state = 0; function render() { counter.innerText = state;

    } incrementBtn.addEventListener('click', () => { state += 1; render(); }); decrementBtn.addEventListener('click', () => { state -= 1; render(); });
  9. let state = 0; function render() { counter.innerText = state;

    } incrementBtn.addEventListener('click', () => { state += 1; render(); }); decrementBtn.addEventListener('click', () => { state -= 1; render(); });
  10. state += 1 state -= 1 { type: 'INCREMENT' }

    { type: 'DECREMENT' } Tokens/descriptors that describe a type of change. Actions
  11. function reducer(state = 0, action) { switch (action.type) { case

    'INCREMENT': return state + 1; case 'DECREMENT': return state - 1; default: return state; } } Returns new state from current state and action. Reducer
  12. function reducer(state = 0, action) { switch (action.type) { case

    'INCREMENT': return state + 1; case 'DECREMENT': return state - 1; default: return state; } } Returns new state from current state and action. Reducer
  13. function reducer(state = 0, action) { switch (action.type) { case

    'INCREMENT': return state + 1; case 'DECREMENT': return state - 1; default: return state; } } Returns new state from current state and action. Reducer
  14. function reducer(state = 0, action) { switch (action.type) { case

    'INCREMENT': return state + 1; case 'DECREMENT': return state - 1; default: return state; } } Returns new state from current state and action. Reducer
  15. function reducer(state = 0, action) { switch (action.type) { case

    'INCREMENT': return state + 1; case 'DECREMENT': return state - 1; default: return state; } } Returns new state from current state and action. Reducer
  16. function reducer(state = 0, action) { switch (action.type) { case

    'INCREMENT': return state + 1; case 'DECREMENT': return state - 1; default: return state; } } Returns new state from current state and action. Reducer
  17. function reducer(state = 0, action) { switch (action.type) { case

    'INCREMENT': return state + 1; case 'DECREMENT': return state - 1; default: return state; } } Returns new state from current state and action. Reducer
  18. Updating state is just a function call now let state

    = 0; state = reducer(state, { type: 'INCREMENT' }); // 1 state = reducer(state, { type: 'INCREMENT' }); // 2 state = reducer(state, { type: 'DECREMENT' }); // 1 state = reducer(state, { type: 'ADD_2' }); // 1 console.log(state); // 1
  19. Updating state is just a function call now let state

    = 0; state = reducer(state, { type: 'INCREMENT' }); // 1 state = reducer(state, { type: 'INCREMENT' }); // 2 state = reducer(state, { type: 'DECREMENT' }); // 1 state = reducer(state, { type: 'ADD_2' }); // 1 console.log(state); // 1 Unhandled types are ignored
  20. function dispatch(action) { state = reducer(state, action); render(); } incrementBtn.addEventListener('click',

    () => { dispatch({ type: 'INCREMENT' }); }); decrementBtn.addEventListener('click', () => { dispatch({ type: 'DECREMENT' }); }); Decouple event from state change.
  21. function dispatch(action) { state = reducer(state, action); render(); } incrementBtn.addEventListener('click',

    () => { dispatch({ type: 'INCREMENT' }); }); decrementBtn.addEventListener('click', () => { dispatch({ type: 'DECREMENT' }); }); Decouple event from state change.
  22. function dispatch(action) { state = reducer(state, action); render(); } incrementBtn.addEventListener('click',

    () => { dispatch({ type: 'INCREMENT' }); }); decrementBtn.addEventListener('click', () => { dispatch({ type: 'DECREMENT' }); }); Decouple event from state change.
  23. function dispatch(action) { state = reducer(state, action); render(); } incrementBtn.addEventListener('click',

    () => { dispatch({ type: 'INCREMENT' }); }); decrementBtn.addEventListener('click', () => { dispatch({ type: 'DECREMENT' }); }); Decouple event from state change.
  24. function dispatch(action) { state = reducer(state, action); render(); } incrementBtn.addEventListener('click',

    () => { dispatch({ type: 'INCREMENT' }); }); decrementBtn.addEventListener('click', () => { dispatch({ type: 'DECREMENT' }); }); Decouple event from state change.
  25. incrementBtn.addEventListener('click', () => { store.dispatch({ type: 'INCREMENT' }); }); decrementBtn.addEventListener('click',

    () => { store.dispatch({ type: 'DECREMENT' }); }); store.subscribe(() => { counter.innerText = store.getState(); });
  26. incrementBtn.addEventListener('click', () => { store.dispatch({ type: 'INCREMENT' }); }); decrementBtn.addEventListener('click',

    () => { store.dispatch({ type: 'DECREMENT' }); }); store.subscribe(() => { counter.innerText = store.getState(); });
  27. incrementBtn.addEventListener('click', () => { store.dispatch({ type: 'INCREMENT' }); }); decrementBtn.addEventListener('click',

    () => { store.dispatch({ type: 'DECREMENT' }); }); store.subscribe(() => { counter.innerText = store.getState(); });
  28. incrementBtn.addEventListener('click', () => { store.dispatch({ type: 'INCREMENT' }); }); decrementBtn.addEventListener('click',

    () => { store.dispatch({ type: 'DECREMENT' }); }); Problems: • Creating actions are cumbersome • Requires direct access to store
  29. const increment = () => ({ type: 'INCREMENT', }); const

    decrement = () => ({ type: 'DECREMENT', }); Reusable functions that create actions Action Creators
  30. const increment = () => ({ type: 'INCREMENT', }); const

    decrement = () => ({ type: 'DECREMENT', }); Reusable functions that create actions Action Creators
  31. const increment = () => ({ type: 'INCREMENT', }); const

    decrement = () => ({ type: 'DECREMENT', }); Reusable functions that create actions Action Creators
  32. incrementBtn.addEventListener('click', () => { store.dispatch(increment()); }); decrementBtn.addEventListener('click', () => {

    store.dispatch(decrement()); }); Problems: • Creating actions are cumbersome • Requires direct access to store
  33. const actions = { increment: () => store.dispatch(increment()), decrement: ()

    => store.dispatch(decrement()), }; Automatically dispatch when invoked Bound Action Creators
  34. const actions = { increment: () => store.dispatch(increment()), decrement: ()

    => store.dispatch(decrement()), }; Automatically dispatch when invoked Bound Action Creators Manually created
  35. const actions = { increment: () => store.dispatch(increment()), decrement: ()

    => store.dispatch(decrement()), }; Automatically dispatch when invoked Bound Action Creators
  36. import { bindActionCreators } from 'redux'; const actions = bindActionCreators(

    { increment, decrement }, store.dispatch ); Automatically dispatch when invoked Bound Action Creators
  37. import { bindActionCreators } from 'redux'; const actions = bindActionCreators(

    { increment, decrement }, store.dispatch ); Automatically dispatch when invoked Bound Action Creators
  38. import { bindActionCreators } from 'redux'; const actions = bindActionCreators(

    { increment, decrement }, store.dispatch ); Automatically dispatch when invoked Bound Action Creators Automatically created
  39. Problems: • Creating actions are cumbersome • Requires direct access

    to store incrementBtn.addEventListener('click', actions.increment); decrementBtn.addEventListener('click', actions.decrement);
  40. import React from 'react'; import { render } from 'react-dom';

    import { Provider } from 'react-redux'; render(( <Provider store={store}> <MyAppContainer /> </Provider> ), document.getElementById('main'));
  41. import React from 'react'; import { render } from 'react-dom';

    import { Provider } from 'react-redux'; render(( <Provider store={store}> <MyAppContainer /> </Provider> ), document.getElementById('main'));
  42. import React from 'react'; import { render } from 'react-dom';

    import { Provider } from 'react-redux'; render(( <Provider store={store}> <MyAppContainer /> </Provider> ), document.getElementById('main'));
  43. import { connect } from 'react-redux'; const mapStateToProps = counter

    => ({ counter }); function mapDispatchToProps(dispatch) { return bindActionCreators({ onIncrement: increment, onDecrement: decrement, }, dispatch); } const MyAppContainer = connect( mapStateToProps, mapDispatchToProps )(MyApp);
  44. import { connect } from 'react-redux'; const mapStateToProps = counter

    => ({ counter }); function mapDispatchToProps(dispatch) { return bindActionCreators({ onIncrement: increment, onDecrement: decrement, }, dispatch); } const MyAppContainer = connect( mapStateToProps, mapDispatchToProps )(MyApp);
  45. import { connect } from 'react-redux'; const mapStateToProps = counter

    => ({ counter }); function mapDispatchToProps(dispatch) { return bindActionCreators({ onIncrement: increment, onDecrement: decrement, }, dispatch); } const MyAppContainer = connect( mapStateToProps, mapDispatchToProps )(MyApp);
  46. import { connect } from 'react-redux'; const mapStateToProps = counter

    => ({ counter }); function mapDispatchToProps(dispatch) { return bindActionCreators({ onIncrement: increment, onDecrement: decrement, }, dispatch); } const MyAppContainer = connect( mapStateToProps, mapDispatchToProps )(MyApp);
  47. import { connect } from 'react-redux'; const mapStateToProps = counter

    => ({ counter }); function mapDispatchToProps(dispatch) { return bindActionCreators({ onIncrement: increment, onDecrement: decrement, }, dispatch); } const MyAppContainer = connect( mapStateToProps, mapDispatchToProps )(MyApp);
  48. const MyApp = (props) => ( <div> <button onClick={props.onDecrement}> -

    </button> <div>{props.counter}</div> <button onClick={props.onIncrement}> + </button> </div> );
  49. const MyApp = (props) => ( <div> <button onClick={props.onDecrement}> -

    </button> <div>{props.counter}</div> <button onClick={props.onIncrement}> + </button> </div> ); Store state mapStateToProps
  50. const MyApp = (props) => ( <div> <button onClick={props.onDecrement}> -

    </button> <div>{props.counter}</div> <button onClick={props.onIncrement}> + </button> </div> ); Bound action creators mapDispatchToProps
  51. const increment = () => ({ type: 'INCREMENT', }); const

    decrement = () => ({ type: 'DECREMENT', }); const changeColor = color => ({ type: 'CHANGE_COLOR', payload: color, });
  52. const increment = () => ({ type: 'INCREMENT', }); const

    decrement = () => ({ type: 'DECREMENT', }); const changeColor = color => ({ type: 'CHANGE_COLOR', payload: color, });
  53. const increment = () => ({ type: 'INCREMENT', }); const

    decrement = () => ({ type: 'DECREMENT', }); const changeColor = color => ({ type: 'CHANGE_COLOR', payload: color, });
  54. 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; } }
  55. 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; } }
  56. 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; } }
  57. 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; } }
  58. 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; } }
  59. 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; } }
  60. 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' } }
  61. 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' } }
  62. 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' } }
  63. function counterReducer(state = 0, action) { switch (action.type) { case

    'INCREMENT': return state + 1; case 'DECREMENT': return state - 1; default: return state; } }
  64. const initialState = { color: 'red' }; function carReducer(state =

    initialState, action) { switch (action.type) { case 'CHANGE_COLOR': return { ...state, color: action.payload }; default: return state; } }
  65. const initialState = { color: 'red' }; function carReducer(state =

    initialState, action) { switch (action.type) { case 'CHANGE_COLOR': return { ...state, color: action.payload }; default: return state; } }
  66. const initialState = { color: 'red' }; function carReducer(state =

    initialState, action) { switch (action.type) { case 'CHANGE_COLOR': return { ...state, color: action.payload }; default: return state; } }
  67. const logMiddleware = api => next => action => {

    console.log('dispatch', action); const result = next(action); console.log('state =', api.getState()); return result; };
  68. const logMiddleware = api => next => action => {

    console.log('dispatch', action); const result = next(action); console.log('state =', api.getState()); return result; };
  69. const logMiddleware = api => next => action => {

    console.log('dispatch', action); const result = next(action); console.log('state =', api.getState()); return result; };
  70. const logMiddleware = api => next => action => {

    console.log('dispatch', action); const result = next(action); console.log('state =', api.getState()); return result; };
  71. const logMiddleware = api => next => action => {

    console.log('dispatch', action); const result = next(action); console.log('state =', api.getState()); return result; };
  72. const logMiddleware = api => next => action => {

    console.log('dispatch', action); const result = next(action); console.log('state =', api.getState()); return result; };
  73. const logMiddleware = api => next => action => {

    console.log('dispatch', action); const result = next(action); console.log('state =', api.getState()); return result; };
  74. const logMiddleware = api => next => action => {

    console.log('dispatch', action); const result = next(action); console.log('state =', api.getState()); return result; };
  75. const logMiddleware = api => next => action => {

    console.log('dispatch', action); const result = next(action); console.log('state =', api.getState()); return result; };
  76. 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' } }
  77. 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' } }
  78. 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' } }
  79. 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' } }
  80. 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' } }
  81. 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; } }
  82. 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; } }
  83. 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; } }
  84. const requestUsers = () => ({ type: 'REQUEST_USERS', }); const

    receiveUsers = users => ({ type: 'RECEIVE_USERS', payload: users, });
  85. const thunkMiddleware = api => next => action => {

    if (typeof action === 'function') { return action(api.dispatch); } return next(action); }; const store = createStore( reducer, applyMiddleware(thunkMiddleware) );
  86. const thunkMiddleware = api => next => action => {

    if (typeof action === 'function') { return action(api.dispatch); } return next(action); }; const store = createStore( reducer, applyMiddleware(thunkMiddleware) );
  87. const thunkMiddleware = api => next => action => {

    if (typeof action === 'function') { return action(api.dispatch); } return next(action); }; const store = createStore( reducer, applyMiddleware(thunkMiddleware) );
  88. const thunkMiddleware = api => next => action => {

    if (typeof action === 'function') { return action(api.dispatch); } return next(action); }; const store = createStore( reducer, applyMiddleware(thunkMiddleware) );
  89. function fetchUsers() { return dispatch => { dispatch(requestUsers()); return axios.get('/users')

    .then(({ data }) => { dispatch(receiveUsers(data)); }); }; } store.dispatch(fetchUsers());
  90. function fetchUsers() { return dispatch => { dispatch(requestUsers()); return axios.get('/users')

    .then(({ data }) => { dispatch(receiveUsers(data)); }); }; } store.dispatch(fetchUsers()); Action Creator
  91. function fetchUsers() { return dispatch => { dispatch(requestUsers()); return axios.get('/users')

    .then(({ data }) => { dispatch(receiveUsers(data)); }); }; } store.dispatch(fetchUsers()); Action
  92. function fetchUsers() { return dispatch => { dispatch(requestUsers()); return axios.get('/users')

    .then(({ data }) => { dispatch(receiveUsers(data)); }); }; } store.dispatch(fetchUsers());
  93. • Appropriate for more complex business logic and async flows.

    • Middleware-based like thunk middleware. Alternatives for async code.
  94. • Appropriate for more complex business logic and async flows.

    • Middleware-based like thunk middleware. • redux-saga Alternatives for async code.
  95. • Appropriate for more complex business logic and async flows.

    • Middleware-based like thunk middleware. • redux-saga • redux-observable Alternatives for async code.
  96. • Appropriate for more complex business logic and async flows.

    • Middleware-based like thunk middleware. • redux-saga • redux-observable • redux-logic Alternatives for async code.
  97. const state = { counter: 0, car: { color: 'red'

    }, }; it('returns initial state', () => { expect(reducer(undefined, {})).toEqual(state); }); it('increments the number', () => { const subject = reducer(state, increment()).counter; expect(subject).toBe(1); }); it('changes the car color', () => { const subject = reducer(state, changeColor('green')).car.color; expect(subject).toBe('green'); }); Easy reducer unit tests!
  98. it('creates an INCREMENT action', () => { expect(increment()).toEqual({ type: 'INCREMENT'

    }); }); it('creates a CHANGE_COLOR action', () => { expect(changeColor('blue')).toEqual({ type: 'CHANGE_COLOR', payload: 'blue', }); }); You can test action creators, but not really necessary.
  99. function fetchUsers() { return dispatch => { dispatch(requestUsers()); return axios.get('/users')

    .then(({ data }) => { dispatch(receiveUsers(data)); }); }; } However, you should test async action creators.
  100. import td from 'testdouble'; const axios = td.replace('axios'); it('fetches users',

    () => { // Arrange const dispatch = td.function(); td.when(axios.get('/users')).thenResolve({ data: 'success' }); // Act return fetchUsers()(dispatch).then(() => { const subject = td.matchers.captor(); td.verify(dispatch(subject.capture())); // Assert expect(subject.values[0]).toEqual(requestUsers()); expect(subject.values[1]).toEqual(receiveUsers('success')); }); }); Unit test with test doubles
  101. Use integration tests to ensure all pieces work together. Allow

    store, reducer, and actions to all interact .
  102. it('fetches users', () => { // Arrange const store =

    createStore(reducer, applyMiddleware(thunkMiddleware)); td.replace(axios, 'get'); td.when(axios.get('/users')).thenResolve({ data: 'success' }); // Act const promise = store.dispatch(fetchUsers()); const subject = store.getState; // Assert expect(subject()).toEqual({ isFetching: true, users: [] }); return promise.then(() => { expect(subject()).toEqual({ isFetching: false, users: 'success' }); }); });
  103. it('fetches users', () => { // Arrange const store =

    createStore(reducer, applyMiddleware(thunkMiddleware)); td.replace(axios, 'get'); td.when(axios.get('/users')).thenResolve({ data: 'success' }); // Act const promise = store.dispatch(fetchUsers()); const subject = store.getState; // Assert expect(subject()).toEqual({ isFetching: true, users: [] }); return promise.then(() => { expect(subject()).toEqual({ isFetching: false, users: 'success' }); }); });
  104. it('fetches users', () => { // Arrange const store =

    createStore(reducer, applyMiddleware(thunkMiddleware)); td.replace(axios, 'get'); td.when(axios.get('/users')).thenResolve({ data: 'success' }); // Act const promise = store.dispatch(fetchUsers()); const subject = store.getState; // Assert expect(subject()).toEqual({ isFetching: true, users: [] }); return promise.then(() => { expect(subject()).toEqual({ isFetching: false, users: 'success' }); }); });
  105. it('fetches users', () => { // Arrange const store =

    createStore(reducer, applyMiddleware(thunkMiddleware)); td.replace(axios, 'get'); td.when(axios.get('/users')).thenResolve({ data: 'success' }); // Act const promise = store.dispatch(fetchUsers()); const subject = store.getState; // Assert expect(subject()).toEqual({ isFetching: true, users: [] }); return promise.then(() => { expect(subject()).toEqual({ isFetching: false, users: 'success' }); }); });