The Global State In React Designed Right

Sergey Pogodin – October 7, 2020

State of the art appoach to the global state and asynchronous data management in React applications, using ‌@dr.pogodin/react-global-state‌ library. Caching and automated refreshing of asynchronous data in the global state, along with the Server-Side Rendering (SSR) are integral parts of the presented solution.

Content

  1. Introduction
  2. The Basic Global State
  3. Asynchronous Data & Operations
  4. Server-Side Rendering
  5. Closing Remarks
  6. Conclusion

Introduction

You can read many articles on how to manage the global state in React apps using hooks and Context API, but can you do the following?

Example

Triggered: 1713551463347
Timestamp: 1713551463472
Triggered: 1713551463347
Timestamp: 1713551463472

A simple example at the first glance, until you investigate its behavior closely.

  • Two sample components above share the same global state. In addition to showing exactly the same data, they also share the state of data retrieval. Once one of them triggers the async operation to fetch data, the other one doesn't initiate a separate operation, it renders instead Waiting for async data... message, and waits. Once the data request by another component completes both components re-render with the retrieved data.
  • The loaded data are cached (for demonstration purposes ‌maxage option enforces a short half-minute cache expiration). If within 30 seconds from loading this page you leave it without reloading the app, then rapidly come back, sample components will keep showing the same data (e.g. reload this page, then press this link to go away, then use your browser's Back button to return). If you do the same slower than 30 seconds, the components will reload async data upon your return and render new timestamps.
  • Server-Side Rendering (SSR) is supported. If you see the source code of this page (i.e. HTML markup you get from the server when you come by the direct URL) you will find that server renders already contain the result of asynchronous operation executed server-side, and within the cache expiration limit these data are not reloaded by the frontend.

Why does an article about the global state start with an async data retrieval example? There are different scenarios where a global state is useful, but arguably until asynchronous data are involved it is relatively simple to get along with a very simple solution where the local state of parent component is propagated down to its children via props, thus playing the role of global state, or you even may be good without any global state at all. However, once asynchronous operations are involved, like data retrieval from 3rd party API, which is a routine in web applications, the need for a properly designed global state becomes paramount. The points highlighted by example above are not trivial to implement, and you don't want to re-create related low-level logic in every project and every spot where you need it. Implementing it with Redux would require a lot of boilerplate code, and arguably does not provide a clear well-established way to handle SSR without creation of a completely separate data retrieval logic for server-side code. Wide-spread approaches to the global state management with hooks & Context API do not touch on async operations and SSR either.

Unsatisfied with the current state of art, I went to the drawing board and ended up with a brand new solution based on hooks & Context API, released to NPM as ‌@dr.pogodin/react-global-state‌ library, and referred in the rest of this article simply as the global state. After a year of testing in personal projects, and successful resolutions of all issues I came across, it felt a high time to introduce my approach to a wider community, thus I wrote this article.

The Basic Global State

As everything powered by Context API, the global state starts with the context provider, which should wrap the entire app as the root component (later, for advance use-cases you may want to also use multiple, and/or nested global state providers across your components hierarchy).

Without any props <GlobalStateProvider> creates and empty global state. Components within its children hierarchy can use it via the useGlobalState(path, initialValue) hook, modeled after the standard React's ‌useState(initialValue) powering the local state.

Example: Two Buttons Using Local States

Example: Two Buttons Using The Same Global State

In the first approximation you can assume that apart of exposing the same value to all components relying on the same global state path, the useGlobalState(path, initialValue) hook behaves the same as the standard local state hook, and it is easy to turn one into another and vice-versa.

A step deeper into the global state features, here are the practical points you want to know about the useGlobalState(path, initialValue) hook:

  • Think of the global state as a regular JavaScript object. The dot-separated path in that object is interpreted the lodash way (internally the global state operates using ‌_.get(..), ‌_.set(..), and ‌_.toPath(..) functions). The value returned by ‌useGlobalState(path, initialValue) is essentially a reference to the global state object's sub-tree rooted at the specified path, or the primitive value located at that path. In the former case (when it is a reference) you MUST NOT mutate the referenced object directly, but you may keep it around, as the library guarantees that subsequent state updates via ‌ setValue function won't mutate previously referenced state segments (under the hood the global state clones its segments as necessary to prevent such mutations).
  • It is valid for different hooks to rely on partially matching paths, ‌ e.g. if one hooks (A) is connected to the value at ‌ some.path, and another hook (B) is connected to the value at ‌ some.path.child, they will interplay nicely. In this example, the hook A will always receive updates and trigger re-renders of its component when the B hook updates the value at some.path.child path or its descendants. Similarly, the B hook will receive updates and re-renders its component when the A hook alters the value at some.path.child path or its descendants paths (for example, the hook A can do it by setting the value at its own some.path equal to ‌{ child: 'newValue'}). At the same time, the hook B won't receive an update, and won't cause re-rendering of its component if the A hook updates a value at a path irrelevant to B hook, e.g. at ‌some.path.another.child path.
  • The setValue(newValue) function, returned by ‌useGlobalState(..) hook, behaves the same way as the local state update function acts (including ‌the functional updates feature, and the guarantee that the update function's identity is stable across the component re-renders. As mentioned earlier, the setValue(..) function does not mutate the previous global state object directly, instead it creates its partial clone, and assigns updated values to it. Thus, it is safe to memorize old ‌ values locally.

    For example, consider the following code in a component's body:

    In the first render the variable will end up with the following values: both value, and heap.value will be equal to { epoch: 0 }, and epoch‌ will be equal 0 (the initialValue 1 in the second global hook has no effect as at the moment of its exectuion the value at path.epoch is already initialize by the previous hook).

    Say user clicks a button which bumps the epochexecuting setEpoch(1). In the component re-render triggered by this global state update the variables will end up with the following values: value equal ‌{ epoch: 1 }; heap.value equal ‌{ epoch: 0 } (as promised, it is not mutated by the state update); epoch equal 1.

  • The second argument of useGlobalState(path, initialValue)‌ hook is optional, and it acts exactly as the only argument of the standard React' local state hook, including ‌the lazy initial state feature (the lazy initial value, in our case). When a ‌useGlobalState(path, initialValue) hook is executed with initialValue argument, it checks whether the current value at the specified global state path is undefined. If it is undefined, or the path does not exist in the global state yet, it uses initialValue to initialize the path.

Asynchronous Data & Operations

Back to the opening example of this article. Once we have the global state, we definitely want to use it for management of asynchronous data, ‌ e.g. data being retrieved from an external API, or generated by any other asynchronous operation. We want an easy access to such data from any place of the component hierarchy. When multiple components need the same data simultaneosly, we want to execute the async data fetch (generate) operation only once, and to inform all interested components about the operaiton state, so they can render their throbbers (waiting for data messages) if needed. Finally, we often want to cache such data in the global state, and reuse them for some time without repeating the async operation while we can assume the previously fetched/generated data are fresh enough.

To address all these needs in the most simple way ever ‌@dr.pogodin/react-global-state‌ library introduces ‌useAsyncData(path, asyncOperation, options) hook. Its first argument is the global state path where async data should be stored; the second argument is any ‌asyncOperation which resolves to the data (e.g. it can be a remote API call, or some long async computation); the last argument is optional, and it allows to fine-tune the hook behavior as explained further below.

The useAsyncData(path, asyncOperation, options) hook returns an object with three fields: ‌{ data, loading, timestamp }. The first field, ‌ data holds the actual result of last ‌asyncOperation invokation, or null if the result is not available yet. The loading field is a boolean flag equal true if the async loading operation is currently in progress; and timestamp [ms] holds the moment when the last invokation of ‌asyncOperation completed. The hook allows to set the data refresh timeout (options.refreshAge [ms]) smaller than the cached data expiration (options.maxage [ms]) (both are 5 minutes by default), which allows for the situation where both data are not null, and the ‌loading flag is true. This allows to refresh old data behind the scene, invisbily to the end user.

Multiple components can use useAsyncData(..) hooks pointing to the same global state path. In such a case the library leaves it up to the user to control that every hook uses the same ‌ asyncOperation and hook options. For some advanced scenarios it may be even legit to use different ‌asyncOperation and hook options in different hooks connected to the same path. In this case each hook will operate the data at path according to its own set of options, and it will use its own asyncOperation to refresh the data when necessary.

It is also possible to use useGlobalState(..) hooks to interact with the global state segments managed by ‌useAsyncData(path, asyncOperation). The actual state segment layout at such path is

Here data is the actual cached payload; ‌numRefs is the count of currently mounted async data hooks connected to this data envelope; operationId is an unique ID of the currently running async data retrieval operation, if any; and timestamp [ms] is the time currently cached ‌data where obtained.

The library performs automatic reference counting for mounted ‌useAsyncData(..) hooks pointing to the same ‌path. When the last async hook for given path‌ unmounts it checks data timestamp, and if data are stale it drops them from the state (configurable by the hook's ‌ options.garbageCollectionAge [ms], defaults 5 minutes).

Note: ‌ Currently, the library does not garbage collect stale data which were fresh enough to keep when the last useAsyncData(..) hook pointing them was unmounted (they will be handled appropriately when a new hook pointing them is mounted/unmounted). There is no technical limitations for implementing that, but so far it does not look a much necessary feature.

As a syntax sugar around useAsyncData(..), the library also provides ‌useAsyncCollection(id, path, asyncOperation, options)‌ hook. Assuming asyncOperation function takes a single ID argument and produces different data depending on the ID, this hook loads data for given id into ‌path.id of the global state (see ‌the source code).

Server-Side Rendering

You MUST USE Server-Side Rendering (SSR) whenever it is possible: when a user hits an URL of your website the HTML markup received from the server should match what the user will see after React app initialization in his browser. It is a good user experience, but most importantly, it allows search engine crawlers to correctly index the true content of your pages.

Until any asynchronous data & operations are involved, the SSR, including with the global state, is trivial. In our case, if initial values are provided each useGlobalState(..) hook initializes its state segment the same way both at client and server sides. Thus the standard SSR done somewhat like

will work just fine, as each component relying on the global state will get and use the correct initial value, and render the correct initial HTML.

Asynchronous data & operations complicate the situation drastically. To the best of my knowledge, so far there is no well established and efficient way to do SSR in such scenarios, and to hook it into synchronous React rendering flow (sure, there is ‌React suspense technology, but it is still experimental). In particular, the challenge is to figure out which async data are needed in a particular render, then pre-fetch and inject them into subsequent React rendering operation. Existing solutions often rely on internal React APIs to deduce async data necessary for a render, and then on dedicated boilerplate code to perform server-side data retrieval. Such solutions are fragile because internal React APIs are prone to changes in any release, and inneficient in terms of developer time and efforts needed to support SSR correctly.

The global state provides a trivial and efficient way to handle it! By default, with the simple SSR code shown above, async operations are not executed by async hooks, and the corresponding state segments are initialized with null data, and no loading operation in progress. For the full SSR support you replace that code with the following rendering loop:

When ssrContext prop of the global state provider is used all async data hooks will initiate retrival of async data and collect operation accomplishment promises in the ssrContext.pending‌ array, managed by the provider. These promises are settled after the corresponding data are loaded into the ‌ssrContext.state object (the global state external to the React rendering process). ssrContext.dirty flag, equivalent to !!ssrContext.pending.length is set ‌true if any async operation was initiated, and thus you should wait for pending promises, then redo the rendering using the current ssrContext.state as the new initial state of ‌ <GlobalStateProvider>.

You may think the rendering loop is not efficient, but it is the only correct way to do full SSR with async data. The reason is that nothing forbids a generic app to render completely different component hierarchy and initiate follow-up async operations dependent on previously loaded async data. Thus, it is necessary to repeat the rendering procedure until the global state, and thus the rendered HTML, becomes stable. Alternatively, it is also possible to break the loop prematurely, and send the current, non-final render and global state, to the client, leaving it to complete any pending async operations. In the code snippet above the rendering loop ends either when the context is not dirty after a rendering path (thus, it reached the stable state), or when the limit of iterations (10 in this example) is reached. In the similar manner you can also restrict the maximum time the server is allowed to spend on SSR of a single HTTP request.

No matter what condition terminates the rendering loop, to avoid unnecessary repeating of async operations at the client side you must pass the resulting ssrContext.state to the client and use it there as the initialState of ‌<GlobalStateProvider>.

In some cases you don't want to execute some async operations during SSR, e.g. when an operation take too much time, you prefer to do the initial rendering without corresponding async data, and then load them client-side while showing a throbber (waiting for data message) to the user. For such scenarios async data hooks accept ‌ options.noSSR flag, which prevents individual hooks from fetching its async data during SSR.

Closing Remarks

  • By default, async data hooks (re-)evaluate cached data only when their host components are mounted. To allow re-evaluation at different time, async hooks accept an array of depedencies via the ‌options.deps parameter. When any value in the array changes the hook checks the timestamp of its cached ‌ data, and triggers data update if necessary.
  • You can use multiple and nested global state providers in your component hierarchy. By default, each provider manages its own global state, hiding any outer global state from its children. ‌<GlobalStateProvider> accepts ‌stateProxy property, which allows to specify an external ‌ GlobalState instance to use, instead of creating a new one. Alternatively, this prop can be any other truthy value, which will cause the provider to lookup and reuse the global state from a parent provider. This feature allows to use the global state along with code-splitting and SSR.
  • To facilitate development you can set to any value (inject into your JS bundle via Webpack) REACT_GLOBAL_STATE_DEBUG ‌ environment variable. When set, the library will log all global state operations into console.

Conclusion

Based on a year of experience using ‌@dr.pogodin/react-global-state library, it works as intended, and greatly increases development efficiency for React applications of any size, especially when compared to Redux-managed global state. Regarding SSR capabilities it provides, I don't know any alternative solution allowing the same features with no extra efforts required from developer. Sure there is a room for further improvements and additional features, but I have not encounter any issues limiting its usability in any scenarious, and beside fixing a few edge-case bugs I did not have to alter the library concept nor its originally implemented interface. Thus, the library looks stable and proven enough to recommend other people to try it.

Should you like the library, please share it, and this article, with the community. Any feedback, including critics, is welcome to the library repository ‌https://github.com/birdofpreyru/react-global-state ‌ or to my mail box ‌doc@pogodin.studio.

© Dr. Pogodin Studio, 2018–2024 — ‌doc@pogodin.studio‌ — ‌Terms of Service