-
-
Save damassi/1fb0cfc79da1dc0406e27a41c4b2d3ad to your computer and use it in GitHub Desktop.
This file contains 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 React, { useContext, useEffect, useMemo, useState } from "react" | |
type ProviderComponent<T> = React.FC<{ initialValue: T }> | |
interface PrivateContextValue<T> { | |
useGlobalStateProxy(): T | |
} | |
class GlobalStateContext<T> { | |
constructor(private context: React.Context<PrivateContextValue<T>>, public Provider: ProviderComponent<T>) {} | |
} | |
/** | |
* createGlobalState | |
* | |
* An alternative to react.createContext for creating global state. It is reactive and allows more fine-grained | |
* update propagation. This helps avoid unecessary re-renders in performance senstivie situations, like the | |
* ImageCarousel. | |
* | |
* Usage | |
* | |
* const MyGlobalState = createGlobalState<MyGlobalStateType>() | |
* | |
* then add a provider in your react tree | |
* | |
* <MyGlobalState.Provider initialValue={{showThing: false, numThings: 0}}> | |
* <App /> | |
* </MyGlobalState.Provider> | |
* | |
* Every functional component below this provider will be able to use the `useGlobalState` hook as follows | |
* | |
* const {showThing} = useGlobalState(MyGlobalState) | |
* | |
* Then whenever showThing updates, so will the component using it. | |
* | |
* To update state, use property assignment on the returned object like so | |
* | |
* const state = useGlobalState(MyGlobalState) | |
* | |
* const onToggle = useCallback(() => { | |
* state.showThing = !state.showThing | |
* }, []) | |
* | |
* Using this non-destructuring assignment gives the added benefit of avoiding the need to | |
* declare individual state values in cache-busting arrays on useMemo, useCallback, et al. | |
*/ | |
export function createGlobalState<T extends object>(): GlobalStateContext<T> { | |
// use regular react context under the hood | |
const context = React.createContext<PrivateContextValue<T>>({ | |
useGlobalStateProxy() { | |
throw new Error("no global state provider in tree") | |
}, | |
}) | |
const Provider: ProviderComponent<T> = ({ initialValue, children }) => { | |
const contextValue: PrivateContextValue<T> = useMemo(() => { | |
const store = { ...initialValue } | |
const listeners: { [stateKey: string]: { [listenerId: string]: () => void } } = {} as any | |
Object.keys(initialValue).forEach(key => { | |
listeners[key] = {} | |
}) | |
// set up a listener for a particular react component on a particular state key | |
function listen(listenerId: symbol, key: keyof T, cb: () => void) { | |
// TypeScript doesn't let you use symbols as keys for various reasons, so we need to cast as any here | |
listeners[key][listenerId as any] = cb | |
} | |
// return true iff a particualr react component is listening to a particular state key | |
function isListening(listenerId: symbol, key: keyof T) { | |
return Boolean(listeners[key][listenerId as any]) | |
} | |
// unlisten to all state keys for a particualr component. Called when the component unmounts | |
function unlisten(listenerId: symbol) { | |
for (const key of Object.keys(listeners)) { | |
delete listeners[key][listenerId as any] | |
} | |
} | |
// broadcast a change to a particular state key, calling all registered callbacks | |
function announce(key: keyof T) { | |
for (const listenerId of Object.getOwnPropertySymbols(listeners[key])) { | |
listeners[key][listenerId as any]() | |
} | |
} | |
return { | |
useGlobalStateProxy() { | |
const listenerId = useMemo(() => Symbol(), []) | |
// add unmount handler to unregister all listeners | |
useEffect(() => () => unlisten(listenerId), []) | |
// Use a numberic 'epoch' to trigger re-renders of the component. | |
// We don't care about the actual epoch value, as long it changes | |
// whenever the relevant global state values change. | |
const setEpoch = useState(0)[1] | |
return new Proxy<T>({} as any, { | |
get(_, key: keyof T) { | |
if (!isListening(listenerId, key)) { | |
listen(listenerId, key, () => setEpoch(x => x + 1)) | |
} | |
return store[key] | |
}, | |
set(_, key: keyof T, newValue: T[typeof key]) { | |
if (newValue !== store[key]) { | |
store[key] = newValue | |
announce(key) | |
} | |
return true | |
}, | |
}) | |
}, | |
} | |
}, []) | |
return <context.Provider value={contextValue}>{children}</context.Provider> | |
} | |
return new GlobalStateContext(context, Provider) | |
} | |
export function useGlobalState<T extends object>(context: GlobalStateContext<T>) { | |
// @ts-ignore private filed context.context | |
return useContext(context.context).useGlobalStateProxy() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment