Javier Casas

A random walk through computer science

3 Common Advanced React/Redux mistakes

On the previous articles we talked about things that were black/white. There was little discussion there. If you did these things, you were most likely wrong.

Now, we enter into controversial territory. These issues are not right/wrong, but more like finding a good tradeoff, and sometimes too much of a good thing can be bad. Let's have a look:

Dispatching dynamic actions

This is probably the least controversial issue. If you dispatch actions whose type is generated dynamically, you are going to have several problems:

  • You won't figure out why no reducer acts on that action, until you figure out that the type is subtly different to what you want it to be.
  • You will be confused on how a reducer gets triggered when you search the whole codebase for the corresponding action type, only to find nothing.
  • There is a chance that the dynamically generated action will be like another previous action, and this is going to cause lots of amusement.

If you have something like:

dispatch({type: `UPDATE_${fieldname}`, value})

Even if you save some lines of code, you are going to have trouble.

But there is a way to fix it that doesn't require big changes: make the dynamic part into another field in the action.

dispatch({type: "UPDATE_FIELD", fieldname, value})

Now your reducer can be kept simple, as it will capture the UPDATE_FIELD action and internally switch using the fieldname entry, which is pretty obvious and simple code.

Coupling your actions to your reducers

This sounds wrong, but it isn't. Your reducers have to be inevitably coupled to your actions, as every reducer branch is expected to target an action, so every reducer has to know about the actions it wants to receive. But not the other way around. There is some "common wisdom" that states that for every action we go and create a reducer branch. This is not needed, and it affects the way you think about Redux.

Redux is a global message bus/pub-sub mechanism that allows different parts of the app to communicate. With the basic React/Redux infrastructure, the mechanism allows sending messages (called actions) and receiving them, as long as the receiver is a reducer and all it does is modify the store. But this picture changes when you start adding middlewares. If you add redux-saga, the sagas now offer ways to receive specific messages and do operations upon receiving actions. And now, it looks like a general pub-sub with emitters (whoever calls dispatch) and receivers (reducers and sagas that listen for messages). But, even without sagas, for every action, there may be:

  • One reducer
  • Many reducers - when you want several reducers to act on the same action
  • Zero reducers - when you have another middleware receiving the action, or when you want to ignore an action

An implicit there is that in a given Redux application, all the reducers receive the action, but only some of them do something with it! You can see it on the standard reducer boilerplate code:

function userReducer(state=initialState, action) {
  switch(action.type) {
    case "USER_ACTION_1":
      ...
    case "USER_ACTION_2":
      ...
    default:
      // Why is this 'default' branch needed?
      // How does this even get triggered?
      // The only possibility is that this reducer gets called
      // with actions that are not specified in the 'case' statements above.
      // But the Redux middleware should know nothing about the specific details of the actions of our app
      // and the reducers they target.
      // Therefore, this `default` branch must be called with all actions that get dispatched in the app
      // but are not caught by the 'case' statements above.
      // Therefore, all the reducers must be called with all actions that get dispatched.
      return state
  }
}

A classic example is often seen in applications with a notification system. You have an application that does something, and when it completes it, it shows a notification box with a little cross to close it on the upper right part of the page. For example, it could be buying shares in a stock-trading app. You put the buy order, which gets sent to the brokerage, and eventually may (or may not) succeed. When it succeeds, your list of open buy orders gets updated to reflect that this order has been completed, and you get a nice little box in real time showing that you bought the shares. Let's see how we could implement this:

function buyShares(...) {
  dispatch({type: "BUY_SHARES_REQUEST", ticker, price, amount})
  return fetch(...)  // call API to buy
    .then(order => {
      dispatch({type: "BUY_SHARES_SUCCESSFUL", order.ticker, order.price, order.amount})
      dispatch({type: "SHOW_NOTIFICATION", severity: "SUCCESS", message: `...` })
    })
}

