Nadeesha Cabral

E-mailLinkedInGithub

Trade-offs in React state management

June 27, 2020

I’ve been trialing out the state management strategies for React ever since the good old flux and backbone MVC days. I’ve seen projects go through adopting Redux, migrating some of that state to GraphQL-client based tools, and sometimes move into the Observables route or more novel ways of managing state.

With the amount of solutions out there, I feel like maybe it’s time to step back and analyse the problem a little bit in detail to see what trade-offs we can make consciously.

Client-side state problem(s)

The more frontend work I get involved with, more I think that we’re really trying to solve 3 different problems.

![[Pasted image.png]]

External State (or synchronized External State with App State)

You’ve got an REST/GraphQL API with some state residing on the server. Or you’ve got some state in the user’s computer via localStorage. Whichever way it goes, at the runtime of your application, it must translate into query-able piece of data that is accessible across the application.

If you application does not change this state over time, then the problem is slightly easier because you can always query the data for the latest copy. If it does change, then it becomes more challenging:

  1. How does the external (server-side, perhaps?) copy of the same data be kept in sync with the client copy of the data?
  2. Should we wait until the external state gets updated to reflect the changes in the client? If so, what are the usability implications of that? (Ex: a slow server call will make your client seem laggy)
  3. If you update the client optimistically, what if the server rejects your update? How do you “roll-back” to the previous state and ensure the client data doesn’t get lost?

Solutions

There’s no one-size fits all solution here. It all depends on the different trade-offs you want to make.

  • A GraphQL client library like apollo-client solves most of these problems by giving your application a uniform interface to read and update data as if it were “local state” by managing a subset of the server state locally for you. But you’re increasing the complexity of a backend application by introducing a GraphQL layer, and additional GraphQL types.
  • A REST API with some client-side state management gives you a lot of control, but as your project grows, you end up writing and maintaining a lot of code. To manage side-effects you might introduce something like redux-saga.

Local State

Local State, is the state that is local to a React Component. And I think it is mostly a solved problem these days. This wasn’t the case a few years ago when we had to do this.setState and “handler fatigue” with onChange={this.onTextUpdated} littered all over the code-base.

The problems in this part of the software is different:

  1. Since local state is heavily used and it is independent to each component, how do you make sure developers code against a consistent set of rules?
  2. How do you model your state efficiently so you don’t waste render cycles?
  3. Since local state tends to balloon over time, what abstractions can I use to write concise code?

Solutions

Correct usage of functional components and react hooks have definitely solved most of these problems these days, but it’s definitely worth noting that knowing the internals of how react hooks operate cannot hurt because it’s somewhat of a leaky abstraction. That is, at a certain level, you can’t reason about them as just functions that magically manage state for you.

  • Whenever I have to introduce “clever” constructs like react hooks to my code base, I always rely on static analysis tools as the cost of a misuse would mean multiple wasted hours of dev time.
  • To keep the usage of hooks consistent, and to influence the code to be written using hooks wherever possible, I recommend using a libraries that that abstract side-effects away into hooks.

Shared State between Components

This is in my opinion the most challenging state to model, purely because:

  1. It relies very heavily on the specific use cases of your app.
  2. The rate of change for the code is very high.

Therefore, after working in this problem for a few years, I’m convinced that there will never be a “one size fits all” solution here.

Solutions

One App, one state

One option is to have a top level component of your application (let’s call it <App />), and declare all of your shared state using useState there.

const App = () => {
  const [appState, setAppState] = useState({ foo: "bar" });

  return (
    <Container>
      <TodoHeader state={appState} setState={setAppState} />
      <TodoItems state={appState} setState={setAppState} />
    </Container>
  );
};

const TodoItems = ({ appState, setAppState }) => {
  return (
    <Container>
      <TodoList state={appState} setState={setAppState} />
      <TodoStats state={appState} setState={setAppState} />
    </Container>
  );
};

This is not the worst idea in the world, if your is fairly simplistic and your component hierarchy is shallow. But as your application grows complex, it’d be increasingly harder to figure out which components modify which data at which time, as all of the components in your application gets access to all the parts of your state, implictly. And because of the way that React renders things after a prop change, a change in your TodoHeader will trigger a re-render in TodoStats even if it’s unrelated.

Now, one way around managing this complexity and avoiding the re-renders would be to:

  1. Be very explicity about mutating state.
  2. That is, don’t mutate state from within the component directly, but instead, fire off an event that will trigger some kind of function that will change the state.
  3. Re-use the events across your application without duplicating mutation logic, and centralize the functions that mutate state in a centralized, managed location.
  4. Be very explicit about querying the state.
  5. Don’t consumer the whole state in the component, but rather, listen to a subset of it.
  6. This will lower the “prop footprint” of your components, and avoid unneccary renders. (Renders that don’t constitue to a meaningful change in the JSX output of a component)

Redux and redux variants

If you’re familiar with redux you might note that this is exactly what redux does. Try replacing “mutation function” with “reducer”. Also components only firing off events, and “listening” to state changes ensures one-way data flow.

In fact, I think the reason most applications find Redux very cumbersome and hard to manage is due to the reducer functions and the state tree being split out and over abstracted too early. This is just my opinion, but I think the reason that happens in a lot of applications is due to the fact that over a number of years, the core problem we tried to solve with redux - the mishmash of appState and setAppState being littered across the application, is lost on us.

Redux, MobX and it’s variants all build on the same principle of one-way data flow.

Using the React Context

React Context gives a convenient mechanism for developers to “hoist” state to the top of the application so that it’s shared across the application. Many popular libraries such as react-router uses the context API to provide information about the “global state” of the application - such as which route is active, etc.

It’d be really convenient, if there was some way for us to define independent pieces of appState which are mutable, and make it accessible throughout the application using React Context. If we model this state using already existing state management tools such as useState and other hooks, the cost of establishing this state would be fairly low and we won’t be paying as a higher price as we’d do in Redux to split out the state early.

Plus, we can signal which parts of our application uses which parts of the state if we use the React Context providers. And if we keep these pieces of state independent and small, we wouldn’t need to worry about definite a lot of boiler plate code such as event, event creators, reducers to manage the complexity.

I’ve been having a lot of success with constate doing exactly this, for a certain class of applications.

It optimizes for starting off with smaller independent pieces of shared state which can be combined as the application grows, or split down further. But maintainability of these independent pieces of state becomes challenging without a type system (I use Typescript) and some tests to assert the runtime behavior. Whether a component has access to the context that is hoised depends on whether a component has an ancestor which initializes the ReactContext.Consumer.

![[Blank Diagram - Page 2 (1).png]]

Once again it’s a trade-off between sligtly more cognitive load for the developer (due to the novel use of React.Context), and the amount of code that needs to be maintained (or the lack thereof in this case).

The broader picture

The broader picture with the “shared state” part of our problem is that there are two ways to go on about it. Do you make the trade-offs on the side of consistent one way data flow, or the side of smaller manageable pieces of independent state that evolve on their own?

I reason about it as follows: As your application grows bigger, and if the footprint of your state is correlated with the features you have to deliver, you will start to reap the benefits of one-way data flow. It is my opinion, as the state gets bigger, consistent conventions of managing state, are more attractive over abstractions that reduce the amount of code.