react-singleton-hook
is a library that exposes a method to convert any hook into a singleton hook which makes sure that only one (or 0) instance of the hook is alive at any time.react-singleton-hook
:SingletonHooksContainer
— We need a place where all our singleton hooks instances can stay alive. SingletonHooksContainer
is a component in which all the singleton hooks will be mounted.SingleItemcontainer
— SingletonHooksContainer
will render a component called SingleItemContainer
for each of the singleton hooks. Whenever the return value of the hook changes, this component will “apply” the changes to all the places where the hook is used.singletonHook
— This is the function that converts any hook into a singleton hook. This function will instantiate the hook if it is not already instantiated, create a consumer wherever it is used, define the functions that apply the return value changes in these consumers and also define some cleanup logic that will be executed when the hook will be unmounted.export type Hook<HookReturn = any, HookArgs extends any[] = any[]> = {
/**initial return value of the hook (before the hook is actually called) */
initValue: HookReturn;
/**the hook which we want to make singleton */
useHookBody: Function;
/**a function that informs all the hook consumers of any change in the hook return value */
applyStateChange: (newState: HookReturn) => void;
/**the arguments that are passed to the hook while using it */
args: HookArgs;
};
import React, { useEffect, useState } from 'react';
import { SingleItemContainer } from './SingleItemContainer';
import { Hook } from './singletonHookTypes';
let SingletonHooksContainerMounted = false;
let mountIntoContainer: <HookReturn = any, HookArgs extends any[] = any[]>(hook: Hook<HookReturn, HookArgs>) => void;
let unmountFromContainer: <HookReturn = any, HookArgs extends any[] = any[]>(hook: Hook<HookReturn, HookArgs>) => void;
SingletonHooksContainerMounted
and throw an error if the component is being mounted more than once. We will also set SingletonHooksContainerMounted
to false in the cleanup function.export const SingletonHooksContainer = () => {
useEffect(() => {
if (SingletonHooksContainerMounted) {
console?.log?.(
'SingletonHooksContainer is mounted second time. ' +
'You should mount SingletonHooksContainer before any other component and never unmount it.'
);
}
SingletonHooksContainerMounted = true;
return () => {
SingletonHooksContainerMounted = false;
};
}, []);
hooks
which is a list of all the singleton hooks that are being used at that point in time. We then assign behaviour to mountIntoContainer
and unmountFromContainer
where we append the hook to the list and filter the hook out from the list respectively.const [hooks, setHooks] = useState<Hook[]>([]);
useEffect(() => {
mountIntoContainer = (newHook: Hook) => setHooks((hooks) => [...hooks, newHook]);
unmountFromContainer = (hookToUnmount: Hook) =>
setHooks((hooks) => hooks.filter((hook) => hook.useHookBody !== hookToUnmount.useHookBody));
setHooks([]);
}, []);
SingleItemContainer
for each hook.return (
<>
{hooks.map((hook, index) => (
<SingleItemContainer {...hook} key={index} />
))}
</>
);
SingletonHooksContainer
for each hook. This component will accept the hook with type Hook
as prop. It will keep track of the previous state (in this blog, by “state” of the hook, we refer to the hook’s return value). There is also a safety check to make sure that the useHookBody
is a function.import { useEffect, useRef } from 'react';
import { Hook } from './singletonHookTypes';
export const SingleItemContainer = ({ initValue, useHookBody, applyStateChange, args = [] }: Hook) => {
const lastState = useRef(initValue);
if (typeof useHookBody !== 'function') {
throw new Error(`function expected as hook body parameter. got ${typeof useHookBody}`);
}
useHookBody
along with the arguments args
. We define an effect to run applyStateChange
whenever hookReturn
changes and is different from the lastState
.const hookReturn = useHookBody(...args);
useEffect(() => {
if (lastState.current !== hookReturn) {
lastState.current = hookReturn;
applyStateChange(hookReturn);
}
}, [applyStateChange, JSON.stringify(hookReturn)]);
initValue
and useHookBody
which is the initial return value and the actual hook respectively.mounted
: boolean flag which tells if the hook is already mountedinitStateCalculated
: a boolean flag that tells us if the initial state of the hook is calculatedlastKnownState
: stores the latest hook return valueconsumers
: an array of all the hook consumers. This will basically be an array of setState
functions.export function singletonHook<HookReturn = any, HookArgs extends any[] = any[]>(
initValue: HookReturn,
useHookBody: () => HookReturn
) {
let mounted = false;
let initStateCalculated = false;
let lastKnownState: HookReturn | undefined;
let consumers: React.Dispatch<React.SetStateAction<HookReturn>>[] = [];
applyStateChange
which will be executed whenever the return value changes (as we saw in SingleItemContainer
). This function executes all the consumers (setState
methods) with the new value, in a single batch. We also define a function stateInitializer
which stores the initial state in lastKnownState
and returns it.const applyStateChange = (newState: HookReturn) => {
lastKnownState = newState;
batch(() => consumers.forEach((setState) => setState(newState)));
};
const stateInitializer = () => {
if (!initStateCalculated) {
lastKnownState = initValue;
initStateCalculated = true;
}
return lastKnownState as HookReturn;
};
singletonHook
returns a hook (which is a singleton). We first get the initial state using useState
. We then define an effect which will take care of making this hook a singleton. If the hook is not mounted already, we mount it using mountIntoContainer
function which we saw earlier. We also push the setState
function into the consumer array. If lastKnownState
is not equal to the state value, we update the state using the setState
method. We then define the cleanup logic which gets executed when the hook (or rather the component that consumes the hook) is being unmounted. We remove the consumer from the consumers array. If there are no more consumers, we update the different flags to their initial values and unmount the hook using the unmountFromContainer
method we saw earlier. This singleton hook returns state
which is the return value of the hook which we made into a singleton.return (args: HookArgs) => {
const [state, setState] = useState(stateInitializer);
useEffect(() => {
if (!mounted) {
mounted = true;
addHook<HookReturn, HookArgs>({ initValue, useHookBody, applyStateChange, args });
}
consumers.push(setState);
if (lastKnownState && lastKnownState !== state) {
setState(lastKnownState);
}
return () => {
consumers.splice(consumers.indexOf(setState), 1);
if (consumers.length === 0) {
mounted = false;
initStateCalculated = false;
lastKnownState = undefined;
removeHook({ initValue, useHookBody, applyStateChange, args });
}
};
}, []);
return state;
};
reference: Understanding react-singleton-hook