SaltyCrane Blog — Notes on Javascript and web development

Comparing vanilla React, Redux, and Redux Thunk - Chained modals exercise part 3

This is a comparison of three methods for building a sequence of modals using: 1) React only 2) React+Redux and 3) React+Redux+Redux Thunk.

In part 1 and part 2, I created a configurable sequence of modals using React and React Router. Each modal had a form input. Clicking the "Next" button made an AJAX request and, on success, advanced to the next modal. The user was able to navigate between modals using the browser's history. This example adds better management of the form input state. Form input state can be pre-populated from the server and the input state is maintained when navigating forward or backward between modals.

React only

The first solution uses a parent component to manage the state of the chained modals. The full code is here and the demo is here.

App.js

RoutedApp is the top level component which contains the routing configuration. It is unchanged from part 2 except a formData prop is now passed in addition to the modalList prop to allow pre-filling form data from the server. Assume that "Servur" is data provided by the backend server.

This uses ES'15 arrow functions, argument destructuring and JSX spread. [App.js on github]

const RoutedApp = () => (
  <Router history={hashHistory}>
    <Route component={App}>
      <Route path="/" component={
        partial(ChainedModals, {
          modalList: ['/name', '/phone', '/done'],
          formData: {name: 'Servur'}
        })}>
        <Route path="/name" component={ModalName} />
        <Route path="/phone" component={ModalPhone} />
        <IndexRedirect to="/name" />
      </Route>
      <Route path="/done" />
    </Route>
  </Router>
);

const App = ({ children }) => (
  <div>
    <PageBehindModals />
    {children}
  </div>
);

const partial = (Comp, props) => (fprops) => <Comp {...props} {...fprops} />;
ChainedModals.js

ChainedModals manages two things: 1) form input state for all the modals and 2) advancing to the next modal in the sequence. To manage sequencing, the index of the current modal is stored in the component state. Using React's lifecycle methods componentWillMount and componentWillReceiveProps it determines the current index from the route before rendering. When a modal's "Next" button is clicked, it uses the current index to determine the next route and navigates to it.

ChainedModals also stores form data in its component state and defines methods for each modal to store their form data (_storeName and _storePhone). One problem with this approach is that specific modal functionality is defined in ChainedModals so it no longer works with an arbitrary list of modals passed in modalList as it did in parts 1 and 2.

This uses ES'15 nested destructuring and classes, and ES'17 class properties. [ChainedModals.js on github]

class ChainedModals extends Component {
  constructor(props) {
    super(props);
    const { formData } = props;
    this.state = {
      currIndex: null,
      formData: {
        name: null,
        phone: null,
        ...formData
      }
    };
  }

  render() {
    const { children } = this.props;
    const { currIndex, formData } = this.state;

    const modalElement = children && React.cloneElement(children, {
      step: currIndex + 1,
      formData: formData,
      storeName: this._storeName,
      storePhone: this._storePhone,
      gotoNext: this._gotoNext,
      backdrop: false,
      show: true
    });

    return (
      <div>
        <ModalBackdrop />
        {modalElement}
      </div>
    );
  }

  componentWillMount() {
    this._setIndexFromRoute(this.props);
  }

  componentWillReceiveProps(nextProps) {
    this._setIndexFromRoute(nextProps);
  }

  _setIndexFromRoute(props) {
    const { modalList, location: { pathname } } = props;
    const index = modalList.findIndex(path => path === pathname);
    this.setState({currIndex: index});
  }

  _gotoNext = () => {
    const { modalList } = this.props;
    const { currIndex } = this.state;
    const nextRoute = modalList[currIndex + 1];
    hashHistory.push(nextRoute);
  };

  _storeName = (name) => {
    this.setState({
      formData: {
        ...this.state.formData,
        name: name
      }
    });
  };

  _storePhone = (phone) => {
    this.setState({
      formData: {
        ...this.state.formData,
        phone: phone
      }
    });
  };
}
ModalName.js

