Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

useReducer's dispatch should return a promise which resolves once its action has been delivered #15344

Open
pelotom opened this issue Apr 7, 2019 · 55 comments

Comments

@pelotom
Copy link

pelotom commented Apr 7, 2019

(This is a spinoff from this thread.)

It's sometimes useful to be able to dispatch an action from within an async function, wait for the action to transform the state, and then use the resulting state to determine possible further async work to do. For this purpose it's possible to define a useNext hook which returns a promise of the next value:

function useNext(value) {
  const valueRef = useRef(value);
  const resolvesRef = useRef([]);
  useEffect(() => {
    if (valueRef.current !== value) {
      for (const resolve of resolvesRef.current) {
        resolve(value);
      }
      resolvesRef.current = [];
      valueRef.current = value;
    }
  }, [value]);
  return () => new Promise(resolve => {
    resolvesRef.current.push(resolve);
  });
}

and use it like so:

const nextState = useNext(state);

useEffect(() => {
  fetchStuff(state);
}, []);

async function fetchStuff(state) {
  dispatch({ type: 'START_LOADING' });
  
  let data = await xhr.post('/api/data');
  dispatch({ type: 'RECEIVE_DATA', data });
  
  // get the new state after the action has taken effect
  state = await nextState();

  if (!state.needsMoreData) return;

  data = await xhr.post('/api/more-data');
  dispatch({ type: 'RECEIVE_MORE_DATA', data });
}

This is all well and good, but useNext has a fundamental limitation: it only resolves promises when the state changes... so if dispatching an action resulted in the same state (thus causing useReducer to bail out), our async function would hang waiting for an update that wasn't coming.

What we really want here is a way to obtain the state after the last dispatch has taken effect, whether or not it resulted in the state changing. Currently I'm not aware of a foolproof way to implement this in userland (happy to be corrected on this point). But it seems like it could be a very useful feature of useReducer's dispatch function itself to return a promise of the state resulting from reducing by the action. Then we could rewrite the preceding example as

useEffect(() => {
  fetchStuff(state);
}, []);

async function fetchStuff(state) {
  dispatch({ type: 'START_LOADING' });
  
  let data = await xhr.post('/api/data');
  state = await dispatch({ type: 'RECEIVE_DATA', data });
  
  if (!state.needsMoreData) return;

  data = await xhr.post('/api/more-data');
  dispatch({ type: 'RECEIVE_MORE_DATA', data });
}

EDIT

Thinking about this a little more, the promise returned from dispatch doesn't need to carry the next state, because there are other situations where you want to obtain the latest state too and we can already solve that with a simple ref. The narrowly-defined problem is: we need to be able to wait until after a dispatch() has taken affect. So dispatch could just return a Promise<void>:

const stateRef = useRef(state);
useEffect(() => {
  stateRef.current = state;
}, [state]);

useEffect(() => {
  fetchStuff();
}, []);

async function fetchStuff() {
  dispatch({ type: 'START_LOADING' });
  
  let data = await xhr.post('/api/data');

  // can look at current state here too
  if (!stateRef.current.shouldReceiveData) return;
  
  await dispatch({ type: 'RECEIVE_DATA', data });

  if (!stateRef.current.needsMoreData) return;

  data = await xhr.post('/api/more-data');
  dispatch({ type: 'RECEIVE_MORE_DATA', data });
}
@pelotom pelotom changed the title useReducer's dispatch should return a promise for obtaining the resulting next state useReducer's dispatch should return a promise for obtaining the resulting next state Apr 7, 2019
@pelotom pelotom changed the title useReducer's dispatch should return a promise for obtaining the resulting next state useReducer's dispatch should return a promise for obtaining the resulting next state Apr 7, 2019
@pelotom pelotom changed the title useReducer's dispatch should return a promise for obtaining the resulting next state useReducer's dispatch should return a promise which resolves once its action has been delivered Apr 7, 2019
@facebook facebook deleted a comment from Adrenaline1554 Apr 11, 2019
@facebook facebook deleted a comment from Adrenaline1554 Apr 11, 2019
@facebook facebook deleted a comment from Adrenaline1554 Apr 11, 2019
@bionicles
Copy link

bionicles commented May 19, 2019

Would use. Any easy hacks to implement this?

Could put a “refresh” counter in state and watch for it to change with useNext, but this may cause unnecessary rerenders ?

@sspiff
Copy link

sspiff commented May 23, 2019

If you're willing to pollute your actions, could you add a callback to the action and call it from your reducer?

