Problems with Redux and why Streams are better
When building Single Page Applications using React, Redux is the go-to library for handling data. I recently wrote an article describing my efforts to build a version of Angular's Heroes app using React. What I didn't mention in that article was that I had originally tried using Redux, but after encountering some problems with it, I decided to use RxJs Observables (as Angular itself does) instead. What I discovered was that Redux can lead to an app getting into an inconsistent state. I will describe how in this article.
How Redux works
First a little primer on how Redux works. There is an object, called a store, which contains all the data used within the application. The data from this store flows, via props, through into all the application's components. The store is updated using a function, which the programmer is responsible for creating, called a reducer. Everytime the reducer runs, it returns the new state of the store, calculating this from the parameters that are passed to it. The code the developer writes does not call the reducer directly; Instead, he calls the store's dispatch function, passing in an action object. The action object has a type property that simply identifies the kind of action it is and, typically, data that will be merged into the existing state of the store to create a new state.
Back to the problem
It turns out that you when you use Redux alongside client side routing you can get to a point where the state of the router is out of sync with the state of the store. In order to demonstrate this more clearly, I have created a small application. All it does is display a list of links, which, when clicked on, open up a panel containing a brief description of a fruit. The links continue to be displayed when the panel is shown. The panel is implemented as a Route Component.
An important point to bear in mind is that the lifetime of the component can span multiple navigations. When the user first clicks on a link, the component will be mounted into the DOM. But when they click on other links, the component will not be unmounted but will be recycled for rendering these new locations.
The data for the description panel comes from the server. Every new navigation results in a remote call. As always with dealing with asynchronous code we need to consider the effects of delays occuring on the network.
The most simple scenario, illustrated below, is when the user always waits for data to come back from the server before clicking on another link. Everything works fine in this case. In fact, this is what will normally happen if we assume that the round trip is reasonably brief and the user is not going to be clicking too frequently on the links.
client server | | | A | |--------------------->| | | | 'A | |<---------------------| | | | B | |--------------------->| | | | 'B | |<---------------------| | |
Of course, we have to allow for network delays, and for the user doing things that we don't want them to do!
If the user clicks on a second link before the data for their first request has returned, then two things will happen: first, the data for the first request will return and be rendered onto the page; second, the data for the second request will return and overwrite the data of the first. The effect will be a flash of unwanted content - not a good user experience!
client server | | | A | |--------------------->| | | | B | |--------------------->| | | | 'A | |<---------------------| | | | | | 'B | |<---------------------| | |
But it could be even worse. What if the second request comes back beforethe first? Then the user will briefly see the data that they do want, followed by it being (permanently) overwritten by the data that they don't!
client server | | | A | |--------------------->| | | | B | |--------------------->| | | | 'B | |<---------------------| | | | 'A | |<---------------------| | |
You can see by playing around with my demo that this is not a theoretical problem. The request doesn't actually go to a server but just fetches it from a client side service class. I have emulated the effect of network delays by making the call inside a setTimeout callback. Requests for bananas always take longer than requests for any other fruits. Thus if you click on banana then tomato in quick succession, you will see the problem described above.
So what's going on?
The nub of the problem is that there needs to be a deterministic relationship between the current location and the state of the store, but there isn't. When the location changes, we fire off a request to the server, but when these requests come back the store is updated with them without any checks that they are the correct data.
Obviously, we could write some code to correct this. Perhaps we could store the id of the data within the response and make sure that it matches the id in our current location. A better solution, I think, is to use Streams, (also known as Observables).
A Stream, also known as an Observable, is an object which periodically emits pieces of data. Think of it like an array with the added dimension of time (or even think of it as a 'stream'!). Streams are very useful in this context because they embody the idea of a sequence of related events. In our app, the user clicking on a link is an event; a request for data from the server is an event; the data returning from the server is an event. It is important that these events occur in the correct order. When they do not, we need to be able to take the necessary remedial action. Streams make this very possible to do in an elegant way.
I have built a version of this app which uses streams and fixes the problem. Whenever the location changes, the Router passes a new match object into the props of the component. We have created a Stream of Match objects. We map this stream to a stream of requests to the server for the corresponding data. Subscribing to this stream gives us the data we need to render the description panel.
An important aspect of this is that we use switchMap for mapping requests. The key feature of switchMap is that it will only emit the data for the latest match object in the underlying stream. Any pending requests to the server are effectively cancelled. This fixes our problem.
Had we used flatMap instead, we would have had the same problem, because that always just emits on the output stream the latest value emited from any inner streams. Streams are therefore not a panacea: You do need to understand how they work, but they are very flexible.
Other problems with Redux
Besides this, there are other problems that I perceive with Redux. We have to describe the state of the app within the reducer function. This gives rise to lots of potential error, I believe. We're mixing up UI state (which exists only on the client) with business model state (which has to be synchronised with the server). We need to make the structure of the state compatible with the structure of the app as a whole. For example, in a route app, we might need separate reducers for each page of the app.
I question the need for a store at all.There should be a single canonical source of data, and that source should be a database which lives on the server. Client side applications should not be in the business of persisting state. What is the value of a client side store? The browser will do any caching that you require (you could even use service workers), there doesn't seem any reason not to simply fetch any data that you need from the server.
As a principle, I think it's better for things to be done at a component level, and this is true of data management. A component should store any UI state, such as form data, and it should be responsible for deciding what data it needs for rendering itself.
That's probably a rather more controversial note that I've ended on than I'd have liked! I should probably state that I do think there is a use for client side stores in some instances, such as when there is no remote source of data. Dan Abramov (the man behind Redux) himself has cautioned that Redux should not be used for every application. Horses for courses, as they say! Streams are useful in of themselves, of course, and I believe that they do represent the future of data management in client side apps, purely because they reflect so closely the behaviour of those apps themselves.