ModalName changed to accept some form data from its parent and it also still manages the AJAX request and the state related to it.

This uses ES'15 destructuring, classes, and promises, and ES'17 rest/spread and class properties. [ModalName.js on github]

class ModalName extends Component {
  constructor(props) {
    super(props);
    const { formData: { name } } = props;
    this.state = {
      name: name || '',
      isRequesting: false,
      errorMsg: null
    };
  }

  render() {
    const { step, ...rest } = this.props;
    const { name, isRequesting, errorMsg } = this.state;

    return (
      <Modal {...rest}>
        <Modal.Header closeButton>
          <Modal.Title>Step {step} - Name</Modal.Title>
        </Modal.Header>
        <Modal.Body>
          {isRequesting && <p><em>Making fake ajax request...</em></p>}
          {errorMsg && <p><em>{errorMsg}</em></p>}
          <Input
            label="Enter your name"
            type="text"
            bsSize="large"
            {...(errorMsg ? {bsStyle: 'error'} : {})}
            value={name}
            onChange={this._handleInputChange}
            ref={(c) => this._input = c}
          />
        </Modal.Body>
        <Modal.Footer>
          <Button bsStyle="primary" onClick={this._handleClickNext}>Next</Button>
        </Modal.Footer>
      </Modal>
    );
  }

  _handleInputChange = () => {
    this.setState({name: this._input.getValue()});
  };

  _handleClickNext = () => {
    const { storeName, gotoNext } = this.props;
    const name = this._input.getValue();

    this.setState({isRequesting: true, errorMsg: null});
    request('/api/name', name)
      .then(() => {
        storeName(name);
        gotoNext();
      })
      .catch((error) => {
        this.setState({isRequesting: false, errorMsg: error});
      });
  };
}

Redux

Redux provides a way to manage application state outside of components. Examples of application state are the index of the current modal, the form input value, and status of the AJAX request. Using Redux to manage state makes React components more simple and reusable. Redux also makes it easier to manage complex interactions that affect multiple parts of the application.

Like React, data flows in one direction in Redux. Actions describe changes to be made, then reducers make changes to the state based on the actions, finally, the new state is passed to React components via props. Actions are simple objects. Reducers are pure functions that accept a state and action and return a new state. Because reducers are pure functions, they can be composed of many smaller reducers that each operate on a smaller slice of the state. See the Three Principles of Redux.

The code for the redux solution is here and the demo is here.

App.js

A Redux store is created to hold the application state. The state is initialized with modalList and formData that were previously passed into the ChainedModals component. The application element tree is wrapped with Provider which makes the Redux store available to it's child components.

I also added an event handler which dispatches a Redux action whenever the route changes. This idea was taken from this Redux issue and this related Redux pull request. Note: not all imports are shown in the snippet. See all the imports in [App.js on github]

import { Provider } from 'react-redux';
import { createStore } from 'redux';
import { routeChanged } from '../actions';
import reducer from '../reducers';

const initialState = {
  modalList: [
    '/name',
    '/phone',
    '/done'
  ],
  currIndex: null,
  formData: {
    name: 'Servur',
    phone: null
  }
};

const store = createStore(reducer, initialState);

// Dispatch an action when the route changes.
hashHistory.listen(location => store.dispatch(routeChanged(location)));

const RoutedApp = () => (
  <Provider store={store}>
    <Router history={hashHistory}>
      <Route component={App}>
        <Route path="/" component={ChainedModals}>
          <Route path="/name" component={ModalName} />
          <Route path="/phone" component={ModalPhone} />
          <IndexRedirect to="/name" />
        </Route>
        <Route path="/done" />
      </Route>
    </Router>
  </Provider>
);

const App = ({ children }) => (
  <div>
    <PageBehindModals />
    {children}
  </div>
);
actions.js

I defined an action creator for when a route is changed and two for storing data from a user. The action creators return the action which is a simple object that has a type and some other simple data. The reducers will change the state based on the actions. [actions.js on github]

