Skip to content

Instantly share code, notes, and snippets.

@karlrwjohnson
Created October 6, 2020 15:56
Show Gist options
  • Save karlrwjohnson/08f49f1f0746f82a239ba893ca4a886c to your computer and use it in GitHub Desktop.
Save karlrwjohnson/08f49f1f0746f82a239ba893ca4a886c to your computer and use it in GitHub Desktop.
Alternative React data store (no action objects; dispatch reducers directly)
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:
* &nbsp;
* <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