From 416b7ae3f0e955c69c787f1be1ff9d8b09ace010 Mon Sep 17 00:00:00 2001 From: luisdralves Date: Fri, 15 Jul 2022 14:04:54 +0100 Subject: [PATCH] Add `use-reducer-with-callback` hook --- .../use-reducer-with-callback/index.test.ts | 87 +++++++++++++++++++ .../src/use-reducer-with-callback/index.ts | 54 ++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 packages/hooks/src/use-reducer-with-callback/index.test.ts create mode 100644 packages/hooks/src/use-reducer-with-callback/index.ts diff --git a/packages/hooks/src/use-reducer-with-callback/index.test.ts b/packages/hooks/src/use-reducer-with-callback/index.test.ts new file mode 100644 index 0000000..d2d7ec4 --- /dev/null +++ b/packages/hooks/src/use-reducer-with-callback/index.test.ts @@ -0,0 +1,87 @@ +/** + * Module dependencies. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { useReducerWithCallback } from './'; + +/** + * Mock reducer `State` type. + */ + +type State = { + bar: number; + foo: number; +}; + +/** + * Mock reducer `Action` type. + */ + +type Action = 'incFoo' | 'incBar' | 'decFoo' | 'doubleBar'; + +/** + * Mock reducer. + */ + +const reducer = (state: State, action: Action) => { + switch (action) { + case 'incFoo': + return { ...state, foo: state.foo + 1 }; + + case 'incBar': + return { ...state, bar: state.bar + 1 }; + + case 'decFoo': + return { ...state, foo: state.foo - 1 }; + + case 'doubleBar': + return { ...state, bar: state.bar * 2 }; + + default: + return state; + } +}; + +/** + * Test `useReducerWithCallback` hook. + */ + +describe(`'useReducerWithCallback' hook`, () => { + it('should run the callback after the dispatch', () => { + const { result } = renderHook(() => { + const [state, dispatchWithCallback] = useReducerWithCallback< + State, + Action + >(reducer, { bar: 0, foo: 0 }); + + return { dispatchWithCallback, state }; + }); + + act(() => { + result.current.dispatchWithCallback('incBar', () => { + // Unlike the next assertion, this one only runs after the dispatch + expect(result.current.state).toEqual({ bar: 1, foo: 0 }); + }); + + // This assertion runs in the same render as the dispatch, and as such the state has not been updated yet + expect(result.current.state).toEqual({ bar: 0, foo: 0 }); + }); + + act(() => { + result.current.dispatchWithCallback('doubleBar', () => { + expect(result.current.state).toEqual({ bar: 2, foo: 0 }); + }); + + expect(result.current.state).toEqual({ bar: 1, foo: 0 }); + }); + + act(() => { + result.current.dispatchWithCallback('decFoo', () => { + expect(result.current.state).toEqual({ bar: 2, foo: -1 }); + }); + + expect(result.current.state).toEqual({ bar: 2, foo: 0 }); + }); + }); +}); diff --git a/packages/hooks/src/use-reducer-with-callback/index.ts b/packages/hooks/src/use-reducer-with-callback/index.ts new file mode 100644 index 0000000..6ace43e --- /dev/null +++ b/packages/hooks/src/use-reducer-with-callback/index.ts @@ -0,0 +1,54 @@ +/** + * Module dependencies. + */ + +import { Reducer, useCallback, useEffect, useReducer, useRef } from 'react'; + +/** + * Export `DispatchWithCallback` type. + */ + +export type DispatchWithCallback = ( + action: Action, + callback?: (state: State) => void +) => void; + +/** + * `Return` type. + */ + +type Return = [State, DispatchWithCallback]; + +/** + * Export `useReducerWithCallback` hook. + */ + +export function useReducerWithCallback( + reducer: (state: State, action: Action) => State, + initialState: State +): Return { + const [state, dispatch] = useReducer>( + reducer, + initialState + ); + + const callbackRef = useRef<((state: State) => void) | null>(null); + const dispatchWithCallback = useCallback( + (action: Action, callback?: (state: State) => void) => { + callbackRef.current = callback ?? null; + + return dispatch(action); + }, + [] + ); + + useEffect(() => { + if (callbackRef.current) { + callbackRef.current(state); + + callbackRef.current = null; + } + }, [state]); + + return [state, dispatchWithCallback]; +}