export const ROUTE_CHANGED = 'ROUTE_CHANGED';
export const STORE_NAME = 'STORE_NAME';
export const STORE_PHONE = 'STORE_PHONE';

export function routeChanged(location) {
  return {
    type: ROUTE_CHANGED,
    location: location
  }
}

export function storeName(name) {
  return {
    type: STORE_NAME,
    name: name
  };
}

export function storePhone(phone) {
  return {
    type: STORE_PHONE,
    phone: phone
  };
}
reducers.js

The _sequencing reducer sets the current index based on the route when the route changes. Previously this was done in the ChainedModals component. The _formData reducer stores data (either name or phone) from the user in the state. I wrap statements for each case in curly braces so that const and let declarations will have a more reasonable scope. Thanks Or! [reducers.js on github]

function modalsReducer(state, action) {
  return {
    ..._sequencing(state, action),
    formData: _formData(state.formData, action)
  }
}

function _sequencing(state, action) {
  switch (action.type) {
    case ROUTE_CHANGED: {
      const { location: { pathname } } = action;
      const index = state.modalList.findIndex(path => path === pathname);
      return {
        ...state,
        currIndex: index
      };
    }
    default:
      return state;
  }
}

function _formData(state, action) {
  switch (action.type) {
    case STORE_NAME: {
      return {
        ...state,
        name: action.name
      }
    }
    case STORE_PHONE: {
      return {
        ...state,
        phone: action.phone
      }
    }
    default:
      return state;
  }
}
ChainedModals.js

The ChainedModals component is now connected to Redux. Properties from the Redux state (currIndex, modalList, and formData) are passed into the React component as props with the same name. Similarly, the Redux actions storeName and storePhone are passed in as props with the same name. A lot of the code to manage the form state and sequencing is now removed. However it still defines a _gotoNext method which is used to navigate to the next route. Note in render(), I pull out the children and currIndex props and pass the rest of the props (e.g. formData, storeName, gotoNext) onto the child modal using the ES'17 object rest/spread operators. [ChainedModals.js on github]

class ChainedModals extends Component {
  render() {
    const { children, currIndex, ...rest } = this.props;

    const modalElement = children && React.cloneElement(children, {
      step: currIndex + 1,
      backdrop: false,
      show: true,
      gotoNext: this._gotoNext,
      ...rest
    });

    return (
      <div>
        <ModalBackdrop />
        {modalElement}
      </div>
    );
  }

  _gotoNext = () => {
    const { currIndex, modalList } = this.props;
    const nextRoute = modalList[currIndex + 1];
    hashHistory.push(nextRoute);
  };
}

export default connect(
  function mapStateToProps(state) {
    const { currIndex, modalList, formData } = state;
    return { currIndex, modalList, formData };
  },
  function mapDispatchToProps(dispatch) {
    return {
      storeName: (...args) => dispatch(storeName(...args)),
      storePhone: (...args) => dispatch(storePhone(...args))
    }
  }
)(ChainedModals);
ModalName.js

The individual modal components remain the same. They still make the ajax calls and manage the state related to that. [ModalName.js on github]

class ModalName extends Component {
  constructor(props) {
    super(props);
    const { formData: { name } } = props;
    this.state = {
      name: name || '',
      isRequesting: false,
      errorMsg: null
    };
  }

  render() {
    const { step, ...rest } = this.props;
    const { name, isRequesting, errorMsg } = this.state;

    return (
      <Modal {...rest}>
        <Modal.Header closeButton>
          <Modal.Title>Step {step} - Name</Modal.Title>
        </Modal.Header>
        <Modal.Body>
          {isRequesting && <p><em>Making fake ajax request...</em></p>}
          <Input
            label="Enter your name"
            type="text"
            bsSize="large"
            {...(errorMsg ? {bsStyle: 'error'} : {})}
            help={errorMsg && <em>{errorMsg}</em>}
            value={name}
            onChange={this._handleInputChange}
            ref={(c) => this._input = c}
          />
        </Modal.Body>
        <Modal.Footer>
          <Button bsStyle="primary" onClick={this._handleClickNext}>Next</Button>
        </Modal.Footer>
      </Modal>
    );
  }

