I have seen many posts about how to add repeatable reducers on redux, and I have not seen an elegant solution.
By “elegant”, I’m talking about using a minimal amount of code and confusion to create the same or better result. From Redux docs under Reusing Reducer Logic, it shows creating counters with some sort of identifier, such as using names or indexes. In the following example, we will use no identifiers, and instead be passing a reference of the reducer object directly as the reference. This eliminates the need for extra code for generating and keeping track of identifiers.
That being said, it is important to note that the repeating reducer must be an object or an array because we use object equality to check for the matching reducer. If the repeating reducer is, for example, simply a number, this method will fail. For more complex applications, this will not be an issue, as most of the reducers should be a (nested) JS object anyway.
Example
Below is an example, implementing the popular counter app.
You can find the code at Github
Actions
export const ADD_COUNTER = "ADD_COUNTER"
export const addCounter = (defaults={}) => ({
type: ADD_COUNTER,
defaults,
})
// The following actions are for specific counters. By passing a counter itself
// as a reference, we can use it to specify which counter to operate on.
export const INCREMENT_VALUE = "INCREMENT_VALUE"
export const incrementValue = (counter) => ({
type: INCREMENT_VALUE,
counter,
})
export const DECREMENT_VALUE = "DECREMENT_VALUE"
export const decrementValue = (counter) => ({
type: DECREMENT_VALUE,
counter,
})
export const SET_VALUE = "SET_VALUE"
export const setValue = (counter, value) => ({
type: SET_VALUE,
counter,
value,
})
Reducers
the repeating reducer
import * as actions from '../actions'
// Remember the state must return an object or an array so it may be compared.
// Therefore, we wrap the value as state={value: 0} rather than using state=0
function Counter(state, action) {
// Default values go here
state = {value: 0, ...state}
switch (action.type) {
case actions.INCREMENT_VALUE:
// We spread the state (...state) here in case we add more properties to
// this state in the future.
return {...state, value: state.value + 1}
case actions.DECREMENT_VALUE:
return {...state, value: state.value - 1}
case actions.SET_VALUE:
return {...state, value: action.value}
default:
return state
}
}
export default Counter
The main reducer / collection
import * as actions from '../actions'
import CounterReducer from './counter'
function Counters(state=[], action) {
// Bubble actions through each counter in the array
state = state.map(counter => {
// An instance of counter is passed as reference for comparison. If true,
// we apply reduce further.
if (action.counter === counter)
return CounterReducer(counter, action)
else
return counter
})
switch (action.type) {
case actions.ADD_COUNTER:
var counter = CounterReducer({
// If we have default values to use, we add it here.
...action.defaults,
}, action)
return [...state, counter]
default:
return state
}
}
export default Counters
Usage
The following is an example usage of the above reducers and actions:
import React from 'react'
import ReactDOM from 'react-dom'
import { createStore, combineReducers } from 'redux'
import { Provider, connect } from 'react-redux'
import countersReducer from './reducers/counters'
import * as actions from './actions'
var store = new createStore(
combineReducers({
counters: countersReducer,
})
)
const mapStateToProps = (store) => ({
counters: store.counters,
})
const mapDispatchToProps = ({
addCounter: actions.addCounter,
incrementValue: actions.incrementValue,
decrementValue: actions.decrementValue,
setValue: actions.setValue,
})
class CounterView extends React.Component {
handleChange(e, counter) {
var value = parseInt(e.target.value, 10)
if (isNaN(value)) value = 0
this.props.setValue(
counter,
value
)
}
renderCounters() {
return this.props.counters.map((counter, i) => (
<div key={i} style={{ border: "1px solid black", float: "left" }}>
<h5>Counter</h5>
<button type="button"
onClick={e => this.props.decrementValue(counter)}>
-</button>
<button type="button"
onClick={e => this.props.incrementValue(counter)}>
+</button>
Value:
<input type="text" size="3"
value={counter.value}
onChange={e => this.handleChange(e, counter)}
/>
</div>
))
}
render() {
return (
<div>
<style type="text/css">{`* {margin: 2px; padding: 5px}`}</style>
<div>
<button type="button"
onClick={(e) => this.props.addCounter()}>
Add Counter</button>
<button type="button"
onClick={(e) => this.props.addCounter({ value: 25 })}>
Add Counter starting with 25</button>
</div>
<div>
{this.renderCounters()}
</div>
</div>
)
}
}
const CounterViewContainer = connect(
mapStateToProps,
mapDispatchToProps
)(CounterView)
ReactDOM.render(
<Provider store={store}>
<CounterViewContainer />
</Provider>,
document.getElementById('root'))