@dr.pogodin/react-global-state
Version:
Hook-based global state for React
141 lines (137 loc) • 6.11 kB
JavaScript
// Hook for updates of global state.
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { Emitter } from '@dr.pogodin/js-utils';
import { getGlobalState } from "./GlobalStateProvider.js";
import { cloneDeepForLog, isDebugMode } from "./utils.js";
/**
* 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
) {
var _ref$current;
const globalState = getGlobalState();
const ref = useRef(null);
const [stable] = useState(() => {
const emitter = new Emitter();
const setter = value => {
const rc = ref.current;
if (!rc) throw Error('Internal error');
const newState = typeof value === 'function' ? value(rc.globalState.get(rc.path)) : value;
if (process.env.NODE_ENV !== 'production' && isDebugMode()) {
var _rc$path, _rc$path2;
/* eslint-disable no-console */
console.groupCollapsed(`ReactGlobalState - useGlobalState setter triggered for path ${(_rc$path = rc.path) !== null && _rc$path !== void 0 ? _rc$path : ''}`);
console.log('New value:', cloneDeepForLog(newState, (_rc$path2 = rc.path) !== null && _rc$path2 !== void 0 ? _rc$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.prevValue) emitter.emit();
};
const subscribe = emitter.addListener.bind(emitter);
return {
emitter,
setter,
subscribe
};
});
const value = useSyncExternalStore(stable.subscribe, () => globalState.get(path, {
initialValue
}), () => globalState.get(path, {
initialState: true,
initialValue
}));
(_ref$current = ref.current) !== null && _ref$current !== void 0 ? _ref$current : ref.current = {
globalState,
path,
prevValue: value
};
useEffect(() => {
ref.current = {
globalState,
path,
prevValue: ref.current.prevValue
};
const watcher = () => {
const nextValue = globalState.get(path);
if (ref.current.prevValue !== nextValue) {
ref.current.prevValue = nextValue;
stable.emitter.emit();
}
};
globalState.watch(watcher);
watcher();
return () => {
globalState.unWatch(watcher);
};
}, [globalState, stable.emitter, path]);
return [value, stable.setter];
}
export default useGlobalState;
// TODO: Revise.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
//# sourceMappingURL=useGlobalState.js.map