State Management in Pure React: The One with Class Components

One of the hardest things in building a complex javascript application is a state management. It's why there are so many state management libraries available and more coming around every day. As applications get more complex, the big challenge in the web application is to tame and control the state. And let the main job of React is to just take your application state and turn it into DOM nodes.

There are many kinds of state:

  • Model data: The nouns in our application
  • View/UI state: Are those nouns sorted in ascending or descending order?
  • Session state: Is the user even logged in?
  • Communication: Are we in the process of fetching the nouns from the server?
  • Location: Where are we in the application? which nouns are we looking at?

Or, it might make sense to think about state relative to time.

  • Model state: This is likely the data in our application.
  • Ephemeral state: Stuff like the value of an input field that will be wiped away then you hit "Enter". This could be the order in which a given list is sorted.

But every types of state can fall into one of two buckets:

  • Server Cache: stored on the server and we store in the client for quick-access (like unique user data).
  • UI State: Only useful in the UI for controlling the interactive parts of the app.

How to manage the state or data is not clear cut, and is a decision an engineer has to make.

We have to think deeply about what "state" even means in React application. How the class-based component state and hooks differ, how to incorporate APIs for navigating around prop-drilling, and how to use reducers for more advanced state management.

This article is part of the a small series of Managing State with Pure React.js.


Setting up basic basic components

Before React Hooks, class components were superior to function components because they could be stateful. With a class constructor, we can set an initial state for the component. Also, the the component's instance (this) gives access to the current state (this.state) and the component's state updater method (this.setState).

class App extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      searchTerm: 'React',
    };
  }

  render() {

    const { searchTerm } = this.state;
    return (
        <SearchForm
          searchTerm={searchTerm}
          onSearchInput={() => this.setState({
            searchTerm: event.target.value
          })}
        />
      </div>
    );
  }
}

Let's start with a super simple counter react application.

class Counter extends React.Component {
  render() {
    return (
      <main>
        <p>0</p>
        <section className="controls">
          <button>Increment</button>
          <button>Decrement</button>
          <button>Reset</button>
        </section>
      </main>
    );
  }
}

We'll start with a constructor method that sets the component state.

constructor(props) {
  super(props);
  this.state = {
    count: 0,
  };
}

We'll use that state in the component.

render() {
  const { count } = this.state;

  return (
    <main className="Counter">
      <p className="count">{count}</p>
      <section className="controls">
        <button>Increment</button>
        <button>Decrement</button>
        <button>Reset</button>
      </section>
    </main>
  );
}

Alright, now we'll implement the methods to increment, decrement, and reset the count.

increment() {
  this.setState({ count: this.state.count + 1 });
}

decrement() {
  this.setState({ count: this.state.count - 1 });
}

reset() {
  this.setState({ count: 0 });
}

We'll add those methods to the buttons.

<button onClick={this.increment}>Increment</button>
<button onClick={this.decrement}>Decrement</button>
<button onClick={this.reset}>Reset</button>

And we need to bind those event listeners.

constructor(props) {
  super(props);
  this.state = {
    count: 3,
  };

  this.increment = this.increment.bind(this);
  this.decrement = this.decrement.bind(this);
  this.reset = this.reset.bind(this);
}

State behavior in Class Based Components

Okay, let's say we refactored increment() as follows:

increment() {
  this.setState({ count: this.state.count + 1 });
  this.setState({ count: this.state.count + 1 });
  this.setState({ count: this.state.count + 1 });

  console.log(this.state.count);
}

If you see the console, it logs the new value and it only increments by one. Why? because react will translate the code above into this:

Object.assign(
	{},
	yourFirstCallToSetState,
	yourSecondCallToSetState,
	yourThirdCallToSetState,
);

This is because React is trying to avoid unnecessary re-renders. The React setState() method is asynchronous. React batches setState() calls and executes them eventually. Effectively, you are queuing up state changes. React will batch them up, figure out the result and then efficiently make that change.

