Skip to content

Instantly share code, notes, and snippets.

@ferretwithaberet
Last active August 4, 2025 11:19
Show Gist options
  • Save ferretwithaberet/b52121dadb741ca5ed64c84f724ef7c5 to your computer and use it in GitHub Desktop.
Save ferretwithaberet/b52121dadb741ca5ed64c84f724ef7c5 to your computer and use it in GitHub Desktop.
React utility to hoist deepest value to the root component
import isPlainObject from "lodash/isPlainObject";
import merge from "lodash/merge";
import {
createContext,
Dispatch,
EffectCallback,
SetStateAction,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { isArrayOf } from "@/utils/array";
export type HoistedValueContext<TType> = {
priority: number;
values: Map<number, TType>;
setValues: Dispatch<SetStateAction<Map<number, TType>>>;
};
export type HoistedValueReducer<TType> = (values: TType[]) => TType;
export type CreateHoistedValueOptions<TType> = {
reducer?: true | HoistedValueReducer<TType>;
useEffect?: (callback: EffectCallback) => void;
};
export type HoistedValueProviderProps<TType> = React.PropsWithChildren<{
value?: TType;
}>;
const useEffectWithoutDeps = (effect: EffectCallback) =>
useEffect(effect, [effect]);
const DEFAULT_REDUCER: HoistedValueReducer<unknown> = (values) => {
if (!isArrayOf(values, (value): value is object => isPlainObject(value)))
return values.at(-1);
return merge({}, ...values);
};
export const createHoistedValue = <TType,>(
initialValue: TType,
options: CreateHoistedValueOptions<TType> = {}
) => {
const { reducer, useEffect: useCustomEffect = useEffectWithoutDeps } =
options;
const context = createContext<HoistedValueContext<TType> | null>(null);
const useHoistedValue = () => {
const contextValue = useContext(context);
if (!contextValue)
throw new Error("useHoistedValue used outside of HoistedValueProvider");
const actualReducer = reducer
? typeof reducer === "function"
? reducer
: (DEFAULT_REDUCER as HoistedValueReducer<TType>)
: null;
const values = [initialValue, ...contextValue.values.values()];
const value = actualReducer?.(values) ?? values.at(-1);
return value !== undefined ? value : initialValue;
};
const useCommonProviderLogic = (
contextValue: HoistedValueContext<TType>,
value?: TType
) => {
const { priority, setValues } = contextValue;
const componentWillUnmountRef = useRef(false);
useCustomEffect(
useCallback(() => {
return () => {
componentWillUnmountRef.current = true;
};
}, [])
);
useCustomEffect(
useCallback(() => {
if (value === undefined) {
setValues((values) => {
const newMap = new Map(values);
newMap.delete(priority);
return newMap;
});
return;
}
setValues((values) => {
let newMap: Map<number, TType>;
if (!values.has(priority)) {
newMap = new Map(
[...values.entries(), [priority, value] as const].sort(
([key1], [key2]) => (key1 > key2 ? 1 : -1)
)
);
} else {
newMap = new Map(values);
newMap.set(priority, value);
}
return newMap;
});
return () => {
if (!componentWillUnmountRef.current) return;
setValues((values) => {
const newMap = new Map(values);
newMap.delete(priority);
return newMap;
});
};
}, [priority, value, setValues])
);
};
const RootValueProvider = (props: HoistedValueProviderProps<TType>) => {
const { value, children } = props;
const priority = 1;
const [values, setValues] = useState<Map<number, TType>>(new Map());
useCommonProviderLogic({ priority, values, setValues }, value);
return (
<context.Provider value={{ priority, values, setValues }}>
{children}
</context.Provider>
);
};
const NestedValueProvider = (props: HoistedValueProviderProps<TType>) => {
const { value, children } = props;
const contextValue = useContext(context);
if (!contextValue)
throw new Error("NestedValueProvider used outside of RootValueProvider");
const { priority: previousPriority, values, setValues } = contextValue;
const priority = previousPriority + 1;
useCommonProviderLogic({ priority, values, setValues }, value);
return (
<context.Provider value={{ priority, values, setValues }}>
{children}
</context.Provider>
);
};
const HoistedValueProvider = (props: HoistedValueProviderProps<TType>) => {
const contextValue = useContext(context);
return !contextValue ? (
<RootValueProvider {...props} />
) : (
<NestedValueProvider {...props} />
);
};
return {
useHoistedValue,
HoistedValueProvider,
};
};
// Checks and narrows an array or readonly array to the type specified by predicate
// ty @EskiMojo14 for this util
export function isArrayOf<TValue, TType extends TValue>(
array: TValue[],
predicate: (item: TValue) => item is TType
): array is TType[];
export function isArrayOf<TValue, TType extends TValue>(
array: readonly TValue[],
predicate: (item: TValue) => item is TType
): array is readonly TType[];
export function isArrayOf<TValue, TType extends TValue>(
array: readonly TValue[],
predicate: (item: TValue) => item is TType
): array is readonly TType[] {
return array.every(predicate);
}
@ferretwithaberet
Copy link
Author

ferretwithaberet commented Jun 17, 2025

Preface

I needed a way to "hoist" a value from multiple sources down the react component tree somewhere on the top. I was configuring react-native-toast-message such so, when on a screen with bottom tabs, the toast would place itself above the bottom tabs, this is what I came up with. I am passing the value of useBottomTabBarHeight - safeAreaInsets.bottom from the screenLayout of my expo-router Tabs through useSetHoistedValue, then consuming it in my root layout through useHoistedValue.

This could easily be modified to support normal React by replacing expo-crypto with the browser Crypto API and useFocusEffectWithDeps (which is just useFocusEffect + useCallback with deps) with a normal useEffect.

Now changed to work with web React instead of React Native , not tested yet with React Native works with React Native/Expo too, see React Native/Expo below.

Usage

useHoistedValue will, by default, return the value provided by the deepest HoistedValueProvider anywhere under the top-most HoistedValueProvider.
The value returned by useHoistedValue is customizable through the reducer option, which can be either:

  • true: If all values are plain objects, it will merge them into a single value using lodash.merge and return said value instead, otherwise it will keep the default behavior of returning the deepest value;
  • Reducer function with type (values: TType[]) => TType: This option let's you combine all the provided values any way you want to, values is ordered by the deepness of each value, top-most value is first, deep-most value is last.
// Usually outside of a react component
const {
  useHoistedValue: useMyColor,
  HoistedValueProvider: MyColorProvider,
} = createHoistedValue('#FF0000')

// Anywhere in the React component tree as many times as you need it. WARNING: All providers must be nested, you cannot have sibling providers
const Component = () => {
  return <MyColorProvider value="#00FF00">{children}</MyColorProvider>
}

// Anywhere under the top-most MyColorProvider
const color = useMyColor()

React Native/Expo

If you are using react-navigation or expo-router, when using createHoistedValue pass useFocusEffect in the useEffect option. This is required as react-navigation screens do not, by default, get unmounted, they are kept rendered in the background, which causes useEffect cleanups to never run, hence why we need to use useFocusEffect instead.

import { useFocusEffect } from "expo-router";

const {
  useHoistedValue: useMyColor,
  HoistedValueProvider: MyColorProvider,
} = createHoistedValue('#FF0000', {
  useEffect: useFocusEffect
})

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment