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.
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: 1733345766965
Timestamp: 1733345767093
Triggered: 1733345766965
Timestamp: 1733345767093
// This code omits a simple styling of example components, // which is irrelevant to the demonstrated functionality. import React from 'react'; import { useAsyncData } from '@dr.pogodin/react-utils'; /** * An example asynchronous operation. It could be an async data retrieval from * a remote API, but to simplify the demonstration we use a brief timeout after * which we return the call initiation timestamp. */ async function loader() { const calledAt = Date.now(); return new Promise((resolve) => { setTimeout(() => resolve(calledAt), 123); }); } /** * An example component relying on asynchronously loaded or generated data. */ function Component() { const { data, loading, timestamp } = useAsyncData( 'some.state.path', loader, { maxage: 30000 }, ); return loading ? ( <div>Waiting for async data...</div> ) : ( <div> <code>Triggered: {JSON.stringify(data)}</code><br /> <code>Timestamp: {timestamp}</code> </div> ) } /** * At the root level of example we render two instances of sample component * side-by-side. */ export default function Example() { return ( <div> <Component /> <Component /> </div> ); }
A simple example at the first glance, until you investigate its behavior closely.
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.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.
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).
import React from 'react'; import { GlobalStateProvider } from '@dr.pogodin/react-global-state'; export default function AppRoot() { return ( <GlobalStateProvider> {/* Application components, which can use the GlobalState. */} </GlobalStateProvider> ); }
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
// The standard React's local state example. import React, { useState } from 'react'; function Component() { const [ value, setValue, ] = useState(0); return ( <button onClick={() => setValue(value + 1)} type="button" > {value} </button> ); } export default function Example() { return ( <div> <Component /> <Component /> </div> ); }
Example: Two Buttons Using The Same Global State
// The global state example. // It assumes <GlobalStateProvider> is present // in the parent component hierarchy. import React from 'react'; import { useGlobalState, } from '@dr.pogodin/react-global-state'; function Component() { const [ value, setValue, ] = useGlobalState('state.path', 0); return ( <button onClick={() => setValue(value + 1)} type="button" > {value} </button> ); } export default function Example() { return ( <div> <Component /> <Component /> </div> ); }
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:
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).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 value
s locally.
For example, consider the following code in a component's body:
const { current: heap } = React.useRef({}); const [value] = useGlobalState(‘path’, { epoch: 0 }); const [epoch, setEpoch] = useGlobalState(‘path.epoch’, 1); if (!heap.value) heap.value = value;
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 epoch
executing 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.
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.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
// The global state segment at "path" // created by useAsyncData(path, asyncOperation) hook { data: any, numRefs: number, operationId: string, timestamp: number, }
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).
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
// This is how you generate React HTML markup at server. // Sure a complete SSR solution requires a bunch of standard // auxiliary code to handle incoming HTTP requests, // and to send generated HTML back to the client, wrapped // into generic HTML page template. ReactDOM.renderToString(( <GlobalStateProvider> <AppRoot /> </GlobalStateProvider> ));
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:
let render; let round = 0; const ssrContext = { state: {} }; while (round < 10) { render = ReactDOM.renderToString(( <GlobalStateProvider initialState={ssrContext.state} ssrContext={ssrContext} > <YourApp /> </GlobalStateProvider> )); if (!context.dirty) break; await Promise.allSettled(ssrContext.pending); round += 1; } // At this point render holds the generated HTML markup, // and ssrContext.state holds the intial global state to // inject into <GlobalStateProvider> at the frontend side.
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.
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.<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.REACT_GLOBAL_STATE_DEBUG
environment variable. When set, the library will log all global state operations into console.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.