Victor Molina
Victor Molina

Reputation: 2641

React Hooks - useReducerWithCallback

I am refactoring the complex state management of one of my contexts, which previously used the hook "useStateWithCallback".

I have decided to refactor the code using "useReducer", which will make it more readable.

Currently, I am doing this:

export function ContentsProvider({ children }) {
  const [contents, setContents] = useStateWithCallback(new Map([]));

  const addContents = (newContents, callback = undefined) => {
    setContents(
      (prevContents) => new Map([...prevContents, ...newContents]),
      callback
    );
  };
  
  ...

Now, on any of my app components which consumes this context I can do:

contents.addContents([ ... ], () => { ... });

And the callback will be executed only when the state has changed.

Is there any way to achieve this behavior with useReducer? I mean, if I am passing the callback as argument to the method of my provider, then I will be not able to run a

useEffect(() => { callback?.() } , [contents]);

Any ideas?

I have tried this:

import { useReducer, useEffect } from 'react'

export const useReducerWithCallback = (
  initialState,
  reducer,
  callback = undefined
) => {
  const [state, dispatch] = useReducer(reducer, initialState);

  useEffect(() => {
    callback?.(state);
  }, [state, callback]);

  return [state, dispatch];
};

But it will not work, as in my use case, the callback is not always the same, so I mustn't declare the reducer with the callback.

Upvotes: 1

Views: 1534

Answers (2)

m.thompson
m.thompson

Reputation: 11

Drew's answer is great! If you're a TypeScript user, you could potentially type this function using the types from the @types/react declaration file:

/**
 * Defines the custom dispatch function that wraps the dispatch returned from useReducer. 
 */
type CustomReactDispatchWithCallback<A, S> = (action: A, callback?: DispatchCallback<S>) => void;

/**
 * Defines the callback contract. It should be a function that receives the updated state.
 */
type DispatchCallback<S> = (state: S) => void;

/**
 * Wraps `React.useReducer` and provides a custom dispatch function that accepts 
 * a callback that will be cached and then invoked when the reducer state changes.
 */
export function useReducerWithCallback<R extends React.Reducer<any, any>, I>(
  reducer: R,
  initialState: I & React.ReducerState<R>,
  initializer: (arg: I & React.ReducerState<R>) => React.ReducerState<R>
) {
  const callbackRef = React.useRef<DispatchCallback<React.ReducerState<R>>>();

  const [state, dispatch] = React.useReducer(reducer, initialState, initializer);

  React.useEffect(() => {
    callbackRef.current?.(state);
  }, [state]);

  const customDispatch: CustomReactDispatchWithCallback<
    React.ReducerAction<R>,
    React.ReducerState<R>
  > = (action, callback) => {
    callbackRef.current = callback;
    dispatch(action);
  };

  return [state, customDispatch] as const;
}

This doesn't account for all of React.useReducer's overloads, but worked for my case.

Upvotes: 1

Drew Reese
Drew Reese

Reputation: 202667

If I understand what you are looking for then I think a custom hook using a React ref and effect might achieve the behavior you seek.

  1. Use a React ref to hold a reference to a callback
  2. Use useEffect to call the callback when the state has updated
  3. Return a custom dispatch function to set the callback and dispatch an action to the reducer function.

Code

const useReducerWithCallback = (reducer, initialState, initializer) => {
  const callbackRef = useRef();
  const [state, dispatch] = useReducer(reducer, initialState, initializer);

  useEffect(() => {
    callbackRef.current?.(state);
  }, [state]);

  const customDispatch = (action, callback) => {
    callbackRef.current = callback;
    dispatch(action);
  };

  return [state, customDispatch];
};

const useReducerWithCallback = (reducer, initialState, initializer) => {
  const callbackRef = React.useRef();
  const [state, dispatch] = React.useReducer(reducer, initialState, initializer);

  React.useEffect(() => {
    callbackRef.current && callbackRef.current(state);
  }, [state]);

  const customDispatch = (action, callback) => {
    callbackRef.current = callback;
    dispatch(action);
  };

  return [state, customDispatch];
};

const reducer = (state, action) => {
  switch (action.type) {
    case "ADD":
      return state + action.payload;

    case "SUBTRACT":
      return state - action.payload;

    default:
      return state;
  }
};

function App() {
  const [state, dispatch] = useReducerWithCallback(reducer, 0);
  return (
    <div className="App">
      State: {state}
      <button
        type="button"
        onClick={() =>
          dispatch({ type: "ADD", payload: 1 }, (state) =>
            console.log("Added 1, state", state)
          )
        }
      >
        +
      </button>
      <button
        type="button"
        onClick={() =>
          dispatch({ type: "SUBTRACT", payload: 1 }, (state) =>
            console.log("Subtracted 1, state", state)
          )
        }
      >
        -
      </button>
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  rootElement
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>

Upvotes: 2

Related Questions