Frontend developers increasingly face challenges surrounding complex state and data management. We encounter issues resulting from data management which has become too monolithic or too fragmented, our components update too often, or we spend much of our development time trying to discover how data is being passed through our entire application. Our components should consume only the minimum amount of information necessary to render their children. How can that be achieved in a way both easy to use and easy to comprehend throughout our application? I think the answer is in taking data management outside the realm of react and using react hooks to, well, hook into relevant data updates.
React has shipped with hooks since 16.8.0, and it this has caused developers to ditch class components in favor of functional components with hooks. You may have also considered ditching a library like redux by using hooks and react context. While initially seems like an excellent idea ( I re-wrote a large portion of our application at work this way ), you will find that hooks and context can cause unnecessary re-renders and increase the logical complexity of your codebase.
If you want to just skip to the code, here is the sandbox
Cache Me Outside: How 'Bout That?
Most react applications need to fetch data from a server and display it to the user of the application. Where to store that data in the application quick becomes a challenge as the application grows in size and scope. If you inspect a react application of nearly any size, you will probably find a combination of different solutions. It's popular to use a third-party library like redux or mobx, but sometimes this data is stored in local component state.
Trade offs need to be considered in each approach: using an external library can mean writing more code to update and consume our data in the application, leading to hard to follow logic; keeping application data in component state means that it disappears when the component is unmounted, forcing us to re-fetch the data or place the component higher up in the render tree ( often this is the pattern of "container" components ).
State and data management in many cases can and should be separated. The data available to consume in an application is not necessarily reflected in the current state of the components which consume that data. An example of this is storing data in redux. When we place data in a reducer from a server, we now have access to that data while we are connected to the redux store. A component that consumes that data may have several states and state transitions, but that does not change the availability of the data for consumption.
I think that we can move data management outside of react, giving us the benefits of:
0: Having a simple API for both writing and reasoning ( a problem with redux, sagas, mobx, etc. is boiler plate code and hard to follow logic ).
1: Allowing us to bind the UI to the data when necessary, but not having our data depend on our rendering library ( using react context means our data must follow the constraints of react )
2: Allowing for underlying changes to data only relevant to the current component to automatically trigger a request to the react scheduler to update.
Big OOF: Why Context Is Complex
Storing data inside of react context can lead to complexity and re-renders, which can both harm the performance of your application and decrease the codebase's maintainability. Nik Graf has an excellent talk concerning this, so if you'd rather here it from him, check it out. Digging into context, however, the problems quickly arise when looking for a solution for managing data needed by your application. Let's dig into some examples.
Using the following collection of data:
[
{
"name": "sam",
"id": "1987ea87gde302",
"likes": [
{ "id": 0, "item": "cars" },
{ "id": 1, "item": "dogs" },
{ "id": 2, "item": "Bruce Springsteen" },
{ "id": 3, "item": "mowing the lawn" }
],
"dislikes": [
{ "id": 0, "item": "vegetables" },
{ "id": 1, "item": "income tax" },
{ "id": 2, "item": "existential crises" }
]
},
...
]
If we wanted to store this in react context and pull it out with hooks, we would do something like this:
// Assuming the data structure above, a list of user objects is named userList
const UserContext = React.createContext(userList)
function UserListView() {
const listOfUsers = React.useContext(UserContext)
return listOfUsers.map(user => <p>{user.name}</p>)
}
This works great! Until you need to update that list of users, in which case you probably need to create a custom component that exposes methods for updating and retrieving values inside the context:
const UserContext = React.createContext([])
function UserContextHolder({children}) {
const [users, setUsers] = React.useState([])
return (
<UserContext.Provider value={{users, setUsers}}>
{children}
</UserContext.Provider>
)
}
Seems simple enough! However, this component will have to sit high enough in the react DOM tree that all components that consume from it can be its children. This means that any other children of this component will be forced to re-render whenever any values of this context are updated! Additionally, if we try and re-use this context to store something else related to our user list, such as a selected user, or a collection of selected users, we would again force all components which consume this information to be children of the context and force them to re-render anytime any of the data changes.
To illustrate this, imagine we have a UI that shows a list of our users and then a list of the likes and dislikes of a selected user. If we store all of this information in context, we would see a lot of render events when using this UI:
=== MAIN LIST ===
0: The context mounts and our user list is updated via an XHR request.
1: The default selected user is chosen from the user list and is set in the context
2: Every time a new selected user is chosen, the context is updated and the component is re-rendered
3: Updates from likes list
4: Updates from dislikes list
=== LIKES LIST ===
0: Selected user from Main List causes initial render
1: Selected user update
2: Updates to itself
3: Updates to dislikes list
=== DISLIKES LIST ===
0: Selected user from Main List causes initial render
1: Selected user update
2: Updates to itself
3: Updates to likes list
Notice how with context, even updates to irrelevant bits of the data cause re-renders. Our main list that just renders the users' names should not be forced to re-render when information about a specific user's likes and dislikes is updated. This model also assumes that the three lists are the only children of the context component, but in the real world, our applications tend to be a little more complex. For example, if we add button components for adding, deleting, and editing likes and dislikes, all of those components would also be re-rendered.
Imagine if we add properties to the user object--for example if we want to show if a user is online--or we have a recursive data structure, with each user having a list of friends who in turn are user objects. Since many changes to the user list could take place, we would increase the amount of re-renders of every component each time we add, remove, or modify a part of this list. Storing data in react context creates unnecessary links between components and forces us to wrap each component in useMemo
to optimize rendering.
Don't Forget To Like And Subscribe: Using RXJS To Build Custom Data Structures
One of the convenient aspects of using react context is that you get updates for free! Anytime a context value is updated, all the components that consume it and their children call for a re-render. This behavior is fantastic when you think about having a truly data-driven UI, but not so fantastic when you consider the complexities introduced above. So how can we keep this auto-updating behavior while reducing component renders to only depend on data directly consumed by the component itself? Enter rxjs.
If you aren't familiar with rxjs or reactive programming, I recommend you check out Andre Staltz's gist covering some of the principles of reactive
programming. Rxjs subjects are a way for components to subscribe to data changes. They offer a clean API for receiving and interacting with updates to a data store. However, piping data directly from observables into components will not be compatible with future react updates since react updates on a pull based system, whereas observables are push based. Using the hooks related to updating state provided by react, we subscribe to changes in the data without directly pushing updates to our components but rather requesting an update from the react scheduler.
Using the user list defined above, we can construct a custom store to contain our data and expose methods for updating and subscribing to updates. By creating this data structure outside of react, we allow its methods to be accessed independently from our UI, giving us a powerful starting point for creating our own useful data management utilities.
Let's start by creating a basic structure for storing data and subscriptions:
import { Subject } from 'rxjs'
class DataStore {
subjects = new Map()
store = new Map()
getSubscription = key => this.subjects.get(key)
getValue = key => this.store.get(key)
createSubscription = key => {
const subject = this.subjects.get(key)
const storeValue = this.store.get(key)
if (subject && storeValue) return subject
this.subjects.set(key, new Subject())
this.store.set(key, undefined)
return this.subjects.get(key)
}
setValue = (key, value) => {
this.store.set(key, value)
this.subjects.get(key).next(value)
}
removeSubscription = key => {
const selectedSubscription = this.subjects.get(key)
const selectedValue = this.store.get(key)
if (selectedSubscription) {
selectedSubscription.complete()
this.subjects.delete(key)
} else {
throw new Error('Cannot find subscription %s', key)
}
if (selectedValue) {
this.store.delete(key)
} else {
throw new Error('Cannot find store key %s', key)
}
}
}
Unlike our example using context, this structure is completely agnostic to the shape of our data giving it flexibility to be re-used across our entire application. A data structure like this allows us to store almost anything ( except duplicate keys ), which means that we could have many instances of the DataStore
object, or we could have a monolithic store that contains all of our application's data ( though I'm not sure if that is the best idea ).
From the methods exposed by the DataStore
object, we can make a whole host of utilities for consuming, updating, introspecting, and subscribing to our data. Let's see how they can be consumed directly within our react components.
First, instantiate an instance of the DataStore
object outside of our component and create a subscription for our user list.
// in UserRender.jsx
const USER_STORE = new DataStore()
USER_STORE.createSubscription('userList')
Inside our component logic we can create methods for consuming the data from the store. Here is where we want to cause re-renders when our data changes.
// in UserRender.jsx
const USER_STORE = new DataStore()
USER_STORE.createSubscription('userList')
const fetchAndStoreUserList = () => {
fetchUsers().then(users => USER_STORE.setValue('userList', users))
}
export function UserRender() {
const [userList, setUserList] = React.useState([])
USER_STORE.getSubscription('userList').subscribe(setUserList)
React.useEffect(fetchAndStoreUserList, [])
return userList.map(user => <p>{user.name}</p>)
}
This is how we pull the list of users out of the DataStore
and into our component. This leverages react's useState
function by allowing us to request an update from react instead of immediately pushing component updates from our subscription. Piping the output of our subscription into useState
also allows react to batch renders, which comes in handy if the user list was being updated from a web-socket connection or any other method that rapidly triggers state updates.
At this point you are probably be thinking, "this looks nice, but won't I still have to re-render the main list when I call USER_STORE.setValue
?". The answer is yes. Even though we have moved the management of the application data outside of react, we're still tied to the update cycle called by useState
as it's passed as a callback to USER_STORE.setValue
. This is where hooks really start to shine!
Press F For Selects ?
If you've used redux, you've most likely encountered selectors. For those who aren't familiar with the subject, selectors allow us to isolate ( or select ) a part of our application data and only initiate renders when that part of the data changes. Using hooks, we wrap the functionality of the USER_STORE
to use a selector which only updates the UserRender
component when the list of users changes. This means that we update parts of the user data ( like a likes or dislikes list ) without having to re-render the components that don't consume that data directly. Creating hooks that take selectors as an argument also helps those of us transitioning from a redux heavy codebase and allows for the re-use of existing code.
export function useSelector(store, subscriptionKey, selector) {
store.getSubscription(subscriptionKey).subscribe(selector)
}
The code for useSelector
is simple, thanks to how we created the DataStore
. We simply want to pass it the store we from which we want to read ( in this case USER_STORE
), the key for the subscription we are interested in ( userList
), and the selector function that will be called whenever a new value gets pushed to the stream. We can now reuse our redux selectors with our new data structure!
We want to serialize the keys of our user list and only update the UserRender
component if those keys change. To do that, we need to first create our user list selector:
function memoUsers() {
const cache = {}
return function(updateUser) {
return function(userList: User[]) {
const key = JSON.stringify(userList.map(user => user.user))
if (cache[key]) {
// don't call to re-render
} else {
cache[key] = key
updateUser(userList)
}
}
}
}
Now, memoUsers
can be passed to our useSelector
hook and be used in place of our userSubscription
.
// in UserRender.jsx
import { useSelector } from './hooks'
const USER_STORE = new DataStore()
USER_STORE.createSubscription('userList')
const fetchAndStoreUserList = () => {
fetchUsers().then(users => USER_STORE.setValue('userList', users))
}
function memoUsers() {
const cache = {}
return function(updateUser) {
return function(userList: User[]) {
const key = JSON.stringify(userList.map(user => user.user))
if (cache[key]) {
// don't call to re-render
} else {
cache[key] = key
updateUser(userList)
}
}
}
}
const cache = memoUsers()
export function UserRender() {
const [userList, setUserList] = React.useState([])
const setCachedUserList = cache(setUserList)
useSelector(USER_STORE, 'userList', setCachedUserList)
React.useEffect(fetchAndStoreUserList, [])
return userList.map(user => <p>{user.name}</p>)
}
The UserRender
component now only updates if we have added or removed a user from the list or changed the selected user, and not when we change the properties of a particular user. The component itself is simple and the heavy lifting of application data is handled by our DataStore
. We didn't need to create actions and reducers, or use higher order components.
You can do more to improve writing to your data store be extending the DataStore
object. Extending DataStore
should be on a per-use-case basis, as it would be an anti-pattern to add a new method to DataStore
for every use case encountered. A better approach would be to create a new object that extends DataStore
and adds the methods needed for a particular situation. The key here is that we maintain flexibility with these data structures, since the structure of our data is irrelevant to react, custom data structures should be simple to read and simple to write.
State Management !== Data Management: don't @ me ( or do, I would love to hear your feedback )
Taking data management outside of react gives us a base for controlling externally triggered component renders. It also allows us to develop patterns that are easily re-used across the application. It takes the burden of caching and manipulating away from react. With hooks, we can easily hook into our custom data structures which allows our components to only consume what they need and react only to updates that are relevant to them.
Focusing on how our components consume data across our application prevents brittle architecture by allowing each component to independently pick and choose how and when it reads and writes to a shared data structure. Unlike using context, we don't have to think about where in the react DOM tree our components are located, or wrapping components in useMemo
to optimize render cycles.
Efficient data management boils down to simplicity. Can you reliably track the flow of data through you application, are you able to introspect your data, are your components forced to update when data they don't consume changes? These are questions that should be asked as you build your application. No one-size fits all solution exists for data management, but I hope you will consider simplifying and try some of the concepts talked about here.
Top comments (0)