Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Async Operations with useReducer Hook

Async Operations with useReducer Hook

9 March, 2019

We were discussing with @erusev what we can do with async operation when using useReducer() in our application. Our app is simple and we don't want to use a state management library. All our requirements are satisfied with using one root useReducer(). The problem we are facing and don't know how to solve is async operations.

In a discussion with Dan Abramov he recommends Solution 3 but points out that things are fresh with hooks and there could be better ways of handling the problem.

Problem

Doing asynchronous operations in a useReducer reducer is not possible. We have thought of three possible solutions and can't figure which one is better or if there is an even better solution.

We are searching for a solution where a single action will be used multiple times in multiple places all over the application.

Solution 1

Just manually call the async function and after it completes call the dispatch.

Pros

  • No additional abstraction
  • Doesn't introduce additional learning curve because it uses already existing ideas

Cons

  • Now calling dispatch({ type: 'DELETE_FILE' }) have an invisible dependency/requirement. If you don't execute the required code before the dispatch call you are calling for a strange bug that can be missed depending on the app architecture
  • For larger async operations we need to extract the code in a global place
function App() {
  const [state, dispatch] = useReducer((state, action) => {
    switch (action.type) {
      case 'DELETE_FILE':
        let index = state.files.indexOf(action.file);

        return {
          ...state,

          files: [...state.files.slice(0, index), ...state.file.slice(index + 1)]
        };
    }
  }, {
    files: ['a', 'b', 'c'],
  });

  return (
    <DispatchContext.Provider value={dispatchMiddleware(dispatch)}>
      <Component files={state.files} />
    </DispatchContext.Provider>
  );
}

function Component({ files }) {
  const dispatch = useContext(DispatchContext);
  
  function deleteFile(file) {
    unlink(file, () => {
      dispatch({ type: 'DELETE_FILE', file: file });      
    });
  }

  return (
    <>
      {files.map(file =>
        <button onClick={() => deleteFile(file)}>Delete File</button>
      )}
    </>
  );
}

Solution 2

Use useEffect() hook to delete the file.

Pros

  • The only location where a side effect like writing to a file or fetching data can be is in a useEffect() hook. This improves the cognitive load of understanding the code.

Cons

  • An additional state property
  • State property which may not be used in the UI. Not sure if that is a problem. Maybe state properties that are not part of the UI are normal.
  • If while deleting the file we don't want to show a UI indication the code goes through the component twice which is a small inefficiency and a confusion when you imagine it. Goes through the component logic just to execute a useEffect() call.
function App() {
  const [state, dispatch] = useReducer((state, action) => {
    switch (action.type) {
      case 'REQUEST_DELETE_FILE':
        return {
          ...state,

          deleteFile: action.file
        };
      case 'DELETE_FILE':
        const index = state.files.indexOf(action.file);

        return {
          ...state,

          files: [...state.files.slice(0, index), ...state.file.slice(index + 1)]
        };
    }
  }, {
    deleteFile: null,
    files: ['a', 'b', 'c'],
  });

  useEffect(() => {
    if (!state.deleteFile) {
      return;
    }

    unlink(state.deleteFile, () => {
      dispatch({ type: 'DELETE_FILE', file: state.deleteFile });
    });
  }, [state.deleteFile]);

  return (
    <DispatchContext.Provider value={dispatchMiddleware(dispatch)}>
      <Component files={state.files} />
    </DispatchContext.Provider>
  );
}

function Component({ files }) {
  const dispatch = useContext(DispatchContext);

  return (
    <>
      {files.map(file =>
        <button onClick={dispatch({ type: 'REQUEST_DELETE_FILE', file: file })}>Delete File</button>
      )}
    </>
  );
}

Solution 3

Use a middleware for dispatch which performs the async operation and then calls the actual dispatch.

Pros

  • Doesn't have the disadvantages of Solution 1 and Solution 2

Cons

  • A more complicated architecture. Two places where actions are handled.
function dispatchMiddleware(dispatch) {
  return (action) => {
    switch (action.type) {
      case 'DELETE_FILE':
        unlink(action.file, () => dispatch(action));
        break;

      default:
        return dispatch(action);
    }
  };
}

function App() {
  const [state, dispatch] = useReducer((state, action) => {
    switch (action.type) {
      case 'DELETE_FILE':
        let index = state.files.indexOf(action.file);

        return {
          ...state,

          files: [...state.files.slice(0, index), ...state.file.slice(index + 1)]
        };
    }
  }, {
    files: ['a', 'b', 'c']
  });

  return (
    <DispatchContext.Provider value={dispatchMiddleware(dispatch)}>
      <Component files={state.files} />
    </DispatchContext.Provider>
  );
}

function Component({ files }) {
  const dispatch = useContext(DispatchContext);

  return (
    <>
      {files.map(file =>
        <button onClick={dispatch({ type: 'DELETE_FILE', file: file })}>Delete File</button>
      )}
    </>
  );
}

Conclusion

While discussing we raised two questions we are not sure the answers to. They can help us in deciding the right pattern:

  • Is it normal to do side effects like writing a file or fetching data in an event handler or it should be only in useEffect()?
  • Is it normal to have properties in the state object which are not used in the view at all?
@Athou

This comment has been minimized.

Copy link

Athou commented May 11, 2019

Hey,

I've been playing with React for a week now, to replace the existing UI of CommaFeed (https://github.com/Athou/commafeed), currently written in angularjs 1.x. I encounterd the same issues as described here, and I've come to the same conclusions as you.
I think the issue rise from the fact that there's nothing similar to redux-thunk in React hooks.

I've started experimenting with https://github.com/nathanbuchar/react-hook-thunk-reducer and the result looks quite clean to me, but I don't have a lot of experience with React yet. Do you mind giving your opinion on https://github.com/Athou/commafeed-ui/blob/1821601b56ef4fbc6effc069709279371e03bc99/src/app/AppReducer.ts ?
The idea is that my action creator can create both plain old state changing actions and function actions (thunk), and the custom dispatch can handle both. From the perspective of the components dispatching the action, there's no difference, it always looks like dispatch(ActionCreator.createSomeAction())
What do you think?

Thanks!

@jtomaszewski

This comment has been minimized.

Copy link

jtomaszewski commented Jun 14, 2019

As a side note, similar implementation to solution no 2, but without useReducer hook:

  • increment a setState value every time an async action should be called (with 0 as initial value)
  • define a useEffect dependant (only!) on that value, and do the effect (unless the value is 0)

Example:

  /**
   * Once set to a number,
   * the token will be refetched on next rerender.
   * If token should be fetched again, call `requestNewToken()`.
   */
  const [fetchTokenOnNextRender, setFetchTokenOnNextRender] = useState<number>(
    0,
  );
  const requestNewToken = useCallback(() => {
    setFetchTokenOnNextRender(v => v + 1);
  }, []);

  useEffect(() => {
    if (!fetchTokenOnNextRender) {
      return;
    }
    // fetch the token ...
  }, [fetchTokenOnNextRender]);

Possibly it could be refactored into a generic use hook to which you can pass also arguments.

Like:

const { dispatch: requestNewToken } = useAsyncAction((tokenId, userData) => {
  // do sth
});

// and then trigger the action with
requestNewToken(123, { userId: 456 });
@dfmartin

This comment has been minimized.

Copy link

dfmartin commented Jun 14, 2019

Is your solution 3 similar to an action creator? Something that I've been playing around with:

Rather than type out a long code snippet here is a code sandbox I threw together. The main part is in the character/actions.ts file - I've marked it.

@heyimalex

This comment has been minimized.

Copy link

heyimalex commented Jun 17, 2019

@jtomaszewski I've been using this pattern for a minute to great effect, but I think it's only the right thing to do when the underlying action is state-driven vs user action driven, GET vs POST. Those side-effect-y / imperative / mutative actions are finnicky, and I'm not sure what guarantees react makes about executing them. I really wish the team gave more guidance here, but I think "do the side effect in the actual handler" is the safest bet.

@Athou

This comment has been minimized.

Copy link

Athou commented Jun 18, 2019

I solved this issue by moving to redux (now that it supports hooks https://react-redux.js.org/api/hooks) and redux-thunk.

@jtomaszewski

This comment has been minimized.

Copy link

jtomaszewski commented Jun 27, 2019

Related to this, I wrote a hook that lets you call async action in your component whenever you want, and make it cancel whenever action is triggered again or when the component is dismounted.

Example usage:

const [fetchArticles, cancelFetchArticles] = useCancellableAction(params => {
  const { response, abort } = fetchArticles(params);
  response.then(setArticles);
  return () => abort();
});

const handleRefresh = () => {
 fetchArticles({ page: 1 });
}

const handleClear = () => {
 setArticles([]);
 cancelFetchArticles();
}

Source:

/**
 * Use this hook in case you want to be able to call an async action,
 * that should be cancelled once this component dismounts,
 * OR when the action is triggered again in the meantime.
 *
 * NOTE `onAction` can't be dependant on any variables that are changed while this component is alive
 * TODO allow to pass deps of onAction function
 */
function useCancellableAction(
  onAction: () => void | (() => void | undefined),
): [() => void, () => void];
function useCancellableAction<A1>(
  onAction: (arg1: A1) => void | (() => void | undefined),
): [(arg1: A1) => void, () => void];
function useCancellableAction<A1, A2>(
  onAction: (arg1: A1, arg2: A2) => void | (() => void | undefined),
): [(arg1: A1, arg2: A2) => void, () => void];
function useCancellableAction<A1, A2, A3>(
  onAction: (arg1: A1, arg2: A2, arg3: A3) => void | (() => void | undefined),
) {
  const [nextCall, setNextCall] = useState<{
    args: [A1, A2, A3];
  }>();

  useEffect(
    () => {
      if (nextCall === undefined) {
        return undefined;
      }

      return onAction(...nextCall.args);
    },
    [nextCall],
  );

  const dispatch = useCallback((...args: [A1, A2, A3]) => {
    setNextCall({ args });
  }, []);

  const cancel = useCallback(() => {
    setNextCall(undefined);
  }, []);

  return [dispatch, cancel];
}
@staeke

This comment has been minimized.

Copy link

staeke commented Jul 6, 2019

I'd like to complicate matters a little by proposing a number of different solutions.

Preface

I renamed DispatchContext to MyContext below since there isn't always dispatching involved. And Component to MyComponent or DispatchComponent. And I let the context provide a tuple [state, somethingThatCanChangeState] and MyComponent consume the state through the context. That's just details, but I often find providing the context state is needed, rather than just props propagation. To that end I also wanted to avoid superfluous updates, and do "proper" memoing. Another point is whether to fire events (or call handlers) named e.g. deleteFile or DELETE_FILE vs deleteFileButtonClicked or DELETE_FILE_BUTTON_CLICKED. I don't think it's a big deal of this, although I know that Redux Saga does. In any event it's an orthogonal issue to all solutions below the way I see it.

Now, I have a helper hook (I use Typescript to better visualize api):

useAsync

Much like @jtomaszewski suggested (this is just slightly different version) which encapsulates the state changes around any async operation. I can provide a gist if desired. I believe being able to compose different hooks that encapsulate some intricacies around state change is one of the core benefits of React Hooks.

function useAsync<T>(provider:() => Promise<T>): [{data:T, isPending:boolean, error:any}, {run:Function, reset:Function}]

Solution 4

Use useAsync hook. Deliver an API, but have it memoed with the state.

Pros

  • allows writing normal async functions
  • composability of async operations
  • works along the philosophy of React hooks

Cons

  • requires context handlers to be mocked in testing consumers
  • doesn't guarantee immutability of actions object (harder to downstream useMemo)
function AsyncHookAndMemoed() {
  const [files, setFiles] = useState(['a', 'b', 'c'])
  const [unlinkOp, unlinkFn] = useAsync(pUnlink)

  const ctxValue = useMemo(() => {
    async function deleteFile(file) {
      await unlinkFn.run(file)
      // Beware of concurrent modifications to `files`
      setFiles(files => files.filter(f => f !== file))
    }

    return [
      { files, unlinkOp }, //State
      { deleteFile } //Actions
    ]
  }, [files, unlinkOp])

  return <MyContext.Provider value={ctxValue}>
    <MyComponent/>
  </MyContext.Provider>
}

function MyComponent() {
  const [files, actions] = useContext(MyContext)
  return <>
    {files.map(file =>
      <button onClick={() => actions.deleteFile(file)}>Delete File</button>
    )}
  </>
}

Solution 5

Use useAsync hook. Deliver an API, and have it memoed separately from the state.

Pros

  • allows writing normal async functions
  • composability of async operations
  • works along the philosophy of React hooks

Cons

  • requires context handlers to be mocked in testing consumers
  • guarantees immutability of actions object
  • risk of capturing changing state in API. Thus precaution needs to be taken to either use refs to the current version of state, or data to be put in state and read through invocations to setState(s => read "s" here)
function AsyncHookMemoedImmutableApi() {
  const [files, setFiles] = useState(['a', 'b', 'c'])
  const [unlinkOp, unlinkFn] = useAsync(pUnlink)

  const api = useMemo(() => {
    async function deleteFile(file) {
      await unlinkFn.run(file)
      // Beware of concurrent modifications to `files`
      setFiles(files => files.filter(f => f !== file))
    }

    return { deleteFile }
  }, [])

  const ctxValue = useMemo(() => [
    { files, unlinkOp }, //State
    api //Actions
  ], [files, unlinkOp])

  return <MyContext.Provider value={ctxValue}>
    <MyComponent/>
  </MyContext.Provider>
}

Solution 6

Use useAsync hook. Deliver an dispatcher, but make it asynchronous and non-pure

Pros

  • allows writing normal async functions
  • composability of async operations
  • works along the philosophy of React hooks
  • does not require context handlers to be mocked in testing consumers

Cons

  • requires context handlers to be mocked in testing consumers
  • guarantees immutability of actions object
  • risk of capturing changing state in API. Thus precaution needs to be taken to either use refs to the current version of state, or data to be put in state and read through invocations to setState(s => read "s" here)
  • sort of Redux, but potentially confusing since the "reducer" isn't sync or pure
// Utility (also used in next example)
function useAsyncDispatcher<T>(fn: (action: T, dispatch: ((action: T) => any)) => void) {
  return useMemo(() =>
    function dispatch(action: T) {
      return fn(action, dispatch)
    }, [])
}

function AsyncDispatcher() {
  const [files, setFiles] = useState(['a', 'b', 'c'])
  const [unlinkOp, unlinkFn] = useAsync(pUnlink)

  const dispatch = useAsyncDispatcher<DispatchType>(async (action, dispatch) => {
      switch (action.type) {
        case 'DELETE_FILE':
          await unlinkFn.run(action.file)
          setFiles(files => files.filter(f => f !== action.file))
          break
        default:
          throw new Error()
      }
    }
  )

  const ctxValue = useMemo(() => [
    { files, unlinkOp }, //State
    dispatch
  ], [files, unlinkOp])

  return <MyContext.Provider value={ctxValue}>
    <DispatchComponent/>
  </MyContext.Provider>
}

// Also used in next example
function DispatchComponent() {
  const [files, dispatch] = useContext(MyContext)
  return <>
    {files.map(file =>
      <button onClick={() => dispatch({ type: 'DELETE_FILE', file: file })}>Delete File</button>
    )}
  </>
}

Solution 7

Use useAsync hook. Deliver an dispatcher, but make it asynchronous and non-pure. Declare it inline in object

Pros

  • allows writing normal async functions
  • composability of async operations
  • works along the philosophy of React hooks
  • does not require context handlers to be mocked in testing consumers
  • neater syntax then switch for action

Cons

  • requires context handlers to be mocked in testing consumers
  • guarantees immutability of actions object
  • risk of capturing changing state in API. Thus precaution needs to be taken to either use refs to the current version of state, or data to be put in state and read through invocations to setState(s => read "s" here)
  • sort of Redux, but potentially confusing since the "reducer" isn't sync or pure
// Utility
function useAsyncNamedDispatcher<T extends { type: string }>(provider: () => { [type: string]: Function }) {
  const fnObj = useMemo(provider, [])
  return useAsyncDispatcher<T>((action, dispatch) => {
    if (action.type in fnObj)
      fnObj[action.type](action, dispatch)
    else
      throw new Error('Missing handler for action ' + action.type)
  })
}

function AsyncMapDispatcher() {
  const [files, setFiles] = useState(['a', 'b', 'c'])
  const [unlinkOp, unlinkFn] = useAsync(pUnlink)

  const dispatch = useAsyncNamedDispatcher<DispatchType>(() => ({
    async DELETE_FILE(action, dispatch) {
      await unlinkFn.run(action.file)
      setFiles(files => files.filter(f => f !== action.file))
    }
  }))

  const ctxValue = useMemo(() => [
    { files, unlinkOp }, //State
    dispatch
  ], [files, unlinkOp])

  return <MyContext.Provider value={ctxValue}>
    <DispatchComponent/>
  </MyContext.Provider>
}
@ScottWager

This comment has been minimized.

Copy link

ScottWager commented Jun 24, 2020

My solution was to emulate useReducer using useState + an async function:

async function updateFunction(action) {
    switch (action.type) {
        case 'switch1':
            // Do async code here (access current state with 'action.state')
            action.setState('newState');
            break;
    }
}

function App() {
    const [state, setState] = useState(),
        callUpdateFunction = (vars) => updateFunction({ ...vars, state, setState });

    return <ExampleComponent callUpdateFunction={callUpdateFunction} />;
}

function ExampleComponent({ callUpdateFunction }) {
    return <button onClick={() => callUpdateFunction({ type: 'switch1' })} />
}
@ScottWager

This comment has been minimized.

Copy link

ScottWager commented Jun 24, 2020

I’ve created a custom hook called useAsyncReducer based on above that uses the exact same signature as a normal useReducer:

function useAsyncReducer(reducer, initState) {
    const [state, setState] = useState(initState),
        dispatchState = async (action) => setState(await reducer(state, action));
    return [state, dispatchState];
}

async function reducer(state, action) {
    switch (action.type) {
        case 'switch1':
            // Do async code here
            return 'newState';
    }
}

function App() {
    const [state, dispatchState] = useAsyncReducer(reducer, 'initState');
    return <ExampleComponent dispatchState={dispatchState} />;
}

function ExampleComponent({ dispatchState }) {
    return <button onClick={() => dispatchState({ type: 'switch1' })}>button</button>;
}
@Denys-Bushulyak

This comment has been minimized.

Copy link

Denys-Bushulyak commented Jun 30, 2020

@mikeLspohn

This comment has been minimized.

Copy link

mikeLspohn commented Jul 2, 2020

How does setState handle the await dispatch(state, action) part? Would it not just try and setState to the promise being awaited? If not I wonder if there's any concern with future React versions or concurrent mode or anything breaking that behavior. I may just be missing something obvious though.

@ScottWager

This comment has been minimized.

Copy link

ScottWager commented Jul 3, 2020

@Denys-Bushulyak Thank you!

await makes setState wait for the promise to return a result. I haven't looked into concurrent mode yet so I'm not sure how that'll affect things.

@mikeLspohn

This comment has been minimized.

Copy link

mikeLspohn commented Jul 4, 2020

Ah, yeah I was confusing myself I think. I think it's because I'm more use to seeing async/await used like

function useAsyncReducer(reducer, initState) {
    const [state, setState] = useState(initState),
        dispatchState = async (action) => {
          const newState = await reducer(state, action)
          setState(newState);
       }
    return [state, dispatchState];
}

So it just threw me off a little. I wasn't sure how execution under the hood would happen, was just curious if it would do the await before actually calling the setState or what, but it makes sense now. Thanks though, looks cool!

@pmrt

This comment has been minimized.

Copy link

pmrt commented Jul 15, 2020

Such an interesting discussion!

I'd go for a dispatch middleware. The solution by @ScottWager is really clean and interesting but IMO reducers should be pure functions (concept taken from redux), it's the action itself what is asynchronous not the reducer (which dictates the state change) so it makes sense to wait for the action to finish and then dispatch it to a pure reducer.

How about the middleware solution but with a more generic solution so it doesn't have the complicated architecture? — That is, a thunk:

/* 
 withThunk is a dispatch middleware. When dispatch is invoked if the action is a function it will call
 the function passing down the dispatch itself, if the action is not a function (an object) it will delegate
 to dispatch
*/
function withThunk(dispatch) {
  return actionOrThunk => 
   typeof actionOrThunk === "function"
     ? actionOrThunk(dispatch)
     : dispatch(actionOrThunk)
}

// deleteFile is an action with a thunk instead of an object
function deleteFile(file) {
  return (dispatch) {
      unlink(file, () => dispatch({ type: "DELETE_FILE_SUCCESS" });
   }
}

function App() {
  const [state, dispatch] = useReducer((state, action) => {
    switch (action.type) {
      case 'DELETE_FILE_SUCCESS':
          // Do your thing to your state after deletion
        };
    }
  }, {
    files: ['a', 'b', 'c']
  });

  return (
    <DispatchContext.Provider value={withThunk(dispatch)}>
      <Component files={state.files} />
    </DispatchContext.Provider>
  );
}

function Component({ files }) {
  const dispatch = useContext(DispatchContext);

  return (
    <>
      {files.map(file =>
        // dispatch the action thunk
        <button onClick={dispatch(deleteFile(file))}>Delete File</button>
      )}
    </>
  );
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.