  _handleInputChange = () => {
    this.setState({name: this._input.getValue()});
  };

  _handleClickNext = () => {
    const { storeName, gotoNext } = this.props;
    const name = this._input.getValue();

    this.setState({isRequesting: true, errorMsg: null});
    request('/api/name', name)
      .then(() => {
        storeName(name);
        gotoNext();
      })
      .catch((error) => {
        this.setState({isRequesting: false, errorMsg: error});
      });
  };
}

Redux Thunk

Using Redux added some boilerplate but made React components simpler by moving their state to the Redux store. However there is still some state and logic managed by the components. Redux Thunk is middleware for Redux that enables creating actions with side effects such as making asynchronous requests and changing the route. Redux Thunk legitimizes the pattern of providing dispatch to actions. See the Async Actions Redux documentation for detailed information about using Redux Thunk for asynchronous actions. Other alternatives for handling asynchronous actions are writing custom Redux middleware or using Redux Saga.

The code for the redux-thunk solution is here and the demo is here.

App.js

App.js is the same except I applied the redux thunk middleware. [App.js on github]

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';

const store = createStore(reducer, initialState, applyMiddleware(thunk));
actions.js

In actions.js I created three action creators with side effects: storeName and storePhone make asynchronous requests gotoNext changes the route. Note: though I used getState in my action creators, in general this is considered an anti-pattern. [actions.js on github]

export const ROUTE_CHANGED = 'ROUTE_CHANGED';
export const STORE_NAME_REQUESTED = 'STORE_NAME_REQUESTED';
export const STORE_NAME_SUCCEEDED = 'STORE_NAME_SUCCEEDED';
export const STORE_NAME_FAILED = 'STORE_NAME_FAILED';
export const STORE_PHONE_REQUESTED = 'STORE_PHONE_REQUESTED';
export const STORE_PHONE_SUCCEEDED = 'STORE_PHONE_SUCCEEDED';
export const STORE_PHONE_FAILED = 'STORE_PHONE_FAILED';

export function routeChanged(location) {
  return {
    type: ROUTE_CHANGED,
    location: location
  }
}

export function gotoNext() {
  return (dispatch, getState) => {
    const { currIndex, modalList } = getState();
    const nextRoute = modalList[currIndex + 1];
    hashHistory.push(nextRoute);
  }
}

export function storeName(name, onSuccess) {
  return dispatch => {
    dispatch(_storeNameRequested());
    return request('/api/name', name)
      .then(() => {
        dispatch(_storeNameSucceeded(name));
        onSuccess();
      })
      .catch(error => {
        dispatch(_storeNameFailed(error));
      });
  }
}

export function storePhone(phone, onSuccess) {
  return dispatch => {
    dispatch(_storePhoneRequested());
    return request('/api/phone', phone)
      .then(() => {
        dispatch(_storePhoneSucceeded(phone));
        onSuccess();
      })
      .catch(error => {
        dispatch(_storePhoneFailed(error));
      });
  }
}

function _storeNameRequested() {
  return {
    type: STORE_NAME_REQUESTED
  };
}

function _storeNameSucceeded(name) {
  return {
    type: STORE_NAME_SUCCEEDED,
    name: name
  };
}

function _storeNameFailed(errorMsg) {
  return {
    type: STORE_NAME_FAILED,
    errorMsg: errorMsg
  };
}

function _storePhoneRequested() {
  return {
    type: STORE_PHONE_REQUESTED
  };
}

function _storePhoneSucceeded(phone) {
  return {
    type: STORE_PHONE_SUCCEEDED,
    phone: phone
  };
}

function _storePhoneFailed(errorMsg) {
  return {
    type: STORE_PHONE_FAILED,
    errorMsg: errorMsg
  };
}
reducers.js

