@dr.pogodin/react-global-state
Version:
Hook-based global state for React
147 lines (137 loc) • 6.17 kB
JavaScript
// Hook for updates of global state.
import { isFunction } from 'lodash';
import { useEffect, useRef, useSyncExternalStore } from 'react';
import { Emitter } from '@dr.pogodin/js-utils';
import { getGlobalState } from "./GlobalStateProvider";
import { cloneDeepForLog, isDebugMode } from "./utils";
/**
* The primary hook for interacting with the global state, modeled after
* the standard React's
* [useState](https://reactjs.org/docs/hooks-reference.html#usestate).
* It subscribes a component to a given `path` of global state, and provides
* a function to update it. Each time the value at `path` changes, the hook
* triggers re-render of its host component.
*
* **Note:**
* - For performance, the library does not copy objects written to / read from
* global state paths. You MUST NOT manually mutate returned state values,
* or change objects already written into the global state, without explicitly
* clonning them first yourself.
* - State update notifications are asynchronous. When your code does multiple
* global state updates in the same React rendering cycle, all state update
* notifications are queued and dispatched together, after the current
* rendering cycle. In other words, in any given rendering cycle the global
* state values are "fixed", and all changes becomes visible at once in the
* next triggered rendering pass.
*
* @param path Dot-delimitered state path. It can be undefined to
* subscribe for entire state.
*
* Under-the-hood state values are read and written using `lodash`
* [_.get()](https://lodash.com/docs/4.17.15#get) and
* [_.set()](https://lodash.com/docs/4.17.15#set) methods, thus it is safe
* to access state paths which have not been created before.
* @param initialValue Initial value to set at the `path`, or its
* factory:
* - If a function is given, it will act similar to
* [the lazy initial state of the standard React's useState()](https://reactjs.org/docs/hooks-reference.html#lazy-initial-state):
* only if the value at `path` is `undefined`, the function will be executed,
* and the value it returns will be written to the `path`.
* - Otherwise, the given value itself will be written to the `path`,
* if the current value at `path` is `undefined`.
* @return It returs an array with two elements: `[value, setValue]`:
*
* - The `value` is the current value at given `path`.
*
* - The `setValue()` is setter function to write a new value to the `path`.
*
* Similar to the standard React's `useState()`, it supports
* [functional value updates](https://reactjs.org/docs/hooks-reference.html#functional-updates):
* if `setValue()` is called with a function as argument, that function will
* be called and its return value will be written to `path`. Otherwise,
* the argument of `setValue()` itself is written to `path`.
*
* Also, similar to the standard React's state setters, `setValue()` is
* stable function: it does not change between component re-renders.
*/
// "Enforced type overload"
// "Entire state overload"
// "State evaluation overload"
function useGlobalState(path,
// TODO: Revise it later!
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initialValue
// TODO: Revise it later!
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) {
const globalState = getGlobalState();
const ref = useRef(undefined);
// TODO: Revise how this `rc` variable is used, perhaps we can simplify stuff
// here.
let rc = ref.current;
if (!ref.current) {
const emitter = new Emitter();
ref.current = {
emitter,
globalState,
path,
setter: value => {
const newState = isFunction(value) ? value(rc.globalState.get(rc.path)) : value;
if (process.env.NODE_ENV !== 'production' && isDebugMode()) {
var _path, _path2;
/* eslint-disable no-console */
console.groupCollapsed(`ReactGlobalState - useGlobalState setter triggered for path ${(_path = rc.path) !== null && _path !== void 0 ? _path : ''}`);
console.log('New value:', cloneDeepForLog(newState, (_path2 = rc.path) !== null && _path2 !== void 0 ? _path2 : ''));
console.groupEnd();
/* eslint-enable no-console */
}
rc.globalState.set(rc.path, newState);
// NOTE: The regular global state's update notifications, automatically
// triggered by the rc.globalState.set() call above, are batched, and
// scheduled to fire asynchronosuly at a later time, which is problematic
// for managed text inputs - if they have their value update delayed to
// future render cycles, it will result in reset of their cursor position
// to the value end. Calling the rc.emitter.emit() below causes a sooner
// state update for the current component, thus working around the issue.
// For additional details see the original issue:
// https://github.com/birdofpreyru/react-global-state/issues/22
if (newState !== rc.state) rc.emitter.emit();
},
state: isFunction(initialValue) ? initialValue() : initialValue,
subscribe: emitter.addListener.bind(emitter),
watcher: () => {
// TODO: Revise it later.
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression
const state = rc.globalState.get(rc.path);
if (state !== rc.state) rc.emitter.emit();
}
};
}
rc = ref.current;
rc.globalState = globalState;
rc.path = path;
rc.state = useSyncExternalStore(rc.subscribe, () => rc.globalState.get(rc.path, {
initialValue
}), () => rc.globalState.get(rc.path, {
initialState: true,
initialValue
}));
useEffect(() => {
const {
watcher
} = ref.current;
globalState.watch(watcher);
watcher();
return () => {
globalState.unWatch(watcher);
};
}, [globalState]);
useEffect(() => {
ref.current.watcher();
}, [path]);
return [rc.state, rc.setter];
}
export default useGlobalState;
// TODO: Revise.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
//# sourceMappingURL=useGlobalState.js.map