For example (haven't actually tried this, but...), wrap your reducer and dispatch using something like:

function useReducerWithResolvedDispatch(reducer, initialState, init) {
  const reducerWithResolve = useCallback(
    (state, action) => {
      nextState = reducer(state, action)
      if (action._resolveDispatch)
        action._resolveDispatch(nextState)
      return nextState
    },
    [reducer]
  )
  const store = useReducer(reducerWithResolve, initialState, init)
  const [state, dispatch] = store
  dispatch.resolved = useCallback(
    (action) => (new Promise((resolve, reject) => {
      action._resolveDispatch = resolve
      dispatch(action)
    })),
    [dispatch]
  )
  return store
}

And then later:

...
let nextState = await dispatch.resolved({ 'type': 'FOO', ... })

@pelotom
Copy link
Author

pelotom commented May 23, 2019

If you're willing to pollute your actions, could you add a callback to the action and call it from your reducer?

A reducer must be a pure function with no side effects; it may be called multiple times, and its having been called is no guarantee that the new state it produced was actually used.

@sspiff
Copy link

sspiff commented May 23, 2019

A reducer must be a pure function with no side effects; it may be called multiple times, and its having been called is no guarantee that the new state it produced was actually used.

Yeah, I hear ya. But, to enhance my understanding: What do you mean by a reducer may be "called multiple times" (multiple times for different actions prior to render, multiple times for the same action, or...)? And when would you consider a state to be "used"? When it's "seen" during a render?

@sspiff
Copy link

sspiff commented May 23, 2019

How about the following? When requested via dispatch.resolved(), we add a unique id to the action, record the ids of reduced actions in the state, and then notify the waiter in an effect. We track ids we've already seen, and lazily cleanup the state via future dispatches.

function useReducerWithResolvedDispatch(reducer, initialState, init) {
  const r = useRef({
    lastId: 0,
    resolve: {},
    resolved: [],
    state: null,
  })
  const _init = useCallback(
    ([state, included]) => ([init ? init(state) : state, included]),
    [init]
  )
  const _reducer = useCallback(
    ([state, includes], [aId, resolved, action]) => {
      if (resolved.length)
        includes = includes.filter((i) => !resolved.includes(i))
      if (aId)
        includes = [...includes, aId]
      return [reducer(state, action), includes]
    },
    [reducer]
  )
  const [[state, includes], dispatch] = useReducer(
    _reducer, [initialState, []], _init)
  const _dispatch = useCallback(
    (action) => {
      dispatch([0, r.current.resolved, action])
    },
    [dispatch]
  )
  _dispatch.resolved = useCallback(
    (action) => (new Promise((resolve, reject) => {
      const aId = ++r.current.lastId
      r.current.resolve[aId] = resolve
      dispatch([aId, r.current.resolved, action])
    })),
    [dispatch]
  )
  useEffect(() => {
      r.current.state = state
    },
    [state]
  )
  useEffect(() => {
      for (const aId of includes) {
        if (r.current.resolve[aId]) {
          r.current.resolve[aId](r.current.state)
          delete r.current.resolve[aId]
        }
      }
      r.current.resolved = includes
    },
    [includes]
  )
  return useMemo(() => ([state, _dispatch]), [state, _dispatch])
}

@mgenev
Copy link

mgenev commented Jul 25, 2019

yes, I'm confused right now.
The Docs don't help. Is dispatch asynchronous or synchronous? How do I know when it has finished affecting the state? with setState I have a call back... What do I do with dispatch to manage the order of execution before this feature request is included?

@a-eid
Copy link

a-eid commented Sep 16, 2019

obviously dispatching is async, there is no way to know when the state has been updated.

@stuckj
Copy link

stuckj commented Sep 24, 2019

If it works the same way as redux then it makes sense that it's async. I don't see anywhere in the hooks doc that actually clearly state that dispatch is actually async though. I assume it is since it looks like it's supposed to work similarly to redux (heck, redux is called out in the docs).

But, I certainly don't consider it "obvious".

@deanrad
Copy link

deanrad commented Sep 27, 2019

Why was this closed? AFAIK - the state change is immediate, and the changes to the DOM are made apparently in the microtask queue. So after a Promise.resolve, the new state and DOM changes are complete. But it seems crazy to rely on this. Returning a Promise for when the state change has completed AND the rerender resulting from it is complete makes sense to me.

If I have to throw my actions 'into the void' and only depend on React to rerun my functions for me, and I can't hook upon a Promise for 're-rendering is complete', then I am unnecessarily coupled to React.

@pelotom
Copy link
Author

pelotom commented Sep 27, 2019

@deanius it wasn’t closed.

@johnjesse
Copy link

Instead of returning a promise, could it not also accept a callback - kind of like how setState in classes work, I don't really have a feeling over which would be nicer, but I expect the use-case is similar to the class setState use-case, I.e. do this action, and then run this callback that may call a function on props

@deanrad
Copy link

deanrad commented Dec 3, 2019

@johnjesse - Promises have a contract, unlike callbacks, and protect the code that performs step A from even knowing about a function to do step B. Modern APIs should use Promises without a compelling reason not to—it makes for simpler code all around, that's why I didn't propose that even though some React APIs still accept callbacks (ReactDOM.render I'm looking at you!)

@Arman92
Copy link

Arman92 commented Dec 11, 2019

I'm using useReducer to manipulate my Context, I need to know when the Dispatch is finished and the actions has been completed. I believe returning a promise is a clean way to achieve this.

@pratik-sinha
Copy link

Any update on this, one way would be to use the useEffect on the state variable that is being changed by the action. This way we can know when the state finished updating

@OlegLustenko
Copy link

There a lot of different solutions in user-lands however, most of them blocking UI thread.
I think something like this - https://codesandbox.io/s/use-reducer-promisify-dispatch-ffnm3
Could not block UI thread and pretty simple to use

@smaudd
Copy link

smaudd commented Jan 21, 2020

Any updates? It could be really helpful to await dispatchs to perform any given task without using a lot of useEffect that doesn't really trigger on any order in particular.

@jeswin
Copy link

jeswin commented Feb 1, 2020

@pelotom I may be missing something, but wouldn't the nextState() promise in your example get triggered by any state change? It need not necessarily be due to the effect of the action 'RECEIVE_DATA' on the state.

let data = await xhr.post('/api/data');
dispatch({ type: 'RECEIVE_DATA', data });
  
// get the new state after the action has taken effect
state = await nextState();

@pelotom
Copy link
Author

pelotom commented Feb 2, 2020

@pelotom I may be missing something, but wouldn't the nextState() promise in your example get triggered by any state change? It need not necessarily be due to the effect of the action 'DISPATCH_DATA' on the state.

Yes, it would resolve in response to any change to that particular piece of state. It's all around not a great solution, hence this issue.

@dev-allenk
Copy link

(For the people who doesn't want to use any libraries but want to solve these kind of issues.)
How about using useMemo() instead of useEffect()?

const stateRef = useRef(state);

useMemo(() => { //useMemo runs its callback DURING rendering.
  stateRef.current = state;
}, [state]);

useEffect(() => {
  fetchStuff();
}, []);

async function fetchStuff() {
  dispatch({ type: 'START_LOADING' });
  
  let data = await xhr.post('/api/data');
  dispatch({ type: 'RECEIVE_DATA', data }); //it will triggers useMemo.

  if (!stateRef.current.needsMoreData) return;

  data = await xhr.post('/api/more-data');
  dispatch({ type: 'RECEIVE_MORE_DATA', data });
}

useEffect() runs after rendering, but useMemo() runs during rendering.

  1. So, when dispatch({ type: RECEIVE_DATA', data }) updates the state,
  2. useMemo() will updates stateRef.current,
  3. thus we can access updated state in the next line. (if (!stateRef.current.needsMoreData) return; )

We can make it into a simple custom hook.
Here's the codesandbox for above code including custom hook.

Although useMemo() may not an API for the usage like this, but it works.

However, it seems really useful if dispatch returns a Promise with an updated state.

@samu
Copy link

samu commented Jul 5, 2020

I've tried out two solutions for this. Both of them rely on reduce doing a bit of extra work. The use case looks like this:

  • we call endpoint1, receive result1, then dispatch action update1 with payload result1
  • we want to get updated state after update1
  • we call endpoint2, using information from updated state

Using a callback (not recommended) stackblitz

The service call looks like this:

async function callApi(dispatch) {
  const result1 = await doServiceCall();

  const callback = async (newState) => {
    const result2 = await doServiceCall();
    dispatch({type: 'updateAfterEndpoint2', result: result2});
  }

  dispatch({type: 'updateAfterEndpoint1', result: result1, callback});
}

Reducer looks like this:

function reduce(state, action) {
  switch(action.type) {
    case 'updateAfterEndpoint1': 
      state = "new state";
      action.callback(state)
      return state;
    ...
  }
}

As you can see, reducer calls action.callback(state).

I don't like this solution because it is really ugly syntactically.

Using promise (not sure if recommended, but better) stackblitz

The service call looks like this:

function makeResolver() {
  let _resolve;
  const promise = new Promise(resolve => _resolve = resolve);
  return [promise, _resolve];
}

async function callApi(dispatch) {
  const result1 = await doServiceCall();

  const [promise, resolve] = makeResolver();
  dispatch({type: 'updateAfterEndpoint1', result: result1, resolve});
  const newState = await promise;

  const result2 = await doServiceCall();
  dispatch({type: 'updateAfterEndpoint2', result: result2});
}

Reducer looks like this (it's actually exactly the same as before):

function reduce(state, action) {
  switch(action.type) {
    case 'updateAfterEndpoint1': 
      state = "new state";
      action.resolve(state)
      return state;
    ...
  }
}

As you can see, reducer calls action.resolve(state).

What i like about this solution is, that it will be easy to refactor, should useReducer introduce the Promise API one day:

- const [promise, resolve] = makeResolver();
- dispatch({type: 'updateAfterEndpoint1', result: result1, resolve});
- const newState = await promise;
+ const newState = await dispatch({type: 'updateAfterEndpoint1', result: result1});
  function reduce(state, action) {
    switch(action.type) {
      case 'updateAfterEndpoint1': 
        state = "new state";
-       action.resolve(state)
        return state;
      ...
    }
  }

Note

While the approach with the promise is nicer syntactically, it is also more "correct" in terms of code flow:

With approach 1, the code is called as part of the execution stack of the reducer. Therefore it can happen that we accidentally trigger a dispatch inside a dispatch. See here.

The output shows that dispatch 2 happens while dispatch 1 is still ongoing:

REDUCER: start 
update after endpoint 1 – undefined 
update after endpoint 2 – undefined 
REDUCER: end

With approach 2 there is a significant difference. See here.

The output shows that dispatch 2 happens after dispatch 1:

REDUCER: start 
update after endpoint 1 – undefined 
REDUCER: end 
update after endpoint 2 – undefined 

@amerllica
Copy link

Thank you Dear @pelotom, actually, the lack of functional component is this issue, I wanna be sure the re-render is finished and then I wanna do something, So really the dispatch need to have a promise returned.

Or instead, just like this.setState it could have a callback argument:

this.setState(~~~, callbackFunction)

It's just an idea, the dispatch function could have a callback argument:

const [state, dispatch] = useReducer(developerReducer, initialState);

dispatch(
  { type: 'SOME_ACTION' },
  callbackFunction
);

@enigma1
Copy link

enigma1 commented Oct 10, 2020

we need to be able to wait until after a dispatch() has taken affect.

I think it will be good to have it perhaps with a different hook. My last implementation to achieve this, was to use a reducer wrapper on the useReduce something like:

const promiseReducer = (reducer, pState, initialState=() => 
  ({...initialState, dispatcherPromise: Promise.resolve('Initial State')}) ) => {

  const [state, dispatch] = useReducer(reducer, pState, initialState);
  const pDispatch = async (params) => {
    params.data.dispatcherPromise = Promise.resolve(nanoid());
    dispatch(params);
    return await state.dispatcherPromise;
  };
  return [state, pDispatch];
}

I use the params.data.dispatcherPromise here for demo purposes you should move it in the actual component's effects functions. You will guarantee state update because an extra state property patch (dispatcherPromise) can use nanoid or an equivalent randomizer to create unique identifiers that will trigger the state update. The iniialState function is to initialize the state property so you won't see an error the first time on the state.dispatcherPromise. Then for the implementation part:

  const [state, dispatch] = useServiceContext(sContext);

  const makeUpdate = (itemQty) => {
    dispatch({type: 'changeQuantity', data:{uid, qty:itemQty}})
      .then(data => {
        console.log('dispatch promise resolved with:', data);
      }
    )
  };

I use a custom hook to get the state/dispatcher with the previously mentioned reducer, not shown here as it is beyond the point. So far with I've tried I haven't seen an issue.

@stuckj
Copy link

stuckj commented Oct 13, 2020

I subscribed to this issue a while back and forgot all about it until this latest message. As a workaround for the original use case I had for wanting a promise from dispatch I've been using this library: https://github.com/conorhastings/use-reducer-with-side-effects. It basically allows you to pair a side effect based on the state from the reducer. Not exactly the same ask, but depending on your situation it may satisfy your requirements. It was originally written a couple months after this issue was raised, but has been around for a little over a year now. I only started using it very recently, but so far it's working fine.

@enigma1
Copy link

enigma1 commented Oct 13, 2020

@stuckj yes that's the tradeoff, as this library you mentioned does. I do a similar thing. You can keep the state pure but have the payload with the side effect. In my approach above I used a state prop as the promise holder but it's better to use a payload parameter instead. It's easier to implement, because the reducer is executed immediately with the original dispatch call, so the promise property is populated right then and it becomes available to the caller.

@JoyTailor-1775
Copy link

JoyTailor-1775 commented Nov 10, 2020

Does react development team consider adding this feature in the future at all?

@genistv
Copy link

genistv commented Dec 30, 2020

I keep encountering situations where this promise or callback would be very helpful. Does anyone know any strong reason why this would not be implemented?

@ghost
Copy link

ghost commented May 15, 2021

+1

@anchi20
Copy link

anchi20 commented May 25, 2021

May the force be with this ticket.

@mmnoo
Copy link

mmnoo commented Jun 12, 2021

Im curious on the reasoning for the React team not prioritizing this.

Using a useEffect with state in the dependency array wont work for me. Not with maintainable or clean code anyway. I have added a new record to my state collection which results in a bunch of inputs being created/recreated from that collection. I want to focus on the new input from the record I created without tracking it by id in some new component state variable for current focus to be used within a useEffect (which would likely interfere with the browsers focus behaviour and open a fragile can of worms).

I should be able to listen for the reducer to finish and focus the input within the function scope where I call dispatch rather than creating some complicated confusing useEffect magic code that is prone to overreach for the purpose of input focus at least.

I could break reducer rules and call a callback/make a side effect, which would be an anti-pattern, but would be easier-to-reason-about code than useEffect (and way cheaper for my client).

(Other than FP ideology, I wonder what the other reasons for avoiding effects in reducers are. Will it necessarily result in a a reasonable bug risk? More research needed there on my part)

@llaenowyd
Copy link

Why does it matter when an action was delivered? What's that mean - it was applied to the store by the reducer? The effect of the action is an updated store. The only event a component should be interested in, due to an action, is that the store was updated, and it can use a selector to react to that event.

@mmnoo
Copy link

mmnoo commented Jun 12, 2021

Sometimes responding to changes on the entire store will make sense. Other times it wont.

In my case, I want to know when the store is updated with my item in a very specific scenario so I can do a thing related to the updated item. I want my code to be declarative and read nicely. So dispatch({ type: 'doVeryVerySpecificThingEdgeCase', payload: id}}).then(respondAndDoSomething(id)) or using a callback makes a whole lot more sense than having to store that id in some component state (and know when to trigger clearing it), and then listening to store changes with an effect which grabs that id and then call respondAndDoSomethingWithId(id) after checking 500 conditionals to make sure I only call it when a very specific edge case scenario occurs.

With the latter, I need to make extra sure respondAndDoSomething(id) doesnt get called too much and its unlikely to be colocated anywhere near the actual logic that triggered it (because now its in useEffect magic which leans much more to the imperative than to the declarative in this case)

@markerikson
Copy link
Contributor

Im curious on the reasoning for the React team not prioritizing this.

I'm not on the React team, but I can think of at least a few possible reasons off the top of my head:

  • The team is small (only a handful of engineers)
  • The team has been focused on the changes that are planned for React 18, which center around Concurrent Rendering and Suspense (per Introducing React 18 and the other threads in that discussion group)
  • This may not be a thing that they feel is necessary or sufficiently useful
  • Even if they do plan to try it at some point, it may be very low priority compared to the current work that they're trying to get out the door

(Other than FP ideology, I wonder what the other reasons for avoiding effects in reducers are. Will it necessarily result in a a reasonable bug risk? More research needed there on my part)

There's excellent reasons for this, and it's not just "ideology".

In Concurrent Rendering, React can and will start rendering components, only to pause and throw away the results. Because of that, React expects that the rendering process truly is "pure" with no side effects.

React calls your reducers at a couple points in the lifecycle and rendering process. It apparently does sometimes try to call it immediately when the update is queued to see if there's a chance of bailing out early. Assuming it keeps going, then it will normally get called during the rendering phase.

So, if you start putting side effects in reducers, you will almost definitely see unexpected behavior because those effects are running even if the component isn't getting updated at that point.

Note that I'm not saying that the feature being asked for in this thread is bad, impossible, or unwanted.

I'm just observing that the React team has limited resources and other major priorities atm, and that proposed workarounds that involve side effects in reducer functions are likely going to cause problems in real apps.

@mmnoo
Copy link

mmnoo commented Jun 13, 2021

@markerikson that is a very useful explanation, thanks for taking the time to write it.

I think I have another solution to my specific problem that is less convoluted than a useEffect. (Munging ui (focus) state with data state as a tradeoff).

If I were to pollute my reducer with a side effect and there was no downside to redundant calls to that side effect, is there still a risk of issues with your understanding?

Just curious where rules can be bent. Not meaning to call all of FP 'ideology' , as I actually quite like it. When blindly followed, anything start to feel that way though :)

@markerikson
Copy link
Contributor

@mmnoo that's sort of what I was saying. Think of it is less as "rules", and think of it as "how is this going to potentially change behavior in my application?".

I can't give specifics, because I don't know your app and I don't know what side effects you're considering doing here.

If the "side effect" is logging to the console or writing to localStorage, then while it's technically "breaking the rules", it's meaningless in the grand scheme and won't cause bugs.

If it's, say, making an HTTP DELETE call at the wrong time... then that could be very bad for your app :)

So, try to think about behavior based on the stated way React is going to execute things, not "am I breaking the rules".

@gaearon
Copy link
Collaborator

gaearon commented Jun 18, 2021

Im curious on the reasoning for the React team not prioritizing this.

Sorry, I know this is frustrating. (It's a known pain point with Hooks.) The truth is we don't know what a good solution looks like for this problem. We don't think the proposed solutions we've seen so far (here and in many other places) solve the issue very well.

There are several problems with the proposed solution.

Something like await for a dispatch result does not work great because it encourages writing code with race conditions. (What if you get another state update in the middle? What if this same function runs again in different arguments while you're waiting? What if responses come out of order? How are you going to coordinate that? Which state values are you hoping to receive?)

If the issue is "I just want a setState callback to do something imperative", then Hooks don't offer that—but if the only problem is that React delays DOM update, you can force it to happen sync.

import { flushSync } from 'react-dom'

flushSync(() => {
  setTabVisible(true)
}) // flushes DOM update
tabRef.current.scrollIntoView(); // updated by this point

This isn't quite the same but solves a number of cases.

This doesn't let you read the latest state value. But allowing to read it is also not enough, unfortunately. It also needs to be able to read fresh props which come from parent component. Or possibly other state. That's easy with classes because they're mutable, but you can't do it with Hooks without managing refs yourself. So it's not clear how a React API would do this.

You could of course always call the reducer yourself.

dispatch(action)
let nextLikelyState = reducer(state, action)
// ...

Not super pretty but you have full control.

So — we'd like to fix this, if we knew how to do it well.

@nikparo
Copy link

nikparo commented Jun 19, 2021

flushSync is not a solution I had considered or was aware of, thanks for the pointer! I think it will serve my needs well enough, though it seems a bit heavy-handed to force the update to happen synchronously. Would an async version be possible? I reckon that on its own would cover most of the need, since the tools available for selecting and passing around state are already pretty good.

// Asynchronous version of flushSync. Not sure about the name. awaitFlush() perhaps?
await flushAsync(() => {
  setTabVisible(true)
}) // flushes DOM update
tabRef.current.scrollIntoView(); // updated by this point

The above suggestion is bare-bones, but I think you could do quite a lot with it. As an example:

function usePromiseReducer(reducer, initialState, initializer) {
  const [state, dispatch] = useReducer(reducer, initialState, initializer);
  const ref = useRef(state);

  // Keep track of the committed state. Layout effects happen before flushAsync resolves.
  useLayoutEffect(() => {
    ref.current = state;
  });

  const wrappedDispatch = useCallback(async (action) => {
    // Wait for the next committed (or skipped) component render
    await flushAsync(() => dispatch(action));
    // Return the latest state, whether it was updated or not.
    // The reducer may have processed multiple actions before we get here.
    return ref.current;
  }, [dispatch]);

  return [state, wrappedDispatch];
}

@devsmitra
Copy link

devsmitra commented Jun 25, 2021

I have come up with work around this problem, This might help

Live Example


let cb; 
function useDispatch() {
  const [state, dispatch] = useReducer(
    (state, action) => {
      if (action.value === state.someState) return state;
      return { someState: action.value };
    },
    { someState: 0 }
  );

  useEffect(() => {
   cb && cb(state);
  }, [state]);

  return [
    state,
    (action, callback) => {
      cb = callback;
      dispatch(action);
    },
  ];
}

const Component = () => {
  const [state, dispatch] = useDispatch();
  const loggedDispatch = (action) => {
    console.log("pre-state", state);
    dispatch(action, (newState) => {
      console.log("new-state", newState);
    });
  };

  return (
    <div>
      <div onClick={() => loggedDispatch({ value: 1 })}>Set to 1</div>
      <div onClick={() => loggedDispatch({ value: 2 })}>Set to 2</div>
    </div>
  );
};

@disrael
Copy link

disrael commented Jun 25, 2021

I have come up with work around this problem, This might help

Live Example


let cb; 
function useDispatch() {
  const [state, dispatch] = useReducer(
    (state, action) => {
      if (action.value === state.someState) return state;
      return { someState: action.value };
    },
    { someState: 0 }
  );

  useEffect(() => {
   cb && cb(state);
  }, [state]);

  return [
    state,
    (action, callback) => {
      cb = callback;
      dispatch(action);
    },
  ];
}

const Component = () => {
  const [state, dispatch] = useDispatch();
  const loggedDispatch = (action) => {
    console.log("pre-state", state);
    dispatch(action, (newState) => {
      console.log("new-state", newState);
    });
  };

  return (
    <div>
      <div onClick={() => loggedDispatch({ value: 1 })}>Set to 1</div>
      <div onClick={() => loggedDispatch({ value: 2 })}>Set to 2</div>
    </div>
  );
};

No that's basically an anti-pattern in React as things currently stand. Our painful experience is that useEffect currently does not always run to completion as any re-render can interrupt the running instance.

That's difficult to reproduce and your simple example may not have enough changing variables or your callback running long enough to see it but it happens and dramatically reduces the usability of useEffect. You basically can only use useEffect in cases where you are idempotent and certain that useEffect will keep getting called until whatever it is doing completes.

@juanpreciado
Copy link

juanpreciado commented Sep 8, 2021

Following up on @gaearon last suggestion, a potential workaround for synchronous actions would be to wrap the dispatch in a function that returns the result of the reducer, in that way we could obtain the next state immediately after dispatching

// const reducer = ...;
// const initialState = ...;
const useCustomReducer = (reducer, initialState) => {
  const [state, dispatch] = React.useReducer(reducer, initialState);

  return [
    state,
    (action) => {
      dispatch(action);

      return reducer(state, action);
    },
  ];
};

const someComponent = () => {
  const [state, dispatch] = useCustomReducer(reducer, initialState);

  const someFunction = () => {
    const result = dispatch({ type: 'DO_SOMETHING', payload: 'some value' });

    console.log(`Result from my dispatch obtained immediately: ${result}`);
    console.log(`This is the sate without the change caused by the latest dispatch: ${state}`);
  };
  return <button onClick={someFunction}></button>;
};

@owenallenaz
Copy link

@juanpreciado I believe this is a flawed approach to this problem. You are using the state value that is in your render. If the state was to update between when your component renders and when that dispatch is called, you'll find the current state mismatches the state you see. For example, you can see this issue if you just add another call to dispatch before your current dispatch. That first dispatch will change the state, but the second will assume it's using the state in it's context, which is incorrect. Obviously that's a contrived example, but in practice it's likely some other part of your app that's calling the first dispatch().

@owenallenaz
Copy link

owenallenaz commented Sep 17, 2021

The following is my attempt at solving this problem. I utilize an EventEmitter and a WeakMap that keys off the action object. You can use the useReducerPromise with the exact same signature as useReducer but a guarantee that it returns a promise that only resolves when the reducer is run. It returns the calculated state. If the state did not change, then a UI update won't occur, but the reducer will have completed it's run.

Internally, the way it works is that whenever a dispatch is called it stores the action object in a WeakMap with the value being the resolve from the returned Promise. An event is emitted that contains the action and the newState whenever a resolver completes. This allows a single event listener to listen for the reduced event, locate the associated promise resolve in the WeakMap and execute it with the newState. This is abstracted away, so that you simply can just await or then every call to dispatch.

useReducerPromise.js:

import { useReducer, useMemo } from "react";
import EventEmitter from "eventemitter3";

const events = new EventEmitter();
const waiters = new WeakMap();

events.on("reduced", function (action, newState) {
  const resolver = waiters.get(action);
  if (resolver) {
    waiters.delete(action);
    resolver(newState);
  }
});

function reducerWrap(reducer) {
  return function (state, action) {
    const reduced = reducer(state, action);
    events.emit("reduced", action, reduced);
    return reduced;
  };
}

function dispatchWrap(dispatch) {
  return function (action) {
    const p = new Promise(function (resolve, reject) {
      waiters.set(action, resolve);
    });

    dispatch(action);

    return p;
  };
}

export default function useReducerPromise(reducer, ...reducerArgs) {
  const wrappedReducer = useMemo(() => reducerWrap(reducer), [reducer]);
  const [state, dispatch] = useReducer(wrappedReducer, ...reducerArgs);
  const wrappedDispatch = useMemo(() => dispatchWrap(dispatch), [dispatch]);
  return [state, wrappedDispatch];
}

https://codesandbox.io/s/usereducerpromise-8sp89

@stale
Copy link

stale bot commented Jan 8, 2022

This issue has been automatically marked as stale. If this issue is still affecting you, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize it yet. If you have any new additional information, please include it with your comment!

@stale stale bot added the Resolution: Stale Automatically closed due to inactivity label Jan 8, 2022
@martinkadlec0
Copy link

bump

@stale stale bot removed the Resolution: Stale Automatically closed due to inactivity label Jan 9, 2022
@anolan23
Copy link

anolan23 commented Feb 19, 2022

I'm not sure how Redux works under the hood, but if Redux can return a Promise with the dispatch function what's preventing the React team from implementing it the same way?

Example of awaiting dispatch with redux

const onAddTodoClicked = async () => {
  await dispatch(createTodo(todoText))
  navigate(`/todos/${todo.id}`)
}

Is it because Redux uses classes as opposed the useReducer being a hook?

@markerikson
Copy link
Contributor

@anolan23 no, this only works with Redux because you can configure a Redux store to use the "thunk" middleware. Since Redux middleware wrap around the dispatch function, any middleware can alter the return value of dispatch. The thunk middleware specifically returns whatever your thunk function returns. Additionally, the normal dispatching process is synchronous, allowing an immediate return value.

See these resources:

useReducer, on the other hand, has a dispatch function that A) directly updates React's internals, B) queues a state update, C) does not allow for middleware, and D) does not provide a way to know when the state update has been applied. So, completely different technical constraints.

@yanickrochon
Copy link

yanickrochon commented May 26, 2022

I see many blog posts addressing asynchronous implementations of this hook, and I came here searching the issues trying to understand why userReducer is not dispatching asynchronously. This is certainly confusing and it does not answer many apps' use cases.

From my understanding, the dispatch function is called asynchronously, but may be called repeatedly. This is fine, as the dispatch function may indeed to called from different contexts (e.g. inside a useEffect, inside a useCallback, etc.), and it's execution should prevent concurrent calls, in order to avoid updating the state concurrently, etc. (e.g. A calls dispatch, then B calls dispatch, but B finishes before A so the state has an undefined behavior).

However, this does not resolve many use cases, where the state depends on asynchronous executions. For example, a component has a form with server validations. Currently, this requires a reducer and some hacky useEffect function. In other words, there should be another hook to handle more complex state manipulations.

Something like :

const reducer = async (setState, action) => {
   switch (action.type) {
      case 'foo':
         const foo = await somethingFoo();
         setState(state => ({ ...state, foo }));
         break;
   }
}

...

const [ state, dispatch ] = useAsyncReducer(reducer, initialState);

...

await dispatch({ type: 'foo' });
// the reducer has returned at this point

Note that the reducer now takes a function similar to setState, where the state can either be fully replaced or merged. This would allow concurrent executions of the reducer and allow asynchronous dispatching of actions.

@andre-byrne
Copy link

andre-byrne commented Apr 4, 2023

I thought I would leave my solution: if I want my dispatch to "accept callbacks" then I add a queue of callbacks to my state.

// example of an action with a callback
const doSomethingWithCallback = (payload, callback) => {
  dispatch({ type: DO_SOMETHING, payload })
  dispatch({ type: ADD_CALLBACK, payload: callback })
}

Then elsewhere, usually where the useReducer is called

  const [state, dispatch] = useReducer(reducer, initialState)

  useEffect(() => {
    if (isEmpty(state.scheduledCallbacks)) {
      return
    }

    state.scheduledCallbacks.forEach((callback) => {
      callback(state)
    })

    dispatch({ type: ResourceActionType.CLEAR_CALLBACKS })
  }, [state.scheduledCallbacks])

I have also considered pulling this into my useReducer implementation directly, so that all my reducers can do this generically...

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

No branches or pull requests