reducers.js now updates some state based on the status of the API request which is used by the modal components to show the spinner or validation errors. [reducers.js on github]

function modalsReducer(state, action) {
  return {
    ..._sequencing(state, action),
    formData: _formData(state.formData, action)
  }
}

function _sequencing(state, action) {
  switch (action.type) {
    case ROUTE_CHANGED: {
      const { location: { pathname } } = action;
      const index = state.modalList.findIndex(path => path === pathname);
      return {
        ...state,
        requestStatus: null,
        currIndex: index
      };
    }
    case STORE_NAME_REQUESTED:
    case STORE_PHONE_REQUESTED: {
      return {
        ...state,
        isRequesting: true,
        errorMsg: null
      }
    }
    case STORE_NAME_SUCCEEDED:
    case STORE_PHONE_SUCCEEDED: {
      return {
        ...state,
        isRequesting: false,
        errorMsg: null
      }
    }
    case STORE_NAME_FAILED:
    case STORE_PHONE_FAILED: {
      return {
        ...state,
        isRequesting: false,
        errorMsg: action.errorMsg
      }
    }
    default:
      return state;
  }
}

function _formData(state, action) {
  switch (action.type) {
    case STORE_NAME_SUCCEEDED: {
      return {
        ...state,
        name: action.name
      }
    }
    case STORE_PHONE_SUCCEEDED: {
      return {
        ...state,
        phone: action.phone
      }
    }
    default:
      return state;
  }
}
ChainedModals.js

ChainedModals is now a simpler functional component. It's purpose is connecting child modal components to redux and setting some default props for the modals. It is also used to display the backdrop behind the modals. [ChainedModals.js on github]

const ChainedModals = ({ children, ...rest }) => {
  const modalElement = children && React.cloneElement(children, rest);
  return (
    <div>
      <ModalBackdrop />
      {modalElement}
    </div>
  );
};

export default connect(
  function mapStateToProps(state) {
    const { currIndex, isRequesting, errorMsg, formData } = state;
    return {
      backdrop: false,
      show: true,
      step: currIndex + 1,
      isRequesting,
      errorMsg,
      formData
    };
  },
  function mapDispatchToProps(dispatch) {
    return {
      gotoNext: (...args) => dispatch(gotoNext(...args)),
      storeName: (...args) => dispatch(storeName(...args)),
      storePhone: (...args) => dispatch(storePhone(...args))
    }
  }
)(ChainedModals);
ModalName.js

Individual modal components now only use state for controlled inputs. [ModalName.js on github]

class ModalName extends Component {
  constructor(props) {
    super(props);
    const { formData: { name } } = props;
    this.state = {
      name: name || ''
    };
  }

  render() {
    const { step, isRequesting, errorMsg, ...rest } = this.props;
    const { name } = this.state;

    return (
      <Modal {...rest}>
        <Modal.Header closeButton>
          <Modal.Title>Step {step} - Name</Modal.Title>
        </Modal.Header>
        <Modal.Body>
          {isRequesting && <p><em>Making fake ajax request...</em></p>}
          <Input
            label="Enter your name"
            type="text"
            bsSize="large"
            {...(errorMsg ? {bsStyle: 'error'} : {})}
            help={errorMsg && <em>{errorMsg}</em>}
            value={name}
            onChange={this._handleInputChange}
            ref={(c) => this._input = c}
          />
        </Modal.Body>
        <Modal.Footer>
          <Button bsStyle="primary" onClick={this._handleClickNext}>Next</Button>
        </Modal.Footer>
      </Modal>
    );
  }

  _handleInputChange = () => {
    this.setState({name: this._input.getValue()});
  };

  _handleClickNext = () => {
    const { storeName, gotoNext } = this.props;
    const name = this._input.getValue();
    storeName(name, gotoNext);
  };
}

References / Further Reading (mostly from Redux author, Dan Abramov)

Comments