Compact shared-state management in React
Taking inspiration from the easiness of using local state with the useState
hook.
This package provides the Store
class and the useStoreListener
hook. The Store
class is a container for shared data that allows for subscription to its updates, and the useStoreListener
hook makes these subscriptions from within React components. The sharing of the stores across components is performed by means of React's Context in a pretty straightforward manner, as shown in the example below.
import {createContext, useContext} from 'react';
import ReactDOM from 'react-dom';
import {Store, useStoreListener} from '@axtk/react-store';
// Creating a React Context that will be furnished with a
// store in a `StoreContext.Provider` component below.
const StoreContext = createContext();
// Making up a helper hook that picks the store from the Context
// and makes a subscription to the store updates.
const useStore = () => {
const store = useContext(StoreContext);
useStoreListener(store);
return store;
};
// Both `PlusButton` and `Display` below subscribe to the same
// store and thus share the value of `n` contained in the store.
const PlusButton = () => {
const store = useStore();
return (
<button onClick={
() => store.set('n', store.get('n') + 1)
}>
+
</button>
);
};
const Display = () => {
const store = useStore();
return <span>{store.get('n')}</span>;
};
const App = () => <div><PlusButton/> <Display/></div>;
ReactDOM.render(
// Initializing the context with a store.
// The constructor of the Store class accepts an (optional)
// initial state.
<StoreContext.Provider value={new Store({n: 42})}>
<App/>
</StoreContext.Provider>,
document.querySelector('#app')
);
This example covers much of what is needed to deal with a store in a React app, although there are in fact another couple of methods in the Store API.
From the context's perspective, the store as a data container never changes after it has been initialized concealing its updates under the hood. All interactions with the shared context data are left to the store itself, without the need to come up with additional utility functions to mutate the data in order to trigger a component update.
The shape of a React's context can be virtually anything. It means a single context can accommodate several stores. The task is still to pick the store from the context and to subscribe to its updates by means of the useStoreListener
hook.
Having multiple stores can help to convey the semantic separation of data in the application and to avoid component subscriptions to updates of irrelevant chunks of data.
import {createContext, useContext} from 'react';
import ReactDOM from 'react-dom';
import {Store, useStoreListener} from '@axtk/react-store';
const StoreContext = createContext({});
// A helper hook for quicker access to the specific store
// from within the components
const useTaskStore = () => {
const {taskStore} = useContext(StoreContext);
useStoreListener(taskStore);
return taskStore;
};
const Task = ({id}) => {
const taskStore = useTaskStore();
const task = taskStore.get(id);
return task && <div class="task">{task.name}: {task.status}</div>;
};
const App = () => {
// Fetching, pushing to the store and rendering multiple tasks
};
ReactDOM.render(
<StoreContext.Provider value={{
taskStore: new Store(),
userStore: new Store()
}}>
<App/>
</StoreContext.Provider>,
document.querySelector('#app')
);
On the server, the stores can be pre-filled and passed to a React Context in essentially the same way as in the client-side code.
// On an Express server
app.get('/', prefetchAppData, (req, res) => {
const html = ReactDOMServer.renderToString(
<StoreContext.Provider value={new Store(req.prefetchedAppData)}>
<App/>
</StoreContext.Provider>
);
const serializedAppData = JSON.stringify(req.prefetchedAppData)
.replace(/</g, '\\x3c');
res.send(`
<!doctype html>
<html>
<head><title>App</title></head>
<body>
<div id="app">${html}</div>
<script>
window._prefetchedAppData = ${serializedAppData};
</script>
<script src="/index.js"></script>
</body>
</html>
`);
});
When the application is rendered in the browser, the browser store instance can be filled with the serialized data to match the rendered state.
// On the client
ReactDOM.hydrate(
<StoreContext.Provider value={new Store(window._prefetchedAppData)}>
<App/>
</StoreContext.Provider>,
document.querySelector('#app')
);
A component-scoped store can act as a local state persistent across remounts and as an unmount-safe storage for async data.
import {useEffect} from 'react';
import {Store, useStoreListener} from '@axtk/react-store';
const itemStore = new Store();
const Item = ({id}) => {
useStoreListener(itemStore);
useEffect(() => {
if (itemStore.get(id)) return;
itemStore.set(id, {loading: true});
fetch(`/items/${id}`)
.then(res => res.json())
.then(data => itemStore.set(id, {data, loading: false}));
// If the request completes after the component has unmounted
// the fetched data will be safely put into `itemStore` to be
// reused when/if the component remounts.
// Data fetching error handling was not added to this example
// only for the sake of focusing on the interaction with the
// store.
}, [itemStore]);
let {data, loading} = itemStore.get(id) || {};
// Rendering
};
export default Item;
By default, each store update will request a re-render of the component subscribed to the particular store, which is then optimized by React with its virtual DOM reconciliation algorithm before affecting the real DOM (and this can be sufficient in many cases). The function passed to the useStoreListener
hook as the optional second parameter can prevent the component from some re-renders at an even earlier stage if its returned value hasn't changed.
useStoreListener(store, store => store.get([taskId, 'timestamp']));
// In this example, a store update won't request a re-render if the
// timestamp of the specific task hasn't changed.
useStoreListener(store, null);
// With `null` as the second argument, the store updates won't cause
// any component re-renders.
The optional third parameter allows to specify the value equality function used to figure out whether the update trigger value has changed. By default, it is Object.is
.
useStoreListener(
store,
store => store.get([taskId, 'timestamp']),
(prev, next) => next - prev < 1000
);
// In this example, a store update won't request a re-render if the
// timestamp of the specific task has increased by less than a
// second compared to the previous timestamp value.
- @axtk/store, the
Store
class without React