Created
January 5, 2024 12:27
-
-
Save audunolsen/c18602685ef5db913fb52701df2c99e2 to your computer and use it in GitHub Desktop.
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 { useReducer, type Reducer, useEffect, useRef, useMemo } from 'react'; | |
import { useConst } from '../hooks'; | |
/** | |
* For when side effects are needed using React's built in useReducer. | |
* You can listen to dispatched events and fire callbacks when the state is updated. | |
* | |
* @note | |
* - Only criteria is that events/actions are typed as objects w a required `type` field. | |
* | |
* - Only events that results in an actual state change fires an event. If the state | |
* is a complex data-type this means returning a new reference | |
* | |
* - recursive reducers are not supported and will not trigger events | |
* | |
* @example | |
* ```typescript | |
* type Event = 'jump' | 'swim'; | |
* type State = { activity?: 'jumping' | 'swimming' }; | |
* | |
* const reducer: Reducer<State, Event> = (prevState, event) => { | |
* return event === 'jump' ? { activity: 'jumping' } : { activity: 'swimming' } | |
* }; | |
* | |
* // Inside component | |
* const [state, dispatch, useEvent] = useReducerWithEffects(reducer, {}); | |
* | |
* useEvent('jump', () => { console.log('SUCCESSFUL EVENT FIRED') }) | |
* ``` | |
*/ | |
export default function useReducerWithEffects<E extends Event, S>( | |
reducer: Reducer<S, E>, | |
initialState: S, | |
) { | |
const callbacks = useRef<CallbackRefs<E>>({}); | |
const proxiedReducer = new Proxy(reducer, { | |
apply(target, at, args) { | |
const nextState = Reflect.apply(target, at, args); | |
const [prevState, event] = <[S, E]>args; | |
callbacks.current[event.type] = { | |
...callbacks.current[event.type], | |
queued: nextState !== prevState, | |
payload: event, | |
}; | |
return nextState; | |
}, | |
}); | |
const [state, dispatch] = useReducer(proxiedReducer, initialState); | |
const useEvent: UseEvent<E, S> = useConst(function useEvent(e, cb) { | |
callbacks.current[e] = { ...callbacks.current[e], cb }; | |
useEffect(() => { | |
return function cleanup() { | |
delete callbacks.current[e]; | |
}; | |
}, []); | |
}); | |
useEffect(() => { | |
for (const [key, e] of Object.entries(callbacks.current)) | |
if (e.queued) { | |
e.cb?.(state, e.payload); | |
delete callbacks.current[key].queued; | |
delete callbacks.current[key].payload; | |
} | |
}, [state]); | |
return useMemo(() => [state, dispatch, useEvent] as const, [state]); | |
} | |
/** | |
* The base type that an Event/Action payload must extend | |
*/ | |
type Event = { type: string }; | |
export interface UseEvent<E extends Event, S> { | |
/** | |
* Fires callback when an event that is fired results in a changed state. | |
* | |
* Use this to accociate side effects with state changes while decoupling | |
* the effects from the reducer logic | |
*/ | |
<Picked extends E['type']>( | |
event: Picked, | |
cb: (state: S, payload: Extract<E, { type: Picked }>) => void, | |
): void; | |
} | |
type CallbackRefs<E extends Event> = Record< | |
string, | |
Partial<{ | |
cb: (...args: any) => void; | |
queued: boolean; | |
payload: E; | |
}> | |
>; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment