loading...
Cover image for Redux is half of a pattern (2/2)

Redux is half of a pattern (2/2)

davidkpiano profile image David K. 🎹 ・9 min read

I wrote a form library once.

Once.

It was called React Redux Form, and using Redux for forms was a good idea, at the time (don't use it). In fact, my library was written as a response to Redux Form, and both libraries soon discovered that the idea of using a single global store to store all of your application state is a really, really bad idea.

When all of your forms live in one single store, state is easy to manage at first. And then, every single keypress starts to lag. It's a terrible user experience.

So what do you do?

  • Blur inputs
  • Add debounced updates
  • Memoize everything
  • Optimize selectors everywhere
  • Make controlled components uncontrolled
  • Use React.memo() on components
  • Use PureComponent for good measure
  • Use Suspense (??)
  • etc. etc.

In short, you go into panic mode and try to contain the spread of the global updates affecting every single connected component, even if those components don't need to rerender.

Some of you have gotten really good at solving this, and have become expert "selector, caching, and memoization" developers. That's fantastic.

But let's examine if those tactics should even be necessary. What if all state wasn't global?

Local vs. global state

The first of Redux's three principles is that there is essentially a single source of truth for your whole application state:

The state of your whole application is stored in an object tree within a single store.

Source: Redux's three principles: single source of truth

The primary reason for this is that it makes many things easier, such as sharing data, rehydrating state, "time-travel debugging", etc. But it suffers from a fundamental disconnect: there is no such thing as a single source of truth in any non-trivial application. All applications, even front-end apps, are distributed at some level:

And, in a contradictory way, even the Redux Style Guide advises against putting the entire state of your application in a single store:

[...] Instead, there should be a single place to find all values that you consider to be global and app-wide. Values that are "local" should generally be kept in the nearest UI component instead.

Source: Redux Style Guide

Whenever something is done for the sole purpose of making something easy, it almost always makes some other use-case more difficult. Redux and its single-source-of-truth is no exception, as there are many problems that arise from fighting against the nature of front-end apps being "distributed" instead of an idealistic atomic, global unit:

  • Multiple orthogonal concerns that need to be represented in the state somehow.

This is "solved" by using combineReducers.

  • Multiple separate concerns that need to share data, communicate with each other, or are otherwise tangentially related.

This is "solved" by more complex, custom reducers that orchestrate events through these otherwise separate reducers.

  • Irrelevant state updates: when separate concerns are combined (using combineReducers or similar) into a single store, whenever any part of the state updates, the entire state is updated, and every "connected" component (every subscriber to the Redux store) is notified.

This is "solved" by using selectors, and perhaps by using another library like reselect for memoized selectors.

I put "solved" in quotes because these are all solutions that are all but necessary due to problems that are caused solely by using a global, atomic store. In short, having a single global store is unrealistic, even for apps that are already using global stores. Whenever you use a 3rd-party component, or local state, or local storage, or query parameters, or a router, etc., you have already shattered the illusion of a single global store. App data is always distributed at some level, so the natural solution should be to embrace the distribution (by using local state) rather than fighting against it just for the sake of making some use-cases easier to develop in the short run.

Acting differently

So how can we address this global state problem? To answer that, we need to go back in time a little bit and take some inspiration from another old, well-established model: the actor model.

The actor model is a surprisingly simple model that can be extended slightly beyond its original purpose (concurrent computation). In short, an actor is an entity that can do three things:

  • It can receive messages (events)
  • It can change its state/behavior as a reaction to a received message, including spawning other actors
  • It can send messages to other actors

If you thought "hmm... so a Redux store is sort of an actor", congratulations, you already have a basic grasp of the model! A Redux store, which is based on some single combined-reducer thing:

  • ✅ Can receive events
  • ✅ Changes its state (and thus its behavior, if you're doing it right) as a reaction to those events
  • ❌ Can't send messages to other stores (there's only one store) or between reducers (dispatching only happens outside-in).

It also can't really spawn other "actors", which makes the Reddit example in the official Redux advanced tutorial more awkward than it needs to be:

function postsBySubreddit(state = {}, action) {
  switch (action.type) {
    case INVALIDATE_SUBREDDIT:
    case RECEIVE_POSTS:
    case REQUEST_POSTS:
      return Object.assign({}, state, {
        [action.subreddit]: posts(state[action.subreddit], action)
      })
    default:
      return state
  }
}

Source: https://redux.js.org/advanced/async-actions#reducersjs

Let's dissect what is happening here:

  1. We're taking only the relevant slice of state we need (state[action.subreddit]), which should ideally be its own entity
  2. We are determining what the next state of only this slice should be, via posts(state[action.subreddit], action)
  3. We are surgically replacing that slice with the updated slice, via Object.assign(...).

In other words, there is no way we can dispatch or forward an event directly to a specific "entity" (or actor); we only have a single actor and have to manually update only the relevant part of it. Also, every other reducer in combineReducers(...) will get the entity-specific event, and even if they don't update, every single one of them will still be called for every single event. There's no easy way to optimize that. A function that isn't called is still much more optimal than a function that is called and ultimately does nothing (i.e., returns the same state), which happens most of the time in Redux.

Reducers and actors

So how do reducers and actors fit together? Simply put, a reducer describes the behavior of an individual actor:

  • Events are sent to a reducer
  • A reducer's state/behavior can change due to a received event
  • A reducer can spawn actors and/or send messages to other actors (via executed declarative actions)

This isn't a cutting-edge, groundbreaking model; in fact, you've probably been using the actor model (to some extent) without even knowing it! Consider a simple input component:

const MyInput = ({ onChange, disabled }) => {
  const [value, setValue] = useState('');

  return (
    <input
      disabled={disabled}
      value={value}
      onChange={e => setValue(e.target.value)}
      onBlur={() => onChange(value)}
    />
  );
}

This component, in an implicit way, is sort of like an actor!

  • It "receives events" using React's slightly awkward parent-to-child communication mechanism - prop updates
  • It changes state/behavior when an event is "received", such as when the disabled prop changes to true (which you can interpret as some event)
  • It can send events to other "actors", such as sending a "change" event to the parent by calling the onChange callback (again, using React's slightly awkward child-to-parent communication mechanism)
  • In theory, it can "spawn" other "actors" by rendering different components, each with their own local state.

Reducers make the behavior and business logic more explicit, especially when "implicit events" become concrete, dispatched events:

const inputReducer = (state, event) => {
  /* ... */
};

const MyInput = ({ onChange, disabled }) => {
  const [state, dispatch] = useReducer(inputReducer, {
    value: '',
    effects: []
  });

  // Transform prop changes into events
  useEffect(() => {
    dispatch({ type: 'DISABLED', value: disabled });
  }, [disabled]);

  // Execute declarative effects
  useEffect(() => {
    state.effects.forEach(effect => {
      if (effect.type === 'notifyChange') {
        // "Send" a message back up to the parent "actor"
        onChange(state.value);
      }
    });
  }, [state.effects]);

  return (
    <input
      disabled={disabled}
      value={state.value}
      onChange={e => dispatch({
        type: 'CHANGE', value: e.target.value
      })}
      onBlur={() => dispatch({ type: 'BLUR' })}
    />
  );
}

Multi-Redux?

Again, one of Redux's three main principles is that Redux exists in a single, global, atomic source of truth. All of the events are routed through that store, and the single huge state object is updated and permeates through all connected components, which use their selectors and memoization and other tricks to ensure that they are only updated when they need to be, especially when dealing with excessive, irrelevant state updates.

And using a single global store has worked pretty well when using Redux, right? Well... not exactly, to the point that there are entire libraries dedicated to providing the ability to use Redux on a more distributed level, e.g., for component state and encapsulation. It is possible to use Redux at a local component level, but that was not its main purpose, and the official react-redux integration does not naturally provide that ability.

No Redux?

There are other libraries that embrace the idea of "state locality", such as MobX and XState. For React specifically, there is Recoil for "distributed" state and the built-in useReducer hook that feels a lot like a local Redux, specifically for your component. For declarative effects, I created useEffectReducer which looks and feels just like useReducer, but also gives you a way to manage effects.

For state that needs to be shared (not globally), you can use a pattern that is very similar to what React-Redux already uses, by making an object that can be subscribed to (i.e., "listened" to) and passed down through context:

That will give you the best performance, as that "subscribable" object will seldom/never change. If that feels a bit boilerplatey for you and performance is not a huge concern, you can combine useContext and useReducer with not too much effort:

const CartContext = createContext();

const cartReducer = (state, event) => {
  // reducer logic
  // try using a state machine here! they're pretty neat

  return state;
};

const initialCartState = {
  // ...
};

const CartContextProvider = ({ children }) => {
  const [state, dispatch] = useReducer(cartReducer, initialCartState);

  return <CartContext.Provider value={[state, dispatch]}>
    {children}
  </CartContext.Provider>;
};

export const useCartContext = () => {
  return useContext(CartContext);
};

And then use it in your components:

const CartView = () => {
  const [state, dispatch] = useCartContext();

  // ...
};

Not too bad, right? In general, this is not a problem that can be solved in Redux without going against-the-grain, since Redux is fundamentally a single, atomic global store.

What do others think?

I ran a non-scientific poll on Twitter to see where most app state lives, and how developers feel about it:

Global vs. Local State poll

From this, I gather two things:

  • Whether you distribute state locally, or contain all state in a single store, you will be able to accomplish app state requirements successfully.
  • However, more developers are discontent with the majority of app state being global instead of local, which also might hint to why the majority of developers are happy using local state instead.

What do you think? Share your thoughts in the comments!

Conclusion

Thinking in terms of "actors", in which your application is organized by lots of smaller actors that all talk to each other by passing messages/events to each other, can encourage separation of concerns and make you think differently about how state should be localized (distributed) and connected. My goal for this post is to help you realize that not all state needs to be global, and that other patterns (such as the Actor Model) exist for modeling distributed state and communication flow.

The Actor Model is not a panacea, though. If you're not careful, you can end up having a spaghetti-like state management problem, where you have completely lost track of which actor is talking to another actor. Anti-patterns are present in any solution that you choose, so it helps to research best practices and actually model your app before you start coding.

If you want to learn more about the Actor Model, check out The Actor Model in 10 Minutes by Brian Storti, or any of these videos:

Please keep in mind that this is post reflects my opinions based on what I've researched, and is in no way meant to be authoritative on the way you should do things. I want to make you think, and I hope that this post accomplished that goal. Thanks for reading!

If you enjoyed this post (or even if you didn't and just want to hear more of my state management ramblings), subscribe to the Stately Newsletter for more content, thoughts, and discussion 📬

Photo by Steve Johnson on Unsplash

Posted on by:

Discussion

pic
Editor guide
 

Really great article. We need more thought pieces like this.

Honestly, I feel like a lot the problems that people have with libraries like React is that they doing state management wrong.

Give the people createContext and every bit of state goes into context. Create useState and everyone does spaghetti state code.

As an industry we need to have better education and best practices on state management.

That's why a lot of people are resonating with xState. More clarity and some good old CS to back it up.

Recoil also looks interesting, but I fear it will fall to the same trap as Redux. Everyone will use it even when they don't need it. Then they'll complain about it being too complicated.

There is a way forward and that's by having proper conversations about where to go from here. For example your talks radically shifted my point of view on state management and a lot of it came from your in-depth reasoning. I'm sure many others have felt the same.

In my opinion we've come a long way in past decade. But we're definitely needing more articles like this to go further.

 

Right now It's difficult for me to find a good use case for using any external state management library.
We have learned that keeping state of the forms in redux is a bad idea.
I used to see a lot of people using redux as a cache for api responses, but things like swr are so much better for this.
Another reason was to allow the state live longer than the component using it - but for that it's usually better to persist it in the localStorage.
And for the "global" state in case of react built in context works great.

 

The issue for me has always been that if you make some subset of state local to a component, then that component also has to handle all logic relating to that slice of state.

Take a concrete example - a button which makes an api call. We want to display the loading state with some change of appearance in the button. If we hold the loading state local to the button component - then what's making the api call? How does the button component become notified of changes to the loading state? It either needs to be complexly wired up to some external code - in which case, why not just hold the state externally? Or else it has to have all the api-call code within the button component itself. The latter option seriously reduces the reusability of the component.

Strictly speaking, isolating state must mean that all logic surrounding that state may only be performed within the isolated component. I don't mean to say that this is necessarily a bad thing - I just haven't found many examples where state is truly isolated in this way.

I've found the best option to be injecting state as props (or, if we prefer, arguments of a function). Then we can manage state however we want, and we aren't letting the component make any decisions at all about where state should be managed. In my opinion, front end components have no business managing state - state is a far more complex than we ought to want to have in a UI component - let it be managed elsewhere, by whatever means is deemed appropriate for that use case. I don't mean to say that this is always, or ever, a global store, or Redux.

I really enjoyed this article and certainly found it thought-provoking; I had an issue with one part though. You wrote:

"Whenever something is done for the sole purpose of making something easy, it almost always makes some other use-case more difficult. Redux and its single-source-of-truth is no exception"

I agree that making things easy often leads to making other things more difficult. But I don't agree that Redux makes things easy. One of the chief arguments against Redux is that it makes all parts of interacting with state far more difficult; granted, it makes it very easily to blindly inject state once we've already got the boilerplate, but it makes it a great deal more difficult to add state in the first place - this to me is a big pro of using Redux.

 

My thoughts are that the single responsibility pattern trumps everything. SRP leads to small component parts where state is intrinsic to the component. If the component needs a state it doesn't have injected, then is can directly request and receive it at will. No top down state propagation needed. Containers then rely on each component to do the right thing. Container state is to where needed inject state to child component interfaces but that model should be rare. Cross component intercommunication must be simple to achieve.

State itself is not a separate concern it should be fully handled within the (small) components that only do one thing and are bulletproof.

Forget reducers, and redux or ngRx. They are not needed and as is being determined after all these years introduce complexity and other issues.

 

A little bit off-topic, but, what do you guys think about how other frameworks manage state? Such as Stimulus

Stimulus also differs on the question of state. Most frameworks have ways of maintaining state within JavaScript objects, and then render HTML based on that state. Stimulus is the exact opposite. State is stored in the HTML, so that controllers can be discarded between page changes, but still reinitialize as they were when the cached HTML appears again

If I understand it right, Stimulus is typically best suited for SSR frameworks (i.e. Ruby on Rails). So, if state is attached to HTML and HTML is rendered at the server ... does that mean that state is managed at the server? If that is true, there is something that feels wrong about it, but I am not sure what.

What do you guys think?