Replacing Redux with observables and React Hooks
State management is a critical part in React development and many tools have been created to assist developers with this task. The most popular one is Redux, a small library created by Dan Abramov to help developers implement the Flux design pattern within their app. In this post, we will discuss whether we really need Redux and see how we can replace it with a simpler approach based on observables and React hooks.
Why do we need Redux in the first place?
Redux is so often associated with React that many developers use Redux without really wondering why. React makes it easy to keep a component in sync with its own state by the use of
useState(). But things get more complicated as soon as state is shared by several components. The most direct solution to share a common state between multiple components is to move it up to their common ancestor. But it can quickly become tricky if the components are far from each other in the component hierarchy, involving a lot of props to pass down and update the shared state. React Context can help decrease the number of props, but declaring a new context each time some state is shared requires a lot of effort and can be quite error-prone.
Redux solves these issues by introducing a single object (the store) that contains the whole state of the app. This store is injected with the
connect() function in components that need to access the state. This function also ensures that when the state is modified, all the components relying on it are re-rendered. Finally, in order to modify the state, components have to dispatch actions that trigger reducers to compute the new modified state.
What is wrong with Redux ?
The first time I read the official Redux tutorial, the thing that struck me the most was the huge amount of code I had to write in order to modify the state. Modifying the state requires to declare a new action, implement the associated reducer and finally dispatch the action. Redux docs also encourage you to write an action creator to facilitate creating the action each time you want to dispatch it.
Redux also makes code harder to understand, refactor or debug. When reading code written by someone else, it is often difficult to find out what is executed when an action is dispatched: we have to dive into the action-creator code to find out the corresponding action type, and then find the reducers that handle this action type. Things can be even harder when some middlewares such as
redux-saga are used, making things even more implicit.
Finally, being a TypeScript user, Redux can be a frustrating experience. By design, actions are just strings associated with additional parameters. There are ways to write well-typed Redux code with TypeScript, but it can be really tedious and it increases again the amount of code we need to write.
Observables and hooks : a simpler approach to state management
Replacing Redux store with observables
In order to solve the shared state problem in a simpler manner, we first need to have a way for components to be notified when shared state is modified by some other components. To do that, let’s introduce the
Observable class that contains a single value, and which allows components to subscribe to value changes. As often with the subscription design-pattern, the
subscribe() method returns a function to call to unsubscribe from the observable.
The implementation of this class is pretty straightforward in TypeScript:
If you compare this class with the Redux store, you’ll see that they are actually pretty similar :
get() corresponds to
subscribe() is the same. The main difference comes from the
dispatch() method that has been replaced by a more straightforward
set() method that allows to modify the contained value without having to rely on reducers. Another big difference is that, unlike Redux, we will use many observables in our code instead of having a single store that contains the whole state.
Replacing reducers by services
Observables can now be used to store the shared state, but we still need to move the logic contained in reducers into another concept. We call these, services. Services are classes that implement the whole business logic of our apps. Let’s try to rewrite the Todo reducer from the Redux tutorial into a Todo service, using observables:
Comparing this with the Todo reducer, we can note several differences between a service and a reducer:
- Actions have been replaced by methods, removing the need to declare the action-type, the action itself and the associated action-creator.
- More importantly, a service contains and mutates the state it manages. This is a big conceptual difference from reducers, which act as pure functions.
Accessing services and observables from components
Now that we have replaced the store and the reducers from Redux with observables and services, we need to make services accessible from every React components. There are several ways to do that : we could use an IoC framework such as Inversify, use React context or use the same approach as with Redux store, having a single global instance for each services. For the purpose of this article, we’ll use the latter approach:
We can now access shared state and modify it from all our React components by importing the
todoService instance. But we still need to find a way to re-render our components when shared state is modified by another component. To achieve this, we’ll write a simple React hook that adds a state variable to a component, subscribe to the observable and update the state variable when the observable’s value has changed :
Putting it all together
Our toolbox is now complete. We can use observables to store shared state in services, and use the
useObservable hook to ensure components are always in sync with this state.
Let’s rewrite the TodoList component from the Redux tutorial using this new hook:
As we can see here, we have written several components that access the shared state values (
visibilityFilter). These values are modified simply by calling methods from the
todoService. Thanks to the
useObservable hook that subscribes to value changes, these components are automatically re-rendered when the shared state is modified.
If we compare this code to the Redux approach, we can see several advantages here:
- Conciseness: the only thing we had to do is wrap state-values into observables, and use the
useObservablehook when accessing these values from components. No need to declare actions, action-creators, to write or combine reducers, or to connect our components to the store with
- Simplicity: it is now much simpler to trace code execution. Understanding what actually happens when a button is clicked is just a matter of jumping to the implementation of the method being called. Step-by-step execution with a debugger is also greatly improved as there is no intermediate layer between our components and our services.
- Type-safety out of the box: for TypeScript users, there is no extra work required to have a correctly typed code. No need to declare types for the state and for every actions.
- Async/await support: though it has not been demonstrated here, this solution plays nicely with async functions, making asynchronous programming a lot easier. No need to rely on a middleware such as
redux-thunkthat requires a PhD in functional programming to understand.
Redux still has some serious selling points, especially the Redux DevTools, allowing developers to watch state changes during development, and to time-travel to past snapshots of the app which can be a great tool for debugging. But in my experience, I rarely used this and the price to pay seems too high for a small benefit.
In all of our React and React Native apps, we have used a similar approach to the one described in this article with great success. We actually never felt the need for a more complex state-management system than this.
Observableclass introduced in this post is fairly simple. It is possible to replace it with a more advanced implementations such as micro-observables (our own observable library) or Jotai.
- The solution presented here is quite similar to what can be achieved with MobX. The main difference is that MobX encourages deep mutability of state objects. It also relies on ES6 proxies to notify when changes occur, making it implicit when a re-render happens and making it harder to debug when things don’t work as expected.