DEV Community

Cover image for Help! I Need to Organize My Global State in a React Application
Michael Mangialardi
Michael Mangialardi

Posted on • Edited on

Help! I Need to Organize My Global State in a React Application

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'],
  },
};
Enter fullscreen mode Exit fullscreen mode

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 }],
};
Enter fullscreen mode Exit fullscreen mode

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,
      },
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

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 }],
};
Enter fullscreen mode Exit fullscreen mode

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 }],
};
Enter fullscreen mode Exit fullscreen mode

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 }],
  },
};
Enter fullscreen mode Exit fullscreen mode

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'],
 },
};
Enter fullscreen mode Exit fullscreen mode

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 }],
  },
};
Enter fullscreen mode Exit fullscreen mode

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'],
  },
};
Enter fullscreen mode Exit fullscreen mode

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'],
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

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, ...);
Enter fullscreen mode Exit fullscreen mode

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?

Top comments (0)