useDispatchingReducer.tsx 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. import type React from 'react';
  2. import {
  3. type ReducerAction,
  4. type ReducerState,
  5. useCallback,
  6. useMemo,
  7. useRef,
  8. useState,
  9. } from 'react';
  10. /**
  11. * A hook that wraps a reducer to provide an observer pattern for the state.
  12. * By observing the state, we can avoid reacting to it via effects
  13. *
  14. * @param reducer The reducer function that updates the state.
  15. * @param initialState The initial state of the reducer.
  16. * @param initializer An optional function that can be used to initialize the state.
  17. */
  18. type ArgumentTypes<F extends Function> = F extends (...args: infer A) => any ? A : never;
  19. export interface DispatchingReducerMiddleware<R extends React.Reducer<any, any>> {
  20. ['before action']: (S: Readonly<ReducerState<R>>, A: React.ReducerAction<R>) => void;
  21. ['before next state']: (
  22. P: Readonly<React.ReducerState<R>>,
  23. S: Readonly<React.ReducerState<R>>,
  24. A: React.ReducerAction<R>
  25. ) => void;
  26. }
  27. type MiddlewaresEvent<R extends React.Reducer<any, any>> = {
  28. [K in keyof DispatchingReducerMiddleware<R>]: Set<DispatchingReducerMiddleware<R>[K]>;
  29. };
  30. export class DispatchingReducerEmitter<R extends React.Reducer<any, any>> {
  31. listeners: MiddlewaresEvent<R> = {
  32. 'before action': new Set<DispatchingReducerMiddleware<R>['before action']>(),
  33. 'before next state': new Set<DispatchingReducerMiddleware<R>['before next state']>(),
  34. };
  35. on(
  36. key: keyof DispatchingReducerMiddleware<R>,
  37. fn: DispatchingReducerMiddleware<R>[keyof DispatchingReducerMiddleware<R>]
  38. ) {
  39. const store = this.listeners[key];
  40. if (!store) {
  41. throw new Error(`Unsupported reducer middleware: ${key}`);
  42. }
  43. // @ts-expect-error we cant actually validate function types here
  44. store.add(fn);
  45. }
  46. off(
  47. key: keyof DispatchingReducerMiddleware<R>,
  48. listener: DispatchingReducerMiddleware<R>[keyof DispatchingReducerMiddleware<R>]
  49. ) {
  50. const store = this.listeners[key];
  51. if (!store) {
  52. throw new Error(`Unsupported reducer middleware: ${key}`);
  53. }
  54. // @ts-expect-error we cant actually validate function types here
  55. store.delete(listener);
  56. }
  57. emit(
  58. key: keyof DispatchingReducerMiddleware<R>,
  59. ...args: ArgumentTypes<DispatchingReducerMiddleware<R>[typeof key]>
  60. ) {
  61. const store = this.listeners[key];
  62. if (!store) {
  63. throw new Error(`Unsupported reducer middleware: ${key}`);
  64. }
  65. store.forEach(fn => fn(...args));
  66. }
  67. }
  68. function update<R extends React.Reducer<any, any>>(
  69. state: ReducerState<R>,
  70. actions: ReducerAction<R>[],
  71. reducer: R,
  72. emitter: DispatchingReducerEmitter<R>
  73. ) {
  74. if (!actions.length) {
  75. return state;
  76. }
  77. let start = state;
  78. while (actions.length > 0) {
  79. const next = actions.shift()!;
  80. emitter.emit('before action', start, next);
  81. const nextState = reducer(start, next);
  82. emitter.emit('before next state', start, nextState, next);
  83. start = nextState;
  84. }
  85. return start;
  86. }
  87. export function useDispatchingReducer<R extends React.Reducer<any, any>>(
  88. reducer: R,
  89. initialState: ReducerState<R>,
  90. initializer?: (arg: ReducerState<R>) => ReducerState<R>
  91. ): [ReducerState<R>, React.Dispatch<ReducerAction<R>>, DispatchingReducerEmitter<R>] {
  92. const emitter = useMemo(() => new DispatchingReducerEmitter<R>(), []);
  93. const [state, setState] = useState(
  94. initialState ?? (initializer?.(initialState) as ReducerState<R>)
  95. );
  96. const stateRef = useRef(state);
  97. stateRef.current = state;
  98. const reducerRef = useRef(reducer);
  99. reducerRef.current = reducer;
  100. const actionQueue = useRef<ReducerAction<R>[]>([]);
  101. const updatesRef = useRef<number | null>(null);
  102. const wrappedDispatch = useCallback(
  103. (a: ReducerAction<R>) => {
  104. // @TODO it is possible for a dispatched action to throw an error
  105. // and break the reducer. We should probably catch it, I'm just not sure
  106. // what would be the best mechanism to handle it. If we opt to rethrow,
  107. // we are likely going to have to rethrow async and lose stack traces...
  108. actionQueue.current.push(a);
  109. if (updatesRef.current !== null) {
  110. window.cancelAnimationFrame(updatesRef.current);
  111. }
  112. window.requestAnimationFrame(() => {
  113. setState(s => {
  114. const next = update(s, actionQueue.current, reducerRef.current, emitter);
  115. stateRef.current = next;
  116. return next;
  117. });
  118. });
  119. },
  120. // Emitter is stable and can be ignored
  121. // eslint-disable-next-line react-hooks/exhaustive-deps
  122. []
  123. );
  124. return [state, wrappedDispatch, emitter];
  125. }