diff --git a/README.md b/README.md index b2edcb5..c6b67d0 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ If you know how to [`useReducer`](https://reactjs.org/docs/hooks-reference.html# - [Installation](#installation) +- [Isn't this unsafe?](#isnt-this-unsafe) - [Quick Start](#quick-start) - [Named Effects](#named-effects) - [Effect Implementations](#effect-implementations) diff --git a/src/index.tsx b/src/index.tsx index ea049ac..06abb76 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,11 @@ -import { useReducer, useEffect, useCallback, useRef, useMemo } from 'react'; +import { + useReducer, + useEffect, + useLayoutEffect, + useCallback, + useRef, + useMemo, +} from 'react'; type CleanupFunction = () => void; @@ -18,6 +25,10 @@ export interface EffectObject { exec?: EffectFunction; } +interface InternalEffectObject + extends Record, + EffectObject {} + export type Effect< TState, TEvent extends EventObject, @@ -32,6 +43,8 @@ type EntityTuple = [ type AggregatedEffectsState = [ TState, EntityTuple[], + EffectEntity[], + EntityTuple[], EffectEntity[] ]; @@ -46,7 +59,8 @@ enum EntityStatus { Stopped, } -export interface EffectEntity { +export interface EffectEntity + extends Record { type: string; status: EntityStatus; start: (state: TState, dispatch: React.Dispatch) => void; @@ -56,13 +70,14 @@ export interface EffectEntity { function createEffectEntity< TState, TEvent extends EventObject, - TEffect extends EffectObject + TEffect extends InternalEffectObject >(effect: TEffect): EffectEntity { let effectCleanup: CleanupFunction | void; const entity: EffectEntity = { type: effect.type, status: EntityStatus.Idle, + [layoutEffectsSymbol]: !!effect[layoutEffectsSymbol], start: (state, dispatch) => { if (effect.exec) { effectCleanup = effect.exec(state, effect, dispatch); @@ -94,6 +109,9 @@ export interface EffectReducerExec< entity: EffectEntity | undefined, effect: TEffect | EffectFunction ) => EffectEntity; + layout: ( + effect: TEffect | EffectFunction + ) => EffectEntity; } export type EffectReducer< @@ -106,6 +124,7 @@ export type EffectReducer< exec: EffectReducerExec ) => TState; +const layoutEffectsSymbol = Symbol(); const flushEffectsSymbol = Symbol(); // 🚽 @@ -152,8 +171,13 @@ const toEffectObject = < >( effect: TEffect | EffectFunction, effectsMap?: EffectsMap -): TEffect => { +): InternalEffectObject => { const type = typeof effect === 'function' ? effect.name : effect.type; + const layoutEffect = + typeof effect === 'function' + ? false + : // @ts-ignore + !!effect[layoutEffectsSymbol]; const customExec = effectsMap ? effectsMap[type as TEffect['type']] : undefined; @@ -161,7 +185,12 @@ const toEffectObject = < customExec || (typeof effect === 'function' ? effect : effect.exec); const other = typeof effect === 'function' ? {} : effect; - return { ...other, type, exec } as TEffect; + return { + ...other, + type, + exec, + [layoutEffectsSymbol]: layoutEffect, + } as InternalEffectObject; }; export type InitialEffectStateGetter< @@ -184,35 +213,58 @@ export function useEffectReducer< effectsMap?: EffectsMap ): [TState, React.Dispatch] { const entitiesRef = useRef>>(new Set()); + const layoutEntitiesRef = useRef>>( + new Set() + ); const wrappedReducer = ( - [state, stateEffectTuples, entitiesToStop]: AggregatedEffectsState< - TState, - TEvent - >, + [ + state, + effectStateTuples, + entitiesToStop, + layoutEffectStateTuples, + layoutEntitiesToStop, + ]: AggregatedEffectsState, event: TEvent | FlushEvent ): AggregatedEffectsState => { const nextEffectEntities: Array> = []; const nextEntitiesToStop: Array> = []; + const nextLayoutEffectEntities: Array> = []; + const nextLayoutEntitiesToStop: Array> = []; if (event.type === flushEffectsSymbol) { // Record that effects have already been executed - return [state, stateEffectTuples.slice(event.count), nextEntitiesToStop]; + return [ + state, + effectStateTuples.slice(event.count), + nextEntitiesToStop, + layoutEffectStateTuples.slice(event.count), + nextLayoutEntitiesToStop, + ]; } const exec = ( effect: TEffect | EffectFunction ) => { const effectObject = toEffectObject(effect, effectsMap); - const effectEntity = createEffectEntity( - effectObject - ); - nextEffectEntities.push(effectEntity); + const effectEntity = createEffectEntity< + TState, + TEvent, + InternalEffectObject + >(effectObject); + + (effectObject[layoutEffectsSymbol] + ? nextLayoutEffectEntities + : nextEffectEntities + ).push(effectEntity); return effectEntity; }; exec.stop = (entity: EffectEntity) => { - nextEntitiesToStop.push(entity); + (entity[layoutEffectsSymbol] + ? nextLayoutEntitiesToStop + : nextEntitiesToStop + ).push(entity); }; exec.replace = ( @@ -220,11 +272,25 @@ export function useEffectReducer< effect: TEffect | EffectFunction ) => { if (entity) { - nextEntitiesToStop.push(entity); + (entity[layoutEffectsSymbol] + ? nextLayoutEntitiesToStop + : nextEntitiesToStop + ).push(entity); } return exec(effect); }; + exec.layout = ( + effect: TEffect | EffectFunction + ) => { + const effectObject = toEffectObject(effect, effectsMap); + return exec({ + ...effectObject, + // @ts-ignore + [layoutEffectsSymbol]: true, + }); + }; + const nextState = effectReducer( state, event, @@ -234,11 +300,17 @@ export function useEffectReducer< return [ nextState, nextEffectEntities.length - ? [...stateEffectTuples, [nextState, nextEffectEntities]] - : stateEffectTuples, + ? [...effectStateTuples, [nextState, nextEffectEntities]] + : effectStateTuples, entitiesToStop.length ? [...entitiesToStop, ...nextEntitiesToStop] : nextEntitiesToStop, + nextLayoutEffectEntities.length + ? [...layoutEffectStateTuples, [nextState, nextLayoutEffectEntities]] + : layoutEffectStateTuples, + layoutEntitiesToStop.length + ? [...layoutEntitiesToStop, ...nextLayoutEntitiesToStop] + : nextLayoutEntitiesToStop, ]; }; @@ -248,6 +320,10 @@ export function useEffectReducer< > = useMemo(() => { if (typeof initialState === 'function') { const initialEffectEntities: Array> = []; + const initialLayoutEffectEntities: Array> = []; const resolvedInitialState = (initialState as InitialEffectStateGetter< TState, @@ -255,11 +331,16 @@ export function useEffectReducer< TEffect >)(effect => { const effectObject = toEffectObject(effect, effectsMap); - const effectEntity = createEffectEntity( - effectObject - ); - - initialEffectEntities.push(effectEntity); + const effectEntity = createEffectEntity< + TState, + TEvent, + InternalEffectObject + >(effectObject); + + (effectObject[layoutEffectsSymbol] + ? initialLayoutEffectEntities + : initialEffectEntities + ).push(effectEntity); return effectEntity; }); @@ -267,14 +348,22 @@ export function useEffectReducer< resolvedInitialState, [[resolvedInitialState, initialEffectEntities]], [], + [[resolvedInitialState, initialLayoutEffectEntities]], + [], ]; } - return [initialState, [], []]; + return [initialState, [], [], [], []]; }, []); const [ - [state, effectStateEntityTuples, entitiesToStop], + [ + state, + effectStateEntityTuples, + entitiesToStop, + layoutEffectStateEntityTuples, + layoutEntitiesToStop, + ], dispatch, ] = useReducer(wrappedReducer, initialStateAndEffects); @@ -325,5 +414,43 @@ export function useEffectReducer< }; }, []); + // Now do it all again, but with layout effects + useLayoutEffect(() => { + if (layoutEntitiesToStop.length) { + layoutEntitiesToStop.forEach(entity => { + entity.stop(); + layoutEntitiesRef.current.delete(entity); + }); + } + }, [layoutEntitiesToStop]); + + useLayoutEffect(() => { + if (layoutEffectStateEntityTuples.length) { + layoutEffectStateEntityTuples.forEach(([effectState, effectEntities]) => { + effectEntities.forEach(entity => { + if (entity.status !== EntityStatus.Idle) return; + + layoutEntitiesRef.current.add(entity); + entity.start(effectState, dispatch); + }); + }); + + dispatch({ + type: flushEffectsSymbol, + count: layoutEffectStateEntityTuples.length, + }); + } + }, [layoutEffectStateEntityTuples]); + + useLayoutEffect(() => { + return () => { + layoutEntitiesRef.current.forEach(entity => { + if (entity.status === EntityStatus.Started) { + entity.stop(); + } + }); + }; + }, []); + return [state, wrappedDispatch]; } diff --git a/test/useEffectReducer.test.tsx b/test/useEffectReducer.test.tsx index 1ffb900..23f07a9 100644 --- a/test/useEffectReducer.test.tsx +++ b/test/useEffectReducer.test.tsx @@ -605,4 +605,102 @@ describe('useEffectReducer', () => { expect(stopped).toBeTruthy(); }); }); + + it('should work with useLayoutEffect', async () => { + interface User { + name: string; + } + + type FetchState = + | { + status: 'idle'; + user: undefined; + } + | { + status: 'fetching'; + user: User | undefined; + } + | { + status: 'fulfilled'; + user: User; + }; + + type FetchEvent = + | { + type: 'FETCH'; + user: string; + } + | { + type: 'RESOLVE'; + data: User; + }; + + type FetchEffect = { + type: 'fetchFromAPI'; + user: string; + }; + + const fetchEffectReducer: EffectReducer< + FetchState, + FetchEvent, + FetchEffect + > = (state, event, exec) => { + switch (event.type) { + case 'FETCH': + exec.layout({ + type: 'fetchFromAPI', + user: event.user, + }); + return { + ...state, + status: 'fetching', + }; + case 'RESOLVE': + return { + status: 'fulfilled', + user: event.data, + }; + default: + return state; + } + }; + + const Fetcher = () => { + const [state, dispatch] = useEffectReducer( + fetchEffectReducer, + { status: 'idle', user: undefined }, + { + fetchFromAPI(_, effect) { + setTimeout(() => { + dispatch({ + type: 'RESOLVE', + data: { name: effect.user }, + }); + }, 100); + }, + } + ); + + return ( +
dispatch({ type: 'FETCH', user: '42' })} + data-testid="result" + > + {state.user ? state.user.name : '--'} +
+ ); + }; + + const { getByTestId } = render(); + + const resultEl = getByTestId('result'); + + expect(resultEl.textContent).toEqual('--'); + + fireEvent.click(resultEl); + + await waitFor(() => { + expect(resultEl.textContent).toEqual('42'); + }); + }); });