Basic State Reducer
In my experience, most reusable components start off fairly simple. A few different props to control overall behavior, or to customize something visually, but then can casually grow to an enormous size as more use cases are supported. As the surface area of the component expands, there's a larger footprint for the application that uses it, a more complex API that can confuse new consumers, and an additional combinations of props that need to be tested.
It makes sense at a certain point of complexity to instead allow the application to take on the additional overhead for extra edge cases if they need them, but how can we support that in a component API? We can use a state reducer!
From Redux with Love
Redux and its other counterparts are well-known for using reducers, but we can use reducers at the component level to provide a similar API for those using our components.
Let's imagine we have a simple Counter
component with state that increments a number when clicked and also keeps track of the number of times the button has been clicked. We're tracking these values with state (instead of just using props) so it'll have some default behavior without a user needing to configure the Counter
.
class Counter extends React.Component {
state = {
timesClicked: 0,
value: 0,
};
increment = () => {
this.setState((prevState) => ({
timesClicked: prevState.timesClicked + 1,
value: prevState.value + 1,
}));
};
render() {
let { timesClicked, value } = this.state;
return (
<div>
<p>Clicked {timesClicked} times</p>
<button type="button" onClick={this.increment}>
{value}
</button>
</div>
);
}
}
Typically, if a user wanted to change the behavior of our Counter
component, we could provide both an onChange
and value
prop for each displayed section and check if they've been provided. But then we'd need to determine when to switch to using the props to control the component, and later switching back to using component state isn't very intuitive.
Let's see what that functionality would look like using a state reducer:
const ACTION_TYPES = {
CHANGE_VALUE: 'CHANGE_VALUE',
CHANGE_TIMES_CLICKED: 'CHANGE_TIMES_CLICKED',
};
class Counter extends React.Component {
static propTypes = {
reducer: PropTypes.func,
};
static defaultProps = {
reducer: (state, action, changes) => changes,
};
static actionTypes = ACTION_TYPES;
state = {
timesClicked: 0,
value: 0,
};
dispatch = (action, changes, cb) => {
let { reducer } = this.props;
this.setState(reducer(this.state, action, changes), cb);
};
increment = () => {
let { timesClicked, value } = this.state;
this.dispatch(ACTION_TYPES.CHANGE_VALUE, { value: value + 1 });
this.dispatch(ACTION_TYPES.CHANGE_TIMES_CLICKED, {
timesClicked: timesClicked + 1,
});
};
render() {
let { timesClicked, value } = this.state;
return (
<div>
<p>Clicked {timesClicked} times</p>
<button type="button" onClick={this.increment}>
{value}
</button>
</div>
);
}
}
Our counter with a state reducer
takes one prop: the reducer prop which when called will receive the current state, the name of the action about to be applied, and the changes associated with that action. Instead of updating the state directly when the button is clicked, we dispatch
actions of the corresponding types and call setState
with our reducer
prop passed into the first argument.
A user can simply create their own reducer for the actions and return an updated changes
object to alter the state of the component. Here's what that could look like:
import Counter from 'counter-example';
function counterReducer(prevState, action, changes) {
switch (action) {
case Counter.actionTypes.CHANGE_VALUE:
return changes.value % 5 === 0 ? { value: changes.value * 5 } : changes;
case Counter.actionTypes.CHANGE_TIMES_CLICKED:
return changes.timesClicked % 10 === 0 ? { timesClicked: 0 } : changes;
default:
return changes;
}
}
We attach the action types to our component as a static property so a user can access the same list of actions wherever they create their component reducer. Documenting and creating action types that make sense for the component is important and there are different ways to organize them. For example, instead of breaking up the button click into two actions that update each individual property, we could consolidate it into one BUTTON_CLICKED
action and place both property changes into changes
. The method will depend on the type and needed flexibility of each component.
For a simple Counter
component, there's a lot to setup here and it can be a bit confusing to get started, but a more complex component with a lot of state can really benefit from using this pattern. Having a state reducer doesn't prevent the component from also having props for common settings, and I would recommend doing just that.
Including a state reducer in a reusable component allows the component to focus only on the common use cases and let the user worry about edge cases most users won't care about.