Unlikenesses A Backend Developer

react sequencer redux

14 July 2017

In the previous article we created a simple sequencer in React. In this one we're going to port the state over to Redux. To do this we need to install the redux and react-redux packages with npm install --save redux react-redux.

My aim is to take the previous non-Redux version and convert it bit by bit. This might end up being more painful than starting from scratch but it will allow us to see the differences in architecture more clearly. Unlike most tutorials I'm going to start with an action. For now we'll take something simple, the toggle play button. Create a folder in src called actions and a file in it called index.js:

export function togglePlay() {
    return {
        type: 'TOGGLE_PLAY'
    };
}

We return the action type TOGGLE_PLAY in the action definition togglePlay. Since there is only one play button and we are not updating any other properties the type of the action is the only information we need to pass. The next task is to define a reducer to handle this action and update the state accordingly. Create another new folder in src called reducers and a file there called index.js. We'll just create one reducer for now, called controls, but since we'll be creating more later, we'll also use combineReducers to put them all into one reducer:

import { combineReducers } from 'redux';

const controls = (state = { playing: false }, action) => {
    switch (action.type) {
        case 'TOGGLE_PLAY':
            return Object.assign({}, state, {
                playing: !state.playing
            });
        default:
            return state;
    }
}

const reducer = combineReducers({
    controls
});

export default reducer;

Since the only piece of state we're moving into Redux for now is the playing property, that's all we need to set in the reducer's first argument as the default state. We then check if the action is TOGGLE_PLAY, and if so return a copy of the state with its playing property toggled.

The next thing is to create a container which has access to this state. We can re-use the root App component we already have. Create a new folder in src called containers and move App.js into there. We'll need to add a couple of import statements at the top:

import { connect } from 'react-redux';
import { togglePlay } from '../actions';

And after the class definition, we need a few things:

First, something to pass the Redux state to our container's props:

const mapStateToProps = (state, ownProps) => {
    return {
        playing: state.controls.playing
    };
};

This grabs the value of the controls reducer's state and passes it as a playing prop to the container. We also need to get the toggle action and give that as a prop:

const mapDispatchToProps = (dispatch) => {
    return {
        togglePlaying: () => {
            dispatch(togglePlay());
        }
    };
};

This means that togglePlaying will be a new prop available in the App container, which when called dispatches the togglePlay action we created earlier.

Finally we need to connect it all up to the Redux store:

export default connect(
    mapStateToProps,
    mapDispatchToProps
)(App);

As a last detail, now that we have these two new props, we can replace the existing ones in the container's invocation of the Controls component:

<Controls 
    bpm={this.state.bpm} 
    handleChange={this.changeBpm} 
    playing={this.props.playing} 
    togglePlaying={this.props.togglePlaying} />

The next step to get this working is to wrap the App container in a Provider component which passes the store to all container components. So we import a few modules:

import { Provider } from 'react-redux';
import { createStore } from 'redux';
import reducer from './reducers';
import App from './containers/App';

... create the store:

let store = createStore(reducer);

... and render the App component within the Provider:

ReactDOM.render(
    <Provider store={store}>
        <App />
    </Provider>,
    document.getElementById('root')
    );

One last thing: we need to watch for when the props.playing property changes and set or clear the timer as appropriate. We can do this with the componentWillReceiveProps lifecycle hook. Add this in App:

componentWillReceiveProps(nextProps, prevProps) {
    if (nextProps.playing !== this.props.playing) {
        this.togglePlaying(this.props.playing);
    }
}

Now the app should be working as before.

Let's do the same with the BPM control. Add a new action to the actions/index.js file:

export function changeBpm(bpm) {
    return {
        type: 'CHANGE_BPM',
        bpm
    }
}

Unlike the togglePlay action, this one takes a bpm value and returns it as a property of the action. In our reducers file we can add this as a case to our switch statement:

case 'CHANGE_BPM': {
    return Object.assign({}, state, {
        bpm: action.bpm
    });
}

We also need to add the default state to the reducer:

const controls = (state = { playing: false, bpm: 220 }, action) => {

Then add the appropriate props to App. In mapStateToProps add the new bpm prop:

return {
    playing: state.controls.playing,
    bpm: state.controls.bpm
};

Then in mapDispatchToProps add the new action:

return {
    togglePlaying: () => {
        dispatch(togglePlay());
    },
    changeBpm: (bpm) => {
        dispatch(changeBpm(bpm));
    }
};

We can then update our invocation of Controls so that it's only using the Redux props:

<Controls 
    bpm={this.props.bpm} 
    handleChange={this.props.changeBpm} 
    playing={this.props.playing} 
    togglePlaying={this.props.togglePlaying} />

And in the Controls component modify the onChange handler to pass the input's value:

onChange={(e) => props.handleChange(e.target.value)}

Finally, back in App we can update our componentWillReceiveProps hook:

if (nextProps.bpm !== this.props.bpm) {
    this.changeBpm(nextProps.bpm);
}

If we remove the state update and modify our reference to playing in changeBpm, it should now be working as previously:

changeBpm(bpm) {
    if (this.props.playing) {
        clearInterval(this.timerId);
        this.setTimer();
    }
}

We can now remove playing and bpm from the App component's state, and update setTimer to reference this.props.bpm instead of this.state.bpm.

Now to take care of the pads. First let's create an action togglePad:

export function togglePad(row, col) {
    return {
        type: 'TOGGLE_PAD',
        row,
        col
    }
}

This takes a row and column as well as the action type. We can now create a reducer (in the same reducers/index.js file) which has an initial pads state and a case to handle toggling a pad - both taken from the App component:

const main = (state = { pads: [
                [0, 0, 0, 0, 0, 0, 0, 0],
                [0, 0, 0, 0, 0, 0, 0, 0],
                [0, 0, 0, 0, 0, 0, 0, 0],
                [0, 0, 0, 0, 0, 0, 0, 0],
                [0, 0, 0, 0, 0, 0, 0, 0],
                [0, 0, 0, 0, 0, 0, 0, 0],
                [0, 0, 0, 0, 0, 0, 0, 0],
                [0, 0, 0, 0, 0, 0, 0, 0]
            ]}, action) => {
    switch (action.type) {
        case 'TOGGLE_PAD':
            let pads = [...state.pads];
            let padState = pads[action.row][action.col];
            if (padState === 1) {
                pads[action.row][action.col] = 0;
            } else {
                pads[action.row][action.col] = 1;
            }
            return Object.assign({}, state, { pads });
        default:
            return state;
    }
}

Don't forget to add main to the combineReducers method.

Now add the new pads state as a prop to App in mapStateToProps:

pads: state.main.pads

and finally pass it to the Pads component:

pads={this.props.pads}

Hopefully the pads will be rendering ok but now they're coming from the Redux store. We can now add the new reducer to our mapDispatchToProps method in App.

togglePad: (row, col) => {
    dispatch(togglePad(row, col));
}

and reference it in our invocation to Pads:

toggleActive={this.props.togglePad}

We also need to import it at the top:

import { togglePlay, changeBpm, togglePad } from '../actions';

We can also now delete toggleActive from App (and its binding in the constructor).

Now just update the checkPad method to loop over this.props.pads instead of this.state.pads and everything should be working as usual. We can also now remove pads from the state in App.

The final source code is on GitHub.