Created
October 6, 2020 15:56
-
-
Save karlrwjohnson/08f49f1f0746f82a239ba893ca4a886c to your computer and use it in GitHub Desktop.
Alternative React data store (no action objects; dispatch reducers directly)
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 {Dispatch, SetStateAction, useCallback, useLayoutEffect, useRef, useState} from "react"; | |
/** | |
* Object that stores immutable data, | |
* allows components to subscribe to changes in that data, | |
* and exposes functions to update the state by mapping the previous state to a new state | |
* | |
* Similar to Redux, except instead of dispatching actions which are consumed by a reducer, | |
* it's like you dispatch the reducer functions themselves. | |
* | |
* The ability to subscribe to parts of the state is also new -- React-Redux's `useSelector()` | |
* isn't parameterizable, but this class' hooks are parameterizable | |
* | |
* Example: | |
* | |
* ```tsx | |
* const store = new Store({ | |
* initialState: { | |
* pizzaIds: [] as string[], | |
* pizzas: {} as Record<string, Pizza>, | |
* }, | |
* actionsBuilder: setState => ({ | |
* updatePizza(pizzaId: string, updates: Partial<Pizza>) { | |
* setState(prev => { | |
* ...prev, | |
* [pizzaId]: { | |
* ...prev[pizzaId], | |
* ...updates | |
* }, | |
* }) | |
* } | |
* }), | |
* selectors: { | |
* usePizzaById: (pizzaId: string) => state => state.pizzas[pizzaId], | |
* usePizzaIds: () => state => state.pizzaIds, | |
* }, | |
* }); | |
* | |
* function PizzaList() { | |
* const pizzaIds = store.hookods.usePizzaById(); | |
* return ( | |
* <> | |
* {pizzaIds.map(pizzaId => ( | |
* <PizzaItem key={pizzaId} pizzaId={pizzaId} /> | |
* )} | |
* </> | |
* ) | |
* } | |
* | |
* function PizzaItem({ pizzaId }: { pizzaId: string}) { | |
* const pizza = store.hooks.usePizzaById(pizzaId); | |
* return ( | |
* <div> | |
* <label> | |
* Name: | |
* | |
* <input | |
* onChange={evt => store.actions.updatePizza({ name: evt.currentTarget.value })} | |
* value={pizza.name} | |
* /> | |
* </div> | |
* ) | |
* } | |
* ``` | |
*/ | |
export class Store<State, Actions, SelectorDefs extends AbstractSelectorDefs<State>> { | |
readonly actions: Actions; | |
readonly hooks: SelectorsFromDefs<SelectorDefs>; | |
readonly selectors: SelectorsFromDefs<SelectorDefs>; | |
private state: State; | |
private readonly stateChangeCallbacks = new CallbackManager<(state: State) => void>(); | |
/** | |
* Build an instance of a Store | |
* @param initialState - Initial value of the state | |
* @param actionsBuilder - Callback function to build the actions object. Each method of the actions object is expected to call setState() zero or one times, synchronously. | |
* @param selectors - A collection of curried functions (written as "use*" hooks) that | |
*/ | |
constructor( | |
{ | |
initialState, | |
actionsBuilder, | |
selectors, | |
}: { | |
initialState: State; | |
actionsBuilder: (setState: SetState<State>) => Actions, | |
selectors: SelectorDefs, | |
} | |
) { | |
this.state = initialState; | |
this.actions = actionsBuilder(this.setState); | |
this.hooks = mapObjectValues( | |
selectors, | |
<K extends keyof SelectorDefs & string>(selector: SelectorDefs[K]) => | |
(...args: Parameters<typeof selector>): ReturnType<ReturnType<SelectorDefs[K]>> => | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
this.useSelection(useCallback((state: State) => selector(...args)(state), [...args])) | |
); | |
this.selectors = mapObjectValues( | |
selectors, | |
<K extends keyof SelectorDefs & string>(selector: SelectorDefs[K]) => | |
(...args: Parameters<typeof selector>): ReturnType<ReturnType<SelectorDefs[K]>> => | |
selector(...args)(this.state) | |
); | |
} | |
/** | |
* Subscribes to a part of the state | |
* | |
* @param memoizedSelector - A "selector" which returns the part of the state to monitor. | |
* When its return value changes, the component is updated and useSelection() return the new value. | |
* It is expected that this callback function is memoized, e.g. using useCallback(). | |
* Failure to memoize it will cause false updates. | |
*/ | |
useSelection = <T>(memoizedSelector: (state: State) => T): T => { | |
const initialValue = memoizedSelector(this.state); | |
// Store the current value as a ref so `handleStateChanged` can inspect it without having to be re-memoized | |
// every time the value changes. (That would cause the `useLayoutEffect` to fire every time as well.) | |
// (ESLint exception because it's not a class component) | |
// eslint-disable-next-line react-hooks/rules-of-hooks | |
const projectedStateRef = useRef<T>(initialValue) | |
// Store the current value as a state value -- not so we can use it, but so that we can force a component refresh | |
// via `setProjectedState` whenever it changes. | |
// (ESLint exception because it's not a class component) | |
// eslint-disable-next-line react-hooks/rules-of-hooks | |
const [, setProjectedState] = useState(initialValue); | |
// (ESLint exception because it's not a class component) | |
// eslint-disable-next-line react-hooks/rules-of-hooks | |
const handleStateChanged = useCallback((newState: State) => { | |
const newProjection = memoizedSelector(newState); | |
if (newProjection !== projectedStateRef.current) { | |
projectedStateRef.current = newProjection; | |
setProjectedState(newProjection); | |
} | |
}, [memoizedSelector]); | |
// Register the callback synchronously using useLayoutEffect() (instead of useEffect()) | |
// because other effects might change the state before a useEffect() might run. | |
// (ESLint exception because it's not a class component) | |
// eslint-disable-next-line react-hooks/rules-of-hooks | |
useLayoutEffect(() => { | |
const cleanupFunction = this.stateChangeCallbacks.add(handleStateChanged); | |
return cleanupFunction; | |
}, [handleStateChanged]); | |
return projectedStateRef.current; | |
} | |
/** | |
* Applies a transformation to the current value of the state | |
* | |
* Not intended to be called from | |
* | |
* @param stateMapper | |
*/ | |
private setState = (stateMapper: SetStateAction<State>): void => { | |
const prevState = this.state; | |
const nextState = typeof stateMapper === 'function' ? (stateMapper as (state: State) => State)(prevState) : (stateMapper as State); | |
if (nextState === undefined) throw new Error('stateMapper returned undefined. This is probably a bug.'); | |
this.state = nextState; | |
console.debug('Prev:', prevState, 'Next:', nextState); | |
// Invoke callbacks | |
this.stateChangeCallbacks.notifyAll(nextState); | |
} | |
} | |
export type UseSelection<State> = <T>(memoizedSelector: (state: State) => T) => T; | |
export type SetState<State> = Dispatch<SetStateAction<State>>; | |
export interface AbstractSelectorDefs<State> { | |
[name: string]: (...args: any[]) => (state: State) => any; | |
} | |
export type SelectorsFromDefs<SD extends AbstractSelectorDefs<any>> = { | |
[K in keyof SD & string]: (...args: Parameters<SD[K]>) => ReturnType<ReturnType<SD[K]>> | |
} | |
/** | |
* Function that removes a callback from a CallbackManager when called | |
*/ | |
export type CallbackRemover = () => void; | |
/** | |
* Utility class for managing a list of callbacks and calling all of them in a loop | |
*/ | |
export class CallbackManager<T extends (...args: any[]) => void> { | |
private readonly callbackList: T[] = []; | |
add(callback: T): CallbackRemover { | |
this.callbackList.push(callback); | |
return () => this.remove(callback); | |
} | |
remove(callback: T): void { | |
// Mutable removal of an item from a list | |
const index = this.callbackList.indexOf(callback); | |
if (index < 0) return; // unlikely | |
this.callbackList.splice(index, 1); | |
} | |
/** | |
* Call every callback in the list | |
* | |
* If any of them throw errors, they'll be caught and logged | |
* | |
* @param args - Arguments to call the functions with | |
*/ | |
notifyAll(...args: Parameters<T>): void { | |
for (const callback of this.callbackList) { | |
try { | |
callback(...args); | |
} catch (e) { | |
console.error('Caught error in callback', e); | |
} | |
} | |
} | |
} | |
type AnEntry<T extends { [key: string]: any }, K extends keyof T> = [K, T[K]]; | |
type AnyEntry<T extends { [key: string]: any }> = AnEntry<T, keyof T & string>; | |
export function mapObjectValues< | |
T extends { [key: string]: any }, | |
TransformValues extends <K extends keyof T & string>(value: T[K], key: K) => any | |
>( | |
obj: T, | |
transformValues: TransformValues | |
): { [K in keyof T & string]: ReturnType<TransformValues> } { | |
const entries: AnyEntry<T>[] = Object.entries(obj); | |
const mappedEntries: AnyEntry<{ [K in keyof T]: ReturnType<TransformValues> }>[] = entries.map(([k, v]) => [k, transformValues(v, k)]); | |
return Object.fromEntries(mappedEntries) as any; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment