Last active
August 4, 2025 11:19
-
-
Save ferretwithaberet/b52121dadb741ca5ed64c84f724ef7c5 to your computer and use it in GitHub Desktop.
React utility to hoist deepest value to the root component
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | |
| }; | |
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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); | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Preface
I needed a way to "hoist" a value from multiple sources down the react component tree somewhere on the top.
I was configuringreact-native-toast-messagesuch 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 ofuseBottomTabBarHeight - safeAreaInsets.bottomfrom thescreenLayoutof myexpo-routerTabsthroughuseSetHoistedValue, then consuming it in my root layout throughuseHoistedValue.This could easily be modified to support normal React by replacingexpo-cryptowith the browser Crypto API anduseFocusEffectWithDeps(which is justuseFocusEffect+useCallbackwith deps) with a normaluseEffect.Now changed to work with web React instead of React Native
, not tested yet with React Nativeworks with React Native/Expo too, see React Native/Expo below.Usage
useHoistedValuewill, by default, return the value provided by the deepestHoistedValueProvideranywhere under the top-mostHoistedValueProvider.The value returned by
useHoistedValueis customizable through thereduceroption, which can be either:true: If all values are plain objects, it will merge them into a single value usinglodash.mergeand return said value instead, otherwise it will keep the default behavior of returning the deepest value;(values: TType[]) => TType: This option let's you combine all the provided values any way you want to,valuesis ordered by the deepness of each value, top-most value is first, deep-most value is last.React Native/Expo
If you are using
react-navigationorexpo-router, when usingcreateHoistedValuepassuseFocusEffectin theuseEffectoption. This is required asreact-navigationscreens do not, by default, get unmounted, they are kept rendered in the background, which causesuseEffectcleanups to never run, hence why we need to useuseFocusEffectinstead.