Object.assign() method copies all enumerable own properties from the source objects to a target object {}, and it returns the target object.

There is actually a bit more to this.setState. Did you know that in its second version, you can pass a function to update the state?

this.setState((prevState, props) => {
  ...
});

So, we can refactor increment() to something like this:

this.setState(state => {
  return { count: state.count + 1 };
});

Or you could use use destructuring to make it evening cleaner.

increment() {
  this.setState(({ count }) => {
    return { count: count + 1 };
  });
  this.setState(({ count }) => {
    return { count: count + 1 };
  });
  this.setState(({ count }) => {
    return { count: count + 1 };
  });
}

When you pass functions to this.setState, it plays through each of them. The cool things about this is we could add some logic to our component. For example, let's make the counter to add in a maximum count as a prop.

render(<Counter max={10} />, document.getElementById('root'));
increment() {
  this.setState((state, props) => {
    if (state.count >= props.max) return;
    return { count: state.count + 1 };
  });
  this.setState((state, props) => {
    if (state.count >= props.max) return;
    return { count: state.count + 1 };
  });
  this.setState((state, props) => {
    if (state.count >= props.max) return;
    return { count: state.count + 1 };
  });
}

It turns out that we can actually have a second argument in there as well—the props.

You can also do it with arrow function, but it may not natively supported, you need a babel for this.

increment = () => {
	this.setState(increment);
	this.setState(increment);
	this.setState(increment);
}

The other thing we can do is pull that function out of the component. This makes it way easier to unit test.

const increment = (state, props) => {
  if (state.count >= props.max) return;
  return { count: state.count + 1 };
};
increment() {
  this.setState(increment);
}

Use cases of functions as input

Example above is one of the crucial cases where it makes sense to use a function over an object: when you update the state depending on the previous state or props. If you don't use a function, the local state management can cause bugs. One more advantage to use a function over an object is that function can live outside of the component.

const updateSearch = (hits, page) => (prevState) => {
  ...
};

class App extends Component {
  ...
}

The function instead of object approach in setState() fixes potential bugs, while improving the readability and maintainability of your code. Further, it becomes testable outside of the App component.

Callbacks

this.setState also takes a callback. It takes a second argument in addition to either the object or function. This function is called after the state change has happened.

Here is a simple thing that you can do:

this.setState(increment, () => (document.title = `Count: ${this.state.count}`));

Yes, you can implement a dynamic title or notification this way.

You can use callback to make side effect when the state changes. For example, when the this.state.counter is updated, you save and update the value in localStorage.

this.setState(increment, () =>
  localStorage.setItem('counterState', JSON.stringify(this.state)),
);

But, you can't pull that our along with increment.

const storeStateInLocalStorage = () => {
  localStorage.setItem('counterState', JSON.stringify(this.state));
};
increment() {
  this.setState(increment, storeStateInLocalStorage);
}

It's because the callback function doesn't get a copy of the state. To do it, we could wrap it into a function and then pass the state in. We could handle this a few ways, for example we can put it onto the class component itself.

storeStateInLocalStorage() {
  localStorage.setItem('counterState', JSON.stringify(this.state));
}

increment() {
  this.setState(increment, this.storeStateInLocalStorage);
}

Patterns and anti-patterns

When we're working with props, we have propTypes. That's not the case with state.

Don't use this.state for derivations of props, such as:

class SearchResult extends Component {
  constructor(props) {
    super(props);
    this.state = {
      searchTerm: props.term + ' in ' + props.category
    };
  }
}

Instead, derive computed properties directly from props themself.

class SearchResult extends Component {
  render() {
    const { term, category } = this.props;
    const fullTerm = term + ' in ' + category;
    return (
      <h1>{fullTerm}</h1>
    )
  }
}

And you don't need to shove everything into your render method. You can break things out into helper methods. And don't use state for things you're not going to render. Learn more on how to write resilient components here.

Stay up to date

Get notified when I publish something new, and unsubscribe at any time.