Building React application is hard. When you finally grasp the idea of VirtualDOM and how the framework works, you can quickly realise that React itself is not enough to create and maintain something bigger than a todo-list-app. You start learning about store architecture like Flux and it's most popular implementation - Redux. Soon you start drowning in actions and reducers. And you start having much more issues with maintaining data separation. Sounds familiar?
In this article I will describe how you can structure your application for scalability. I assume that you have at least some understanding how React works and you know a little bit about Flux or Redux.
This article is a written version of the workshops I have conducted on 25th July 2018 at microConf by SwingDev. For more information about the event visit microconf.io.
If you have been writing even a small application in React you can easily spot that something is missing.
Assume we are creating simple application consisting of two main components: a sidebar with all your emails and the main section which displays content of the currently selected email. When you click on an element in the sidebar, you would like to load the content of this item in the main container. We can use routing to change the url of the page and put the id of the email there. But then we would need to fetch email content from somewhere in our main component. We have these data already in our sidebar component but we don't have access to it from the sibling component.
We can solve it by moving the data to the master component - which wraps both sidebar and the central container, but this would create many potential issues and bottlenecks. Firstly, we would need to pass data down through all the layers of our components. Secondly, we would need to keep all our logic in a single master component.
Using Flux architecture helps with both of these issues. It abstracts the logic of store and the operations we can do on it (actions), and provides easy way to pass these data to the components that needs it (connect function in Redux).
Store Architecture 101
Store architecture is an easy concept but can sound odd if you have never heard of it before. The following diagram describes it well:
Instead of keeping all the data inside the components, we use the single, global store which is our source of truth. It defines the whole UI, holds all the dynamic data - both from the server, user and the internal state of the application. Based on this store we can construct our interface.
When user makes an action - for example clicks a button on a page - an Action is dispatched. An action is a simple object containing just the identifier of the action type and, optionally, additional data that complements it. The action is then sent to the reducer: a function with a single purpose - it takes the old state of the application and the new action, and generates the new, updated state. For example if the user created new entity, the reducer might append this new item to the list of all entities.
The new store is passed then to our components and they update accordingly.
TypeScript + React = ❤️
Using TypeScript with React can be beneficial in various ways. Except usual benefits of using typed languages (you can read more about it here), in React you can easily type Props and State of the React components. That means that as soon as you use the component in your code, you can get the suggestion of all the props the component accepts. If you forget about mandatory property, TypeScript notify you about it.
But extending Redux with TypeScript have much more benefits. Having your store typed is one of them. You don't have to click through your reducers to deduce how the store is built anymore. Moreover you can type your actions and action creators which means that you don't have to guess what parameters they accept.
Let's concider the following example.
This is a simple action creator. It accepts options object and creates new action of type SEARCH. Can you tell what options it accepts?
There is no hint what we can pass to this function. If you'd want to use it in your project, you would probably search through the whole application looking for this function calls. But it might not cover all the use cases.
Below is the code rewritten to TypeScript. It might look a little complicated at the beginning but the additional interfaces clear all the confusion. With this code you can clearly see that this function accepts a single parameter with required text property and optional array of numbers under the tags property.
If we use it incorrectly, editor will notify us about that!
In this article will create a simple notes app. You can find the repository on GitHub. If you want to work along, just checkout the master branch. If you get lost, you can always resume at one of the checkpoint tags.
In the repository you can find configured React + TypeScript project with all the components implemented and all basic actions defined. There is the store missing though.
In the application we have three actions:
- NOTES_FETCH which is dispatched when the app starts fetching the data
- NOTES_FETCH_SUCCESS dispatched when the app got the data from the service
- NOTES_FETCH_ERROR fired when the connection error occurs.
How should we shape the store to be able to show these data to the user?
Common (not recommended) approach
The most basic store architecture could look like the following:
We store the current state of the list: either is in initial (before even sending request to the service), loading (waiting for the response), loaded (data are there) and error (connection / server error).
With such architecture our reducer might look like the following:
This is a simple function with just three conditional statements, reacting on each action individually.
The only thing left to do is to connect the store with our React component. There is a handy Redux function connect which does that for us. It accepts two callbacks - one which morphs our global state to pros and the second one in which we can define our actions available for the component. We need to update the first one in the following way:
You can find working version under sprint1-finish tag in the repository.
Issues with this solution
This version should load and display notes successfully. But let's think about next feature we are about to write - adding search functionality. Because we treat our store as a single source of truth, the moment we filter out the note, it is gone from our store. This means that on every user search we would need to load the data from the server in order to show them to the user.
Also, if we would like to display second list with the same notes (for example we might want to implement search as in Spotlight on MacOs floating on top of the rest of the interface), then it would mean that some of the notes could be found on both lists. This creates unwanted redundancy and, if the content of the entity will differ from the note on the second list, it creates ambiguity.
But there is a solution to that. It is called store normalisation and can bring your React/Redux app to the next level.
To apply normalisation we need to start think differently about our store. We can divide it into two categories.
The first one is a data store which we can treat as our frontend database - it contains information about the available entities. These stores should be independent from the actual views user are interacting with.
The second one is our UI store - this is the place that contains all the information about the state of the user's UI. And if it refers to the certain data entity, instead of keeping this data in the UI store, it just reference the data by its id.
The structure recommended by Redux itself is the following:
We have our main store divided into two categories: entities which are our data stores and ui stores - under ui property. The structure of the UI stores will differ and is up to programmer to decide, the most important thing though is that there is no explicit data stored there. Instead of keeping our list of notes, now we keep the list of ids.
The data stores are more structured though. For each entity type, we keep the object containing two properties: byId and allIds.
- byId is a dictionary mapping ids of the entities to their content.
- allIds is an array of all the ids that we have currently in the store
If you want to read more about store normalisation, I recommend Using the Redux Store Like a Database by Nick Sweeting and the discussion Redux - Why normalize? on StackOverflow.
Let's rewrite our previous solution to use the normalisation. Firstly, we need to convert our reducer to act as a UI store. We can do it by simply mapping all the notes into their ids using .map method:
Then, we need to define type for our data store. We can do it the following way:
The first interface is a TypeScript way of declaring dictionary of the certain shape. Here we define that we want byId to be a dictionary which have numbers as the keys and NoteModel as the values.
Now we have to write a reducer which will update this store:
We react just on a single action - NOTES_FETCH_SUCCESS as this is the only action that provides new data. Then we build dictionary and array of all ids.
Now we need to combine these reducers together and create one main store which will hold all these data.
In a real project you would probably use combineReducers which could simplify this code.
The only thing left to do is to update our connector. Now our list of notes for the ui consists of just ids, so we need to map them into proper entities. Here is the code to do that:
With the new architecture adding search functionality is not a problem. First, we need to create a new action interface for that. It can look like the following:
Then we need to update our UI reducer:
Note that we need to pass our data store into the reducer to be able to iterate over all the elements and filter only those which match our search string. You can implement filterByText function in various ways, below is one of the possibilities:
It just check if either title or content contains our text we are searching for.
And that's it. We have implemented our search. Mind the fact that we haven't touched our connector here. There is no need to update the view as we had our store normalised. Adding more search features or ordering capabilities would not require much changes as well. And if we decide to add a refresh functionality to keep our local version of the entities with the backend, all instances in our application would be updated automatically!
I hope this article gave you basic understanding how Redux store normalisation works and how can you use it to improve your project. If you want to play more with this example I recommend implementing one or all of the following exercises:
- Add ability to remove an element
- Add Tagging functionality
- Add option to search by tag
This article is based on the workshops which took place on SwingDev microConf “State of the State in React” on July 25th 2018 in Warsaw, Poland. If you want to learn more about the event, visit www.microconf.io.