123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147 |
- import type React from 'react';
- import {
- type ReducerAction,
- type ReducerState,
- useCallback,
- useMemo,
- useRef,
- useState,
- } from 'react';
- /**
- * A hook that wraps a reducer to provide an observer pattern for the state.
- * By observing the state, we can avoid reacting to it via effects
- *
- * @param reducer The reducer function that updates the state.
- * @param initialState The initial state of the reducer.
- * @param initializer An optional function that can be used to initialize the state.
- */
- type ArgumentTypes<F extends Function> = F extends (...args: infer A) => any ? A : never;
- export interface DispatchingReducerMiddleware<R extends React.Reducer<any, any>> {
- ['before action']: (S: Readonly<ReducerState<R>>, A: React.ReducerAction<R>) => void;
- ['before next state']: (
- P: Readonly<React.ReducerState<R>>,
- S: Readonly<React.ReducerState<R>>,
- A: React.ReducerAction<R>
- ) => void;
- }
- type MiddlewaresEvent<R extends React.Reducer<any, any>> = {
- [K in keyof DispatchingReducerMiddleware<R>]: Set<DispatchingReducerMiddleware<R>[K]>;
- };
- export class DispatchingReducerEmitter<R extends React.Reducer<any, any>> {
- listeners: MiddlewaresEvent<R> = {
- 'before action': new Set<DispatchingReducerMiddleware<R>['before action']>(),
- 'before next state': new Set<DispatchingReducerMiddleware<R>['before next state']>(),
- };
- on(
- key: keyof DispatchingReducerMiddleware<R>,
- fn: DispatchingReducerMiddleware<R>[keyof DispatchingReducerMiddleware<R>]
- ) {
- const store = this.listeners[key];
- if (!store) {
- throw new Error(`Unsupported reducer middleware: ${key}`);
- }
- // @ts-expect-error we cant actually validate function types here
- store.add(fn);
- }
- off(
- key: keyof DispatchingReducerMiddleware<R>,
- listener: DispatchingReducerMiddleware<R>[keyof DispatchingReducerMiddleware<R>]
- ) {
- const store = this.listeners[key];
- if (!store) {
- throw new Error(`Unsupported reducer middleware: ${key}`);
- }
- // @ts-expect-error we cant actually validate function types here
- store.delete(listener);
- }
- emit(
- key: keyof DispatchingReducerMiddleware<R>,
- ...args: ArgumentTypes<DispatchingReducerMiddleware<R>[typeof key]>
- ) {
- const store = this.listeners[key];
- if (!store) {
- throw new Error(`Unsupported reducer middleware: ${key}`);
- }
- store.forEach(fn => fn(...args));
- }
- }
- function update<R extends React.Reducer<any, any>>(
- state: ReducerState<R>,
- actions: ReducerAction<R>[],
- reducer: R,
- emitter: DispatchingReducerEmitter<R>
- ) {
- if (!actions.length) {
- return state;
- }
- let start = state;
- while (actions.length > 0) {
- const next = actions.shift()!;
- emitter.emit('before action', start, next);
- const nextState = reducer(start, next);
- emitter.emit('before next state', start, nextState, next);
- start = nextState;
- }
- return start;
- }
- export function useDispatchingReducer<R extends React.Reducer<any, any>>(
- reducer: R,
- initialState: ReducerState<R>,
- initializer?: (arg: ReducerState<R>) => ReducerState<R>
- ): [ReducerState<R>, React.Dispatch<ReducerAction<R>>, DispatchingReducerEmitter<R>] {
- const emitter = useMemo(() => new DispatchingReducerEmitter<R>(), []);
- const [state, setState] = useState(
- initialState ?? (initializer?.(initialState) as ReducerState<R>)
- );
- const stateRef = useRef(state);
- stateRef.current = state;
- const reducerRef = useRef(reducer);
- reducerRef.current = reducer;
- const actionQueue = useRef<ReducerAction<R>[]>([]);
- const updatesRef = useRef<number | null>(null);
- const wrappedDispatch = useCallback(
- (a: ReducerAction<R>) => {
- // @TODO it is possible for a dispatched action to throw an error
- // and break the reducer. We should probably catch it, I'm just not sure
- // what would be the best mechanism to handle it. If we opt to rethrow,
- // we are likely going to have to rethrow async and lose stack traces...
- actionQueue.current.push(a);
- if (updatesRef.current !== null) {
- window.cancelAnimationFrame(updatesRef.current);
- }
- window.requestAnimationFrame(() => {
- setState(s => {
- const next = update(s, actionQueue.current, reducerRef.current, emitter);
- stateRef.current = next;
- return next;
- });
- });
- },
- // Emitter is stable and can be ignored
- // eslint-disable-next-line react-hooks/exhaustive-deps
- []
- );
- return [state, wrappedDispatch, emitter];
- }
|