.   .   .
We're always looking for great talent‼️ 🚀😄 And we're passionate about driving innovation and improving lives. Join us as we help social impact organizations and enterprise customers build their web apps. Apply today!
.   .   .

When in the course of developing React/Redux apps, one may verily find him/herself wracking their brain for how to update many separate components in one simple, simultaneous function call.

This was the case when one of our clients wanted a CONFIRM ALL button placed on their app. This gave rise to two questions of existential plight:

  1. How do we track the hasChanged state of many separate components?
  2. Is it possible to fire all of the child components’ this._save() methods simultaneously? And without strange side-effects?

We discovered (what we think to be) an elegant and robust solution for such a problem.

The componentWillMount and componentWillUnmount methods provided by the React component lifecycle proved invaluable for such a task. All we needed to do was store all of the _save functions in an array.

I wrote a boilerplate app for this tutorial that demonstrates how simple this process is. This app is a simple clicker app that allows clicks to be saved, either one at a time, or all at once.


Link to code on github  View the source code here.

Click above to load the live example!

Hooking up our components

The first step is to register our save callback functions when the component mounts, and conversely deregister the callbacks when the component un-mounts.

// components/Clicker.js
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import { /* ... */ registerSaveCallback, deregisterSaveCallback } from '../actions/clicker-actions';
    
class Clicker extends Component {
    
  // ... //
    
  componentWillMount() {
    const { dispatch, clicker } = this.props;
    dispatch(registerSaveCallback(this._save, clicker.id));
  }
    
  componentWillUnmount() {
    const { dispatch, clicker } = this.props;
    dispatch(deregisterSaveCallback(this._save, clicker.id));
  }
    
// ... //
    
}

The action helper simply takes the callback function and clicker id and passes them to the reducer.

// actions/clicker-actions.js
import * as constants from '../constants';
    
export function registerSaveCallback(callback, id) {
  return (dispatch) => {
    dispatch({
      type: constants.SAVE_CALLBACK_REGISTERED,
      payload: { callback, id },
    });
  };
}

And finally, the reducer. Note that this approach assumes that clicker objects have already been initialized.

// reducers/index.js
import * as constants from '../constants';
    
const initialState = {
  clickers: [
    {
      id: 0,
      clicks: 0,
      hasChanged: false,
      saveCallback: null,
    },
    {
      id: 1,
      clicks: 0,
      hasChanged: false,
      saveCallback: null,
    },
  ],
};
    
export default (state = initialState, action) => {
  let x; // just a dummy variable so that we can use block-scoping inside of the switch statement
  switch (action.type) {
    
    case constants.SAVE_CALLBACK_REGISTERED:
      x = state.clickers.map((clicker) => {
        if (clicker.id === action.payload.id) {
          return { ...clicker, saveCallback: action.payload.callback };
        } else {
          return clicker;
        }
      });
      return {
        ...state,
        clickers: x,
      };
    
    // ... //
    
  }
}

Now that we have our save callback functions stored in an array when the app loads, now all we need is a way to fire those stored callback functions. Enter the saveAll action:

// actions/clicker-actions.js
export function saveAll() {
  return (dispatch, getState) => {
    // get clickers
    const clickers = getState().clickers;
    // iterate through clicker and fire its _save callback method
    clickers.forEach(clicker => {
      // fire save method
      if (clicker.saveCallback) {
        clicker.saveCallback();
      }
    });
  };
}

Perfection! And now all we need to do is actually call the sucker.

// components/SaveAllBar.js
import React, { Component, PropTypes } from 'react';
import { saveAll } from '../actions/clicker-actions';
    
class SaveAllBar extends Component {
  
  // ... //
    
  _saveAll() {
    const { dispatch } = this.props;
    dispatch(saveAll());
  }
    
  render () {
    
    return (
      <div className="save-all-bar">
        <button onClick={this._saveAll}>SAVE ALL!</button>
      </div>
    );
  }
}
// ... //

And there you have it. So, in straight-forward backwards fashion, I answered our second question first. But what about implementing a global hasChanged state with multiple components? Well, our reducer code above hinted towards the solution. We simply set the hasChanged boolean for each clicker.

Determining Global hasChanged State for Multiple Components

In Clicker.js, we simply dispatch an action to say that the clicker state has changed if it has been incremented, and reset its changed state if the cancel or save methods are called.

// components/Clicker.js
  
  // ... //
    
  _increment (ev) {
    const { dispatch, clicker } = this.props;
    
    // -> do stuff to increment the clicker
    
    if (!clicker.hasChanged) { dispatch(setClickerHasChanged(true, clicker.id)); }
  }
    
  _cancel (ev) {
    const { dispatch, clicker } = this.props;
    
    // -> do stuff to reset the clicker
    
    dispatch(setClickerHasChanged(false, clicker.id));
  }
    
  _save (ev) {
    const { dispatch, clicker } = this.props;
    
    // -> do stuff to save clicks and reset the clicker
    
    dispatch(setClickerHasChanged(false, clicker.id));
  }
    
  // ... //

And back in familiar action territory:

// actions/clicker-actions.js
    
// ... //
    
export function setClickerHasChanged(hasChanged, id) {
  return (dispatch) => {
    dispatch({
      type: constants.CLICKER_HAS_CHANGED,
      payload: { hasChanged, id },
    });
  };
}

And now the reducer, where the real magic happens.

// reducers/index.js
    
// ... //
    
case constants.CLICKER_HAS_CHANGED:
  x = state.clickers.map((clicker) => {
    if (clicker.id === action.payload.id) {
      return { ...clicker, hasChanged: action.payload.hasChanged };
    } else {
      return clicker;
    }
  });
  return {
    ...state,
    clickers: x,
  };

Perfect, now our array of clickers contains the hasChanged states as well.

Now, determining a hasChanged state from an array is slightly more complicated than just evaluating a boolean to true or false. But in all actuality the implementation is rather painless.

The Javascript Array.some method is very helpful here. With the help of ES2015 fat arrow notation, we can trim this down to three elegant lines of code.

If you aren’t familiar with ES2015 notation, do not fret. Basically, we pass in the array of clickers. If ANY clicker.hasChanged === true, the function returns true.

// utils/functions.js
export function areChangesPresent(clickers) {
  return clickers.some(clicker => clicker.hasChanged);
}

Finally, we’re able to start implementing painless state validation in our app.

// components/TotalClicks.js
import { areChangesPresent } from '../utils/functions';
class TotalClicks extends Component {
    
  // ... //
    
  render () {
    const changesPresent = areChangesPresent(this.props.clickers);
    const changesClass = (changesPresent ? 'changes-present' : null);
    
    const totalClicks = magicallyGetTotalClicks(); // I just made this up
    
    return (
      <div className={'total-clicks-bar ' + changesClass}>
        <h2>Total clicks saved: {totalClicks}</h2>
        {changesPresent && <p><em>Unsaved changes present below. Save them to update this bar.</em></p>}
      </div>
    );
  }
}

That’s it! And this just taps the surface of what is possible with this approach.

If you want to dig through the source code yourself, and even critique and/or improve our code, we welcome and celebrate such industrious endeavors. Check it out here: https://github.com/chiedolabs/global-save-button-example

Or you can go play with the live example: https://global-save-button-example.herokuapp.com/

Happy hacking!