Navigation Menu

Skip to content

Instantly share code, notes, and snippets.

@astoilkov
Last active March 13, 2024 10:19
Show Gist options
  • Star 116 You must be signed in to star a gist
  • Fork 7 You must be signed in to fork a gist
  • Save astoilkov/013c513e33fe95fa8846348038d8fe42 to your computer and use it in GitHub Desktop.
Save astoilkov/013c513e33fe95fa8846348038d8fe42 to your computer and use it in GitHub Desktop.
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?
@ScottWager
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
Copy link

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
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>
      )}
    </>
  );
}

@hugomallet
Copy link

A better solution 1:
Instead of passing dispatch to your context, pass some async functions such as deleteFile that will delete the file and then dispatch

@ArifSanaullah
Copy link

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>;
}

Worked like a magic for me.

@amsterdamharu
Copy link

amsterdamharu commented Mar 17, 2021

I created a useMiddlewareReducer that allows middleware to be passed as 3rd argument so no lazy state initialization.

const compose = (...fns) =>
  fns.reduce((result, fn) => (...args) =>
    fn(result(...args))
  );
const mw = () => (next) => (action) => next(action);
const createMiddleware = (...middlewareFunctions) => (
  store
) =>
  compose(
    ...middlewareFunctions
      .concat(mw)
      .reverse()
      .map((fn) => fn(store))
  );
const useMiddlewareReducer = (
  reducer,
  initialState,
  middleware = () => (b) => (c) => b(c)
) => {
  const stateContainer = useRef(initialState);
  const [state, setState] = useState(initialState);
  const mwDispatch = (action) => {
    const next = (action) => {
      stateContainer.current = reducer(
        stateContainer.current,
        action
      );
      setState(stateContainer.current);
      return action;
    };
    const store = {
      dispatch: mwDispatch,
      getState: () => stateContainer.current,
    };
    return middleware(store)(next)(action);
  };
  return [state, mwDispatch];
};

if you have only thunk middleware you can do the following:

const thunkMiddleWare = ({ getState, dispatch }) => (
  next
) => (action) =>
  typeof action === 'function'
    ? action(dispatch, getState)
    : next(action);
const App = () => {
  const [state, dispatch] = useMiddlewareReducer(
    reducer,
    initialState,
    thunkMiddleWare
  );

If you have multiple middleware functions you can do the following:

const middleware = createMiddleware(
  thunkMiddleWare,
  logMiddleware
);
const App = () => {
  const [state, dispatch] = useMiddlewareReducer(
    reducer,
    init,
    middleware
  );

If you need to put this in React context to be used by many components you may as well use Redux, the useMiddlewareReducer does not work with Redux devtools plugin and will re render all components that use context when dispatch and state are put in context, this is not the case with Redux and useSelector.

@meglio
Copy link

meglio commented Jul 6, 2021

Solution 3 has a few issues. Below I list the issues and proposed ways to tackle them.

Solution 3 - Issue 1

React guarantees dispatch to be the same function instance, which helps with reducing the number of re-renders. With the middleware in place, you now return the middleware function instead of the dispatch function, which is a new function every time. This will increase the number of re-renders and make the reducer less useful and less efficient. You'll no longer benefit form the dispatch being an unchangeable reference to the same function instance.

The solution is to useMemo:

const dispatchMiddleware = useMemo(() => {
        return () => {

        }
    }, [dispatch]);

Solution 3 - Issue 2

unlink(action.file, () => dispatch(action));

This is prone to race conditions! While the file is being deleted, some other component can request its deletion again.

In order to avoid this, you need a state variable that stores current api/ajax status, i.e. currentAjaxId = null.

Before you start deleting the file:

  • check if currentAjaxId is not null, and if so, then stop
  • set currentAjaxId to some non-null constant, e.g. AJAX_DELETING_FILE

After the api is complete:

  • set currentAjaxId to null
  • update the list of files

Bonus: you can now disable the "Delete" button based on the currentAjaxId

@Athelian
Copy link

Athelian commented Mar 13, 2024

Solution 3 has a few issues. Below I list the issues and proposed ways to tackle them.

I found the same issues as @meglio above, with an additional issue with the dispatch middleware in that it cannot access state in the memo unless it is added to the dependency array. Which is sub-optimal.

As a side note, the dependency array should be empty in this case as dispatch doesn't rely on stateful variables.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment