To Redux or Not: the Art of Structuring State in React Apps
One common trend I find among most Redux developers is a hatred towards setState()
. A lot of us (yes, I’ve fallen into this trap many times before) flinch at the sight of setState()
and try to keep all the data in our Redux store. But, as the complexity of your application grows, this poses several challenges.
In this post, I’ll walk you through various strategies to model your state, and dive into when each of them can be used.
Getting Started
Redux works with the principle of being the single source of truth for your application state. A new Game of Thrones season is airing now, and I’m sure everyone’s excited to know how this is going to unfold. Let’s build a fun Game of Thrones fan listing page, to understand these concepts in detail.
Note: I’ll be using yarn
to run the app. If you don’t have yarn set up, replace yarn with npm
.
Before we dive in, download the basic skeleton from the repo and run:
yarn install
yarn run start
You should see a basic list page with some of your favorite GoT characters listed.
Note: We’ll be using the ducks pattern to write our application. It reduces unnecessary module imports and cuts down on a lot of boilerplate.
Intro to Redux
The scope of this article is to help you structure your Redux apps. It assumes a basic knowledge of the library. I’ll give a brief overview of Redux concepts that will help you follow the rest of the article better. If you’re familiar with how these works, feel free to skip this section.
All Redux apps make use of four important constructs: actions, reducers, a store, and containers.
Actions
An action is an intent to update the state. It could be triggered by a network call, or a user clicking a button. Actions have two parts:
- Action type. A unique identifier representing an action.
- Payload. Any metadata that’s associated with the action. For instance, if we make a network request to fetch a list of movies, the response from the server is the payload.
For this example, we’ll be using a library called redux-actions
to create actions.
Reducers
A reducer is a function that listens for an action and returns a new state representation.
Store
An application can be divided into many reducers, representing various parts of the page. A store brings all these together and keeps the app state intact.
Containers
Containers connect your app state and actions with the component, passing them down as props.
To get a deep understanding of how this works, I’d encourage you to first look at the free introduction series by Dan Abramov.
Split App Data and UI State
The list page is nice, but the names don’t give any context to people who are new to the GoT universe. Let’s extend the component to render the character description as well:
//GoTCharacter.js
export const CharacterRow = ({character}) => (
<div className="row">
<div className="name">{character.name}</div>
<div className="description">{character.description}</div>
</div>
);
While this solves the problem, our designers feel that the page looks clumsy, and it’s a better idea to collapse this information till users want it. There are three different approaches we can take to solve this problem.
The setState
approach
The simplest way to achieve this in React is using setState()
to store the data within the component itself:
//GoTCharacter.js
export class StatefulCharacterRow extends Component {
constructor() {
super();
this.state = {
show_description: false
}
}
render() {
const {character} = this.props;
return (<div className="row">
<div className="name">{character.name}</div>
<a href="#" onClick={() => this.setState({
show_description: !this.state.show_description})} >
{this.state.show_description ? 'collapse' : 'expand'}
</a>
{this.state.show_description &&
<div className="description">{character.description}</div>}
</div>);
}
};
The Redux approach
Using setState()
is fine as long as the state we’re dealing with is only local to the component. If, for instance, we want to put in place an “expand all” function, it will be difficult to handle this with just React.
Let’s see how we can move this to Redux:
// FlickDuck.js
// …
export const toggleCharacterDescription = createAction(
FlixActions.TOGGLE_CHARACTER_DESCRIPTION, (character) => ({character})
);
export default (current_state, action) => {
const state = current_state || default_state;
switch (action.type) {
case FlixActions.TOGGLE_CHARACTER_DESCRIPTION:
return {...state, characters: state.characters.map(char => {
if (char.id === action.payload.character.id) {
return {...char,show_description: !char.show_description};
}
return char;
})}
default:
return state
}
}
// GoTCharactersContainer.js
import { connect } from 'react-redux';
import GoTCharacters from './GoTCharacters';
import {toggleCharacterDescription} from './FlickDuck';
const mapStateToProps = (state) => ({
...state.flick
});
const mapDispatchToProps = (dispatch) => ({
toggleCharacterDescription : (data) => dispatch(toggleCharacterDescription(data))
});
export default connect(mapStateToProps, mapDispatchToProps)(GoTCharacters);
// GoTCharacters.js
const GoTCharacters = ({characters,toggleCharacterDescription}) => {
return (
<div className="characters-list">
{characters.map(char => (
<CharacterRow
character={char}
toggleCharacterDescription={toggleCharacterDescription}
key={char.id}/>
))}
</div>
);
};
export const CharacterRow = ({character, toggleCharacterDescription}) => (
<div className="row">
<div className="name">{character.name}</div>
<a href="#" onClick={toggleCharacterDescription.bind(null, character)} >
{character.show_description ? 'collapse' : 'expand'}
</a>
{character.show_description &&
<div className="description">{character.description}</div>}
</div>
);
We’re storing the state of the description field inside the character object. Our state will look like this now:
state = {
characters: [{
id: 1,
name: "Eddard Ned Stark",
house: "stark",
description: "Lord of Winterfell - Warden of the North - Hand of the King - Married to Catelyn (Tully) Stark",
imageSuffix: "eddard-stark",
wikiSuffix: "Eddard_Stark",
show_description: true
},
{
id: 2,
name: "Benjen Stark",
house: "stark",
description: "Brother of Eddard Stark - First ranger of the Night's Watch",
imageSuffix: "benjen-stark",
wikiSuffix: "Benjen_Stark",
show_description: false
}]
}
This is a general pattern a lot of developers follow when they’re starting out with Redux. There’s nothing wrong with this approach, and it works great for smaller apps.
So far, we’ve been dealing with the characters from the first chapter of GoT, and the universe is about to get a whole lot bigger. When it does, our app will become slow. Imagine looping through 1000 characters to update one row.
Let’s see how to scale this for a larger dataset:
// FlickDuck.js
// …
case FlixActions.TOGGLE_CHARACTER_DESCRIPTION:
const {character} = action.payload;
return {
...state,
character_show_description: {
...state.character_show_description,
[character.id]: !state.character_show_description[character.id]
}
}
// …
And in GoTCharacters.js
:
export const CharacterRow = ({character, character_show_description, toggleCharacterDescription}) => (
<div className="row">
<div className="name">{character.name}</div>
<a href="#" onClick={toggleCharacterDescription.bind(null, character)} >
{character_show_description[character.id] ? 'collapse' : 'expand'}
</a>
{character_show_description[character.id] &&
<div className="description">{character.description}</div>}
</div>
);
When the user clicks on the expand link, we update the character_show_description
with the current character id. The state looks like this now:
state = {
characters: [...],
character_show_description: {
1: true,
2: false
}
}
Now we can update the UI state without looping over all the characters.
Managing Form State in Redux
Managing form state is a tricky business. In a typical application, we’ll serialize the form data once during submit and, if it’s valid, submit it. Otherwise, we’ll show an error message. Easy-peasy, right?
But, in the real world, we’ll have some complex interactions involving forms. When there is a validation error on a form, we may have to show the errors at top of the page. We may even need to disable some elements in the other part of the page, depending on the UX. This is usually achieved by passing random callbacks from your parents’ parents’ parent, or even manipulating the DOM with every validation.
Let’s see how we can implement this with Redux:
// FlickDuck.js
// ============
const FlixActions = km({
FETCH_CHARACTERS: null,
TOGGLE_CHARACTER_DESCRIPTION: null,
TOGGLE_CHARACTER_EDIT: null,
SYNC_CHARACTER_EDIT_DATA: null,
SAVE_CHARACTER_EDIT: null
});
const default_state = {
characters: characters,
character_show_description: {},
show_character_edit: {},
character_edit_form_data: {}
};
export const toggleEdit = createAction(
FlixActions.TOGGLE_CHARACTER_EDIT, (character) => ({character})
);
export const syncCharacterEditData = createAction(
FlixActions.SYNC_CHARACTER_EDIT_DATA, (character, form_data) => ({character, form_data})
);
export const editCharacterDetails = createAction(
FlixActions.SAVE_CHARACTER_EDIT, (character) => ({character})
);
export default (current_state, action) => {
// …
switch (action.type) {
// …
case FlixActions.TOGGLE_CHARACTER_EDIT:
character = action.payload.character;
const show_character_edit = !state.show_character_edit[character.id];
return {
...state,
show_character_edit: {
...state.show_character_edit,
[character.id]: show_character_edit
}, character_edit_form_data : {
...state.character_edit_form_data,
[character.id]: show_character_edit ? {...character} : {}
}
}
case FlixActions.SYNC_CHARACTER_EDIT_DATA:
character = action.payload.character;
const {form_data} = action.payload;
return {
...state,
character_edit_form_data: {
...state.character_edit_form_data,
[character.id]: {...form_data}
}
}
case FlixActions.SAVE_CHARACTER_EDIT:
character = action.payload.character;
const edit_form_data = state.character_edit_form_data[character.id];
const characters = state.characters.map(char => {
if (char.id === character.id) return {...char, name:edit_form_data.name, description: edit_form_data.description}
return char;
});
return {
...state,
characters,
show_character_edit: {
...state.show_character_edit,
[character.id]: false
}
}
// …
}
}
// GotCharacters.js
export const CharacterRow = ({character, character_show_description, character_edit_form_data, show_character_edit, toggleCharacterDescription, toggleEdit, syncCharacterEditData, editCharacterDetails}) => {
const toggleEditPartial = toggleEdit.bind(null, character);
return (<div className="row">
<div className="name">{character.name}</div>
<a href="#" onClick={toggleCharacterDescription.bind(null, character)} >
{character_show_description[character.id] ? 'collapse' : 'expand'}
</a>
{!character_show_description[character.id] && <a href="#" onClick={toggleEditPartial} >
edit
</a>}
{character_show_description[character.id] &&
<div className="description">{character.description}</div>}
{show_character_edit[character.id] &&
<EditCharacterDetails character={character}
cancelEdit={toggleEditPartial}
syncCharacterEditData={syncCharacterEditData}
editCharacterDetails={editCharacterDetails}
edit_data={character_edit_form_data[character.id]}/>
}
</div>);
}
export const EditCharacterDetails = ({character, edit_data, syncCharacterEditData, editCharacterDetails, cancelEdit}) => {
const syncFormData = (key, e) => {
const {value} = e.currentTarget;
syncCharacterEditData(character, {
...edit_data,
[key]: value
});
};
const saveForm = (e) => {
e.preventDefault();
editCharacterDetails(character);
};
return (
<form onSubmit={saveForm}>
<label>Name: </label>
<input name='name' value={edit_data.name} onChange={syncFormData.bind(null, 'name')}/>
<label>Description:</label>
<textarea name='description' value={edit_data.description} onChange={syncFormData.bind(null, 'description')}/>
<button type="reset" onClick={cancelEdit}> Cancel </button>
<button type="submit"> Submit </button>
</form>
);
};
Let’s extend this to handle validations:
// FlickDuck.js
// ============
export const editCharacterDetails = createAction(
FlixActions.VALIDATE_AND_SAVE_CHARACTER_EDIT, (dispatch, character, edit_form_data) => {
const errors = validateCharacterForm(edit_form_data);
if (Object.keys(errors).length) {
return dispatch(showErrorMessage(character, errors));
}
return dispatch(saveCharacterEdit(character));
}
);
export const showErrorMessage = createAction(
FlixActions.VALIDATE_CHARACTER_EDIT, (character, errors) => ({character, errors, hasError: true})
);
export const saveCharacterEdit = createAction(
FlixActions.SAVE_CHARACTER_EDIT, (character) => ({character})
);
switch (action.type) {
// …
case FlixActions.VALIDATE_CHARACTER_EDIT:
character = action.payload.character;
const {errors, hasError} = action.payload;
return {
...state,
character_edit_form_errors: {
...state.character_edit_form_errors,
[character.id]: {errors, hasError}
}
}
// …
}
Isn’t this very similar to the example we saw in the previous section? What’s so special about it in forms?
Before jumping into this, it’s important to understand how Redux internals work. When your state changes, you don’t update a single point in the tree. Instead, the entire state tree is replaced by a new one. This tree is passed to your React component and React reconciles all the components to see if the DOM needs updating.
Form state is special, because the state tree changes very quickly. Depending on the users’ typing speed, that could be a problem. Since state changes trigger reconciliation of all nodes, there might be a small lag when users type. It can become very noticeable when dealing with a big page with a few hundred components.
Let’s see how we can remodel this without making a big change:
export class StatefulCharacterRow extends Component {
constructor() {
super();
this.toggleEditForm = this.toggleEditForm.bind(this);
this.syncCharacterEditData = this.syncCharacterEditData.bind(this);
this.state = {
show_description: false,
show_edit_form: false,
edit_data: {}
}
}
toggleEditForm() {
const {name, description} = this.props.character;
const show_edit_form = !this.state.show_edit_form;
const edit_data = show_edit_form ? {name, description} : {};
this.setState({show_edit_form, edit_data});
}
syncCharacterEditData(character, form_data) {
this.setState({
edit_data: {...this.state.edit_data, ...form_data}
});
}
render() {
const {character} = this.props;
return (<div className="row">
<div className="name">{character.name}</div>
<a href="#" onClick={() => this.setState({
show_description: !this.state.show_description})} >
{this.state.show_description ? 'collapse' : 'expand'}
</a>
{!this.state.show_edit_form && <a href="#" onClick={this.toggleEditForm} >
edit
</a>}
{this.state.show_description &&
<div className="description">{character.description}</div>}
{this.state.show_edit_form &&
<EditCharacterDetails character={character}
cancelEdit={this.toggleEditForm}
syncCharacterEditData={this.syncCharacterEditData}
editCharacterDetails={this.props.editCharacterDetails}
edit_data={this.state.edit_data}/> }
</div>);
}
};
The simplest way to handle this is to create a wrapper component around your form (think of it like a container) and store the state there. So, when users input changes, only this node gets updated without shaking the entire tree.
Notice that we’ve only moved the form state inside React, but the error state still remains outside. This will help reduce unnecessary clutter if we ever want to handle these errors outside of the form scope.
Wrapping Up
Before deciding on where to store state when using Redux, it would be helpful to understand the following scenarios:
1. Is this UI state, or application state?
Character name is application state, whereas tracking whether an action is in progress is UI state. While it’s tempting to couple them, in the long run it’ll pay off to keep them separate.
state = {
characters: [{
id: 1,
name: Jon Snow,
…
}],
ui_state: {
1: {
is_edit_in_progress: true,
show_description: false
}
}
}
2. How to decide what goes in component state and what goes in Redux
Usually, app data can be rendered many times on a page. For instance, we can render a list of all characters and show a count of characters grouped by the house they belong to. It makes sense to manage them in Redux.
Store UI state in Redux if there is a global dependency. Otherwise, you’re better off handling it with React’s local component state.
Redux has helped me structure my thoughts better. With jQuery/Backbone, my focus was around how to manipulate the DOM to achieve the intended effect. With Redux, it’s about getting your application state right. Once you nail that, the complexity of your front-end codebase comes down significantly.