The issue here is that we are dispatching two actions for when an event happens (successfully bought shares), so it looks like a minor case of "dispatching many actions for a single event". But, at least, we are not dispatching an action for each of the 10000 shares we bought. Still, there are details that are wrong in a subtle way:

  • We dispatch two actions, this is less efficient than dispatching one action.
  • One of the actions is about an effect derived from the event (showing a notification).
  • We are thinking in a pub-sub system about one system that tells another system what to do, instead of the other system reacting to an event that happened.

The notification system in the example above is a bit too dumb for its own good, and as a result of that, the buyShares function has to compensate, and has to start doing part of the work of the notification system: notifying of significant events. But, if we follow the option of having multiple reducers for the same action, everything can become more clear:

function ordersReducer(state=initialState, action) {
  switch(action.type) {
    case "BUY_SHARES_REQUEST":
      // Add the request to the list of open orders
    case "BUY_SHARES_SUCCESSFUL":
      // Modify the list of open orders and mark the corresponding order as completed
    default:
      return state
  }
}

function notificationsReducer(state=initialState, action) {
  switch(action.type) {
    case "BUY_SHARES_SUCCESSFUL":
      // Add a notification indicating success
    case "TRADING_DAY_CLOSED":
      // Add a notification indicating no more operations possible
    case "OTHER_SIGNIFICANT_EVENT":
      // Notify of other significant event
    default:
      return state
  }
}

Now the notificationsReducer has the responsibility of figuring out what events are worth being notified, and buyShares only has to deal with buying shares and shouting to Redux that has started and succeeded at buying shares.

function buyShares(...) {
  dispatch({type: "BUY_SHARES_REQUEST", ticker, price, amount})
  return fetch(...)  // call API to buy
    .then(order => {
      dispatch({type: "BUY_SHARES_SUCCESSFUL", order.ticker, order.price, order.amount})
      // No more SHOW_NOTIFICATION, that's the notification's business, not ours.
    })
}

This change of paradigm may cause some trouble, as we now decouple the actions we dispatch from the effects they trigger. You may dispatch an action and see it causing changes in places you didn't want to change. So there is a tradeoff to be aware.

Assuming that Redux stores all the state in the app

You should be already limiting the state that your store manages, for example, ignoring minor details in forms. But, it is very tempting to assume that all the state worth of significance is in Redux. Therefore, we can do things such as serialize the state, save it to LocalStorage, send it over the network, then paste it back into Redux, and the app is back in the previous state. Well, at least, you should be making your state serializable, and not putting functions inside the state (which is too tempting as a FP advocate). But still, there are bits of the state that are not in Redux. Let's go to the buyShares example:

function buyShares(...) {
  dispatch({type: "BUY_SHARES_REQUEST", ticker, price, amount})
  return fetch(...)  // call API to buy
    .then(order => {
      dispatch({type: "BUY_SHARES_SUCCESSFUL", order.ticker, order.price, order.amount})
    })
}

So we have a promise that dispatches an action (and we can assume to modify the store) when it starts, and then dispatches another action (and modifies the store) when it completes. What happens if, between start and completion, we take the store, stop the app, ship the store to another machine, and restart the app there with the store? Well, the store will have noted that we ran BUY_SHARES_REQUESTED. But the promise that eventually would call BUY_SHARES_SUCCESSFUL didn't get shipped, and as a result, we will never call BUY_SHARES_SUCCESSFUL. So our app will have the order open forever.

Which means you have to think a bit deeper on using the store for restoring a specific state, especially if you want to support SSR where the server sends the state to the frontend and assumes it will work. This is especially bad with middlewares, as middlewares are likely to have even more effects and internal state, and that internal state will be related to Redux, but not inside the store. If you are not careful with this, refreshing your app (F5) on different pages or sections can have subtle bugs for your users to find, such as "the saga was never started, therefore the button dispatched the action, but no one listened or acted on it".

When dealing with SSR and reload, you have to implement an initialization protocol that resumes the app back in a working situation. Because this working situation is likely to be a small state set, you have to define operations and protocols to guide the state, so that all the corresponding operations and listeners get started, and the user can continue the operation, even if he has to redo part of it, such as filling again form data that was not saved.

Back to index
Copyright © 2018-2023 Javier Casas - All rights reserved.