Help! I Need to Organize My Global State in a React Application
Michael Mangialardi
Posted on November 29, 2021
In this article, we'll discuss some patterns for organizing a global state in a React application.
Common Issues
Writing about how to organize global state implies that there is such a thing as disorganized state. Truth be told, there are several issues that can spring up from an unorganized, unprincipled global state.
Not Distinguishing Between Different Types of Global State
As a basic example, the global state may contain a response payload from an API request, and it may contain some UI state about whether certain components are visible. These two types of state are not the same, and an organized global state will make that clear.
When these distinctions aren't made, you can run into trouble. For example, if you create a top-level property for every screen/experience, you can duplicate the storage of the API responses that support those experiences:
const state = {
editFeaturesModal: {
isOpen: false,
features: [{ id: 'some-feature', derp: 123 }], // from API
selected: ['some-feature'],
},
removeFeaturesModal: {
isOpen: true,
features: [{ id: 'some-feature', derp: 123 }], // also from API, duplicate!
removed: ['some-feature'],
},
};
Failing to Normalize Data
Datasets in the global state should be stored in such a way that other parts of the global state can reference them without having to make a duplicate copy.
For example, a list of features
returned by a /features
API route should be stored in the global state with IDs. State scoped to a particular experience, like editFeaturesModal
that keeps track of features to appear in a user's dashboard, should reference the "selected" features
by an ID, not by storing the entire feature
object:
//bad
const state = {
editFeatures: {
isOpen: true,
selected: [{ id: 'some-feature', derp: 123 }], // copies a `feature` object
},
features: [{ id: 'some-feature', derp: 123 }],
};
// better
const state = {
editFeatures: {
isOpen: true,
selected: ['some-feature'], // "points" to a `feature` object instead of copying it
},
features: [{ id: 'some-feature', derp: 123 }],
};
Multiple Layers of Logic
Another common problem with state management is having multiple places where data in the global state can be modified.
For example:
// SomeComponent.js
function SomeComponent() {
const dispatch = useDispatch();
useEffect(() => {
async function fetchData() {
const resp = await fetch(...);
const { users , ...rest } = await resp.json();
const result = {
authenticatedUsers: {
....users,
isEmpty: users.length > 0,
},
options: { ...rest },
};
dispatch(fetchUsers(result));
}
fetchData();
}, [dispatch]);
}
// actions.js
function fetchUsers({ authenticatedUsers, options }) {
dispatch({ type: 'FETCH_USERS', users: authenticatedUsers, isCalculated: authenticatedUsers.isCalculated, options });
}
// reducer.js
case 'FETCH_USERS': {
return {
...state,
users: {
authenticated: {
...action.payload.users,
isSet: isCalculated,
....action.payload.options,
},
},
};
}
In this example, the response from the API is changed in the useEffect
hook, the action creator, and the reducer. Yuck!
Distinguishing Between Different Types of Global State
The first step to organizing global state is to recognize the different types of state that could be stored globally.
The common attribute of all the types of global state is that the state could be consumed any component (app-wide).
Generally, there are 2 types of global state:
1) App-wide context that can be consumed by multiple experiences (i.e. an API response or an authenticated user's token)
2) App-wide context that is specific to a single experience but needs to be shared between components (i.e. a modal's visibility state)
Technically, we could distinguish between types of app-wide context that can be consumed by multiple experiences, leaving us with 3 types of global state:
1) App-wide context not tied to any specific experience or an API route/feature but consumable by multiple experiences (i.e. authenticated user)
2) App-wide context tied to a specific API route/feature and consumable by multiple experiences (i.e. API responses)
3) App-wide context tied to a specific experience (i.e. a modal's visibility state)
Understanding these different types of global state can help inform how we organize/structure the global state.
Structuring the Global State Based on the Different Types
It can be easier to express what we don't want in this regard:
const state = {
editFeatureModal: {
features: [{ id: 'some-feature', derp: 123 }],
},
isShowingAnotherModal: true,
users: [{ id: 'some-user', derp: 123 }],
};
The issue with this example state is that there are not clear boundaries between the various types of global state.
users
could contain the response of an API, isShowingAnotherModal
refers to state controlling a modal's visibility, and editFeatureModal
refers to state for a specific modal workflow, but it also contains state that could be from an API response.
As an application grows, the state can get very messy. It doesn't matter how great your state management library is, if the global state is messy, you will introduce bugs and a poor developer experience.
So, how can we improve the organization of the state?
One idea is to create slices. That way, you only interact with the global state via a more manageable slice.
However, even with a slice, there are still the same concerns about distinguishing between the different types of global state.
const slice = {
editFeatureModal: {
features: [{ id: 'some-feature', derp: 123 }],
},
isShowingAnotherModal: true,
users: [{ id: 'some-user', derp: 123 }],
};
This state is not any more organized even if its a slice.
Therefore, slices should be thought of as a "cherry on top" of an organized state. We have to first organize the state before we can slice it.
Given that we can categorize the global state into 3 types, perhaps we can shape the state to reflect these different types.
For example:
const state = {
app: {
authenticatedUser: {
email: 'derp@example.com',
},
},
experiences: {
editFeatures: {
isOpen: true,
selected: ['some-feature'],
},
},
api: {
features: [{ id: 'some-feature', derp: 123 }],
},
};
Perhaps, you can think of better names than app
, experiences
, and api
as the top-level properties. Or, perhaps you want to make one of the types the implicit default:
const state = {
app: {
authenticatedUser: {
email: 'derp@example.com',
},
},
api: {
features: [{ id: 'some-feature', derp: 123 }],
},
// "experiences" is the implicit default type in the state
editFeatures: {
isOpen: true,
selected: ['some-feature'],
},
};
These decisions aren't very significant so long as there is a clear, agreeable way to store/retrieve state based on the type.
Perhaps one could say that the distinction between app
and api
is one without a difference.
Fair enough (although, I can conceive situations where the distinction is valuable).
The important thing is to distinguish between state that can be consumed by multiple experience and state that is tied to a specific experience.
This becomes more clear when we consider the importance of normalization.
Normalizing State Based on the Different Types
State that can be consumed by any experience (app
and api
in my example) should store entire datasets (i.e. authenticatedUser
and features
).
State that is tied to a specific experience but relates to state that can be consumed by any experience should not duplicate the datasets.
For example, if an editFeatures
experience (a modal for editing the features of a user's dashboard), needs to keep track of features that a user wants to select/enable for their dashboard, then it should only store an id
that "points" to an object in the api.features
list:
const state = {
experiences: {
editFeatures: {
isOpen: true,
selected: ['some-feature'], // points to a `api.features` object
},
},
api: {
features: [{ id: 'some-feature', derp: 123 }],
},
};
In this sense, we can think of the api.features
object as the "table" and the experiences.editFeatures.selected
are foreign keys to the table when making an analogy with databases.
In fact, this pattern of normalization is suggested by Redux:
Data with IDs, nesting, or relationships should generally be stored in a “normalized” fashion: each object should be stored once, keyed by ID, and other objects that reference it should only store the ID rather than a copy of the entire object. It may help to think of parts of your store as a database, with individual “tables” per item type.
By normalizing our global state in this way, we can avoid 1) duplicating data in the global state and 2) coupling state that could be consumed by multiple experience to a single experience.
Caching State Based on the Different Types
By avoiding a pattern that couples state that could be consumed by any experience to a single experience, we gain the benefit of not needing to make duplicate API requests.
Imagine an application where two experiences require the same underlying dataset that has to be retrieved via an API request.
Let's say there's a "edit features" modal and a "remove features" modal which both require the list of features
from the API.
In poorly organized state, we might store the features
under two "experience" properties:
const state = {
editFeaturesModal: {
isOpen: false,
features: [{ id: 'some-feature', derp: 123 }],
isFeaturesLoading: false,
selected: ['some-feature'],
},
removeFeaturesModal: {
isOpen: true,
features: [{ id: 'some-feature', derp: 123 }],
isFeaturesLoading: false,
removed: ['some-feature'],
},
};
Because of this organization, you will either have to unneccessarily make two separate api calls to a /features
route, or you will have to awkwardly reference another experience without a clear establishment of a "source of truth" for the features list.
By distinguishing between the api.features
property and the experience.editFeatures
and experience.removeFeatures
properties, an EditFeatures
or RemoveFeatures
component can avoid an API request if api.features
is not empty, and both components can pick the api.features
property without confusingly referencing a property in the state coupled to another experience (i.e. EditFeatures
referincing removeFeaturesModal.features
).
Even if the context of your application requires to you re-fetch the features
on each modal to avoid stale data, the latter benefit still remains.
Finding State Based on the Different Types
When working with a global state, it's often useful for debugging purposes to be able to see the global state in the browser via a broswer extension (i.e. Redux DevTools).
By organizing the state based on the different types, it becomes easier to find the state you're looking for, and therefore, it becomes easier to debug.
Improving Upon Our Model
Currently, I've suggested a model where we categorize the global state by api
, experiences
, and app
. Arguably, we could condense api
and app
into one, maybe calling it data
.
Granting that, there is still a potential problem with this sharp division that I have not addressed. When data
and experiences
are separated, there is no explicit way to associate between a experience and the data it references.
Grouping the State
Perhaps an improvement upon our model is to group data
and experiences
by "domains."
A domain can be thought of as a logical grouping of experiences.
Basically, we allow a dataset to be used across multiple experiences, but we can also create boundaries between logical groupings of experiences (and the data they could consume).
For example, we could group various experiences relating to a shopping cart for an ecommerce site into a "shopping cart" domain:
const state = {
shoppingCart: {
data: {
upsells: [{ id: 'some-upsell', derp: 123 }, { id: 'another-upsell', herp: 456 }],
},
editCartModal: {
isOpen: false,
upsells: ['some-upsell'],
},
cart: {
upsells: ['some-upsell', 'another-upsell'],
},
},
};
By grouping the global state in this way, we can distinguish between the different types of state while not losing the readability of associating experiences and the data that supports those experiences.
Also, this structure provides a nice opportunity for using slices. Essentially, you organize the directories in your codebase by domain. Then, each domain directory could define and integrate with its own slice. By the end, all the slices from the various domains are combined into a single global state object:
/* tree */
src/
store.js
/shopping-cart
/modals
/cart
slice.js
/* slice */
const slice = {
shoppingCart: {
data: {
upsells: [{ id: 'some-upsell', derp: 123 }, { id: 'another-upsell', herp: 456 }],
},
editCartModal: {
isOpen: false,
upsells: ['some-upsell'],
},
cart: {
upsells: ['some-upsell', 'another-upsell'],
},
},
};
/* store */
const store = combineSlices(shoppingCart, ...);
Trimming the State
Another way to improve the organization of the state is to reduce its bloat.
A common source of bloat is storing UI state in the global state that could be handled in other ways.
To combat this, you could enforce the rule to only store something in global state if it is required across multiple experiences and cannot be easily shared via props.
Also, there are alternative ways to control a component's visibility other than props or global state.
Assuming you are using client-side routing on your application, you can replace isOpen
flags by scoping a component to a route in the router. You can then toggle the component's visibility by toggling the route.
Conclusion
In conclusion, a tool like Redux enforces a pattern for updating a global state immutably with a single flow of data, but it doesn't enforce a way to organize the state. At the end of the day, any application with state management should think hard about how to organize the global state.
How do you manage to solve this problem?
Posted on November 29, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.