Skip to content

Instantly share code, notes, and snippets.

@ds300
Created July 24, 2019 13:50
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save ds300/db215cdcbd9844a4d65f78bd58de7252 to your computer and use it in GitHub Desktop.
Save ds300/db215cdcbd9844a4d65f78bd58de7252 to your computer and use it in GitHub Desktop.
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