Skip to content

Instantly share code, notes, and snippets.

@audunolsen
Created January 5, 2024 12:27
Show Gist options
  • Save audunolsen/c18602685ef5db913fb52701df2c99e2 to your computer and use it in GitHub Desktop.
Save audunolsen/c18602685ef5db913fb52701df2c99e2 to your computer and use it in GitHub Desktop.
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