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

useEffect can be synchronous? #22506

Closed
alexreardon opened this issue Oct 5, 2021 · 17 comments
Closed

useEffect can be synchronous? #22506

alexreardon opened this issue Oct 5, 2021 · 17 comments
Labels
Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug

Comments

@alexreardon
Copy link

alexreardon commented Oct 5, 2021

Hi legends,

My mental model of useEffect is that schedules an asynchronous task to be executed after rendering

Unlike componentDidMount or componentDidUpdate, effects scheduled with useEffect don’t block the browser from updating the screen. This makes your app feel more responsive. The majority of effects don’t need to happen synchronously. In the uncommon cases where they do (such as measuring the layout), there is a separate useLayoutEffect Hook with an API identical to useEffect.
https://reactjs.org/docs/hooks-effect.html

Implies that useLayoutEffect is synchronous and useEffect is asynchronous

This view seems to be shared amongst a number of popular writings on the topic:

I have recently discovered two use cases where effects created with useEffect are synchronous

Here is the stack during a flushed effect from the first example:

Screen Shot 2021-10-05 at 3 30 24 pm

Having effects sometimes be synchronous is usually fine for most effects. However, there can be cases where having an effect running synchronously can be problematic. This recently caused a bug for a project I work on.

In an effect schedule with useEffect, we were adding a 'click' event listener to the window in the ↑ bubble phase after some state changed. Because the effect was flushed synchronously, the new bubble phase event listener on the window picked up the original 'click' event (which got picked up by react early on the event path)

Tested react + react-dom versions:

  • 16.14.0
  • 17.0.2

I tried wrapping the setState in a unstable_batchedUpdates but that did not impact the flushing of the effect.

React 18

version: 18.0.0-alpha-f2c381131-20211004

First example: "setting react state inside of a non-react event listener"

React 18 example

→ Different behaviour depending on createRoot + render

If using ReactDOM.createRoot then effects are flushed synchronously (the same as React 17)

ReactDOM.createRoot(rootElement).render(<App />);

Interestingly, if you use ReactDOM.render with React 18, then effects are not flushed synchronously (different behaviour to React 17)

ReactDOM.render(<App />, rootElement);

Second example: "setting react state inside of a ref callback"

React 18 example

  • Same behaviour with createRoot + render
  • Same behaviour as React 17 → effect is flushed synchronously

Where to from here?

  1. Is my interpretation of what I am seeing correct? Are effects scheduled with useEffect sometimes synchronous? What should the behaviour be in React 18?
  2. I do not mind that effects scheduled with useEffect can something be synchronous; there are probably good reasons for them to be; and for most effects it should be fine. However, I do think it would be worth calling out that they can sometimes be synchronous as it can cause timing bugs. Perhaps a note in the docs would be sufficient

Thanks @aprilxyc and @Andarist for helping me troubleshoot this one!

@alexreardon
Copy link
Author

Me, last week:

5ovbxm

@eps1lon eps1lon added the Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug label Oct 5, 2021
@just-boris
Copy link
Contributor

I think, it is related to that story: #21400

@alexreardon
Copy link
Author

alexreardon commented Oct 5, 2021

#21400 looks related. However, in both of the cases in this issue, there is no useLayoutEffect

@bvaughn
Copy link
Contributor

bvaughn commented Oct 5, 2021

Having effects sometimes be synchronous is usually fine for most effects. However, there can be cases where having an effect running synchronously can be problematic. This recently caused a bug for a project I work on.

It is important for effects code to be resilient to being run async or sync. (If you need to introduce async code for some reason, you could use a timeout– just be sure to return a cleanup function that calls clearTimeout if you do this.)

Is my interpretation of what I am seeing correct? Are effects scheduled with useEffect sometimes synchronous? What should the behaviour be in React 18?

Yes, as is mentioned here:
This comment #21400 (comment)

If something (usually a layout effect) schedules sync work, React has to flush any pending passive effects before processing the new work– so the overall order will be preserved.

@bvaughn bvaughn closed this as completed Oct 5, 2021
@bvaughn
Copy link
Contributor

bvaughn commented Oct 5, 2021

cc @rachelnabors and @gaearon for making a note of this in the effects docs

@alexreardon
Copy link
Author

Hi @bvaughn,

Thank you for your prompt reply. Based on my reading of the react documentation, and other writings; I had not come across knowledge that "It is important for effects code to be resilient to being run async or sync".

I think this would be great knowledge to surface in the documentation just so that people know about this behaviour 👍

@alexreardon
Copy link
Author

alexreardon commented Oct 6, 2021

Here is a useDeferredEffect hook that schedules effects to be run in a future task.

I'm still hashing it out, writing tests etc.

import { DependencyList, useEffect } from 'react';

type CleanupFn = () => void;

// Under certain conditions, effects queued with `useEffect` can be flushed *synchronously*
// See https://github.com/facebook/react/issues/22506
// For most effects it is fine to be run either sync or async
// However, some effects do need to be in a task after rendering
export default function useDeferredEffect(
  effect: () => void | CleanupFn,
  deps?: DependencyList,
) {
  useEffect(
    function setup() {
      let cleanupFn: CleanupFn | null = null;
      const timeoutId = setTimeout(function task() {
        cleanupFn = effect() ?? null;
      });

      cleanupFn = () => clearTimeout(timeoutId);

      return function cleanup() {
        cleanupFn?.();
      };
    },
    // Ignoring 'effect' from the dependency array
    // eslint-disable-next-line react-hooks/exhaustive-deps
    deps,
  );
}

relevant eslint configuration:

'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': [
  'error',
   {
      additionalHooks: '(useDeferredEffect)',
   },
],

Thinking through:

  • What if the effect was already async, should it need to wait another tick?
  • Is it possible to lose things with this approach? Each effect setup is delayed by a tick. Perhaps something meaningful could be lost in the gap between unbinding the previous effect and a new effect
  • Can this be general purpose, or does this type of approach need to know a lot about the specific use case?

@Andarist
Copy link
Contributor

Andarist commented Oct 7, 2021

What if the effect was already async, should it need to wait another tick?

I guess ideally you wouldn't have to wait - but maybe it doesn't matter. Maybe it's worth classifying for what kind of effects you'd like to use this useDeferredEffect? Do you see any use case beyond attaching an event listener? If that is the only one then maybe you could just implement some window.event-based logic? Note that wouldn't quite work with Shadow DOM but that's probably not that relevant for you.

Is it possible to lose things with this approach? Each effect setup is delayed by a tick. Perhaps something meaningful could be lost in the gap between unbinding the previous effect and a new effect

What I'm slightly afraid of - and why I'm proposing to list the kind of effects for which this could matter. If the thing you are after can be fired as a macro task then scheduling within a micro task should be fine at all times, right?

@bvaughn
Copy link
Contributor

bvaughn commented Oct 7, 2021

This seems sketchy:

// Ignoring 'effect' from the dependency array
// eslint-disable-next-line react-hooks/exhaustive-deps

I think a more consistent (with other custom hooks) approach would be to remove the deps argument. (I think only built-in hooks should use deps like this.) Let the caller memoize the effects function via useCallback.

@alexreardon
Copy link
Author

I went for the current approach so useDeferredEffect had the same signature as useEffect

useEffect(effect, deps);
useDeferredEffect(effect, deps);

@alexreardon
Copy link
Author

alexreardon commented Oct 8, 2021

@Andarist I think it would be difficult to have a 'one size fits all' option given that the solution would likely be dependent on the specific timing needs of the effect.

That said, let's talk about event listeners. Let's say you want to start listening to events on the window after a user event which caused a react update + effect to be run synchronously. You want to defer your effect application until the current event is finished. What deferring mechanism should we choose?

Microtask (queueMicrotask)

You cannot use a microtask as the microtask queue is emptied when the call stack is emptied (which occurs when each event listener has finished executing) Video by Jake Archibald on the topic

If you defer your task with a microtask, it would be flushed when the event listener completed that triggered the effect, and not when the event was finished

I created a forked example to show this

A future task (setTimeout)

Use a future task; but ~4.7ms later. Something might be lost in this gap. Ideally something like queueTask or nodes process.nextTick would be great

Animation frame (requestAnimationFrame)

A frame only occurs ~60fps, and you can get many tasks coming in within a frame. It is still possible to 'miss' future events

🤔

@Andarist
Copy link
Contributor

Andarist commented Oct 8, 2021

I think it would be difficult to have a 'one size fits all' option given that the solution would likely be dependent on the specific timing needs of the effect.

I agree. However - it seems that if we take those "race conditions" out of the picture then we are mostly OK with the timing of the useEffect, right? So the question is - when those "race conditions" can happen? Do we have any other example of that apart of attaching event listeners from within event listeners?

If you defer your task with a microtask, it would be flushed when the event listener completed that triggered the effect, and not when the event was finished

This is right and it's unfortunate for this use case :/

What about smth like this:

// that lengthy list of arguments could potentially be refactored somehow
export default function useGlobalEventListener(
  targetOrGetter
  type,
  listener,
  options,
  deps,
) {
  useEffect(
    function setup() {
      const target = typeof targetOrGetter === 'function' ? targetOrGetter() : targetOrGetter
      const currentEvent = window.event
      // just a small memory leak prevention
      const currentEventTimer = setTimeout(() => (currentEvent = null), 0)
      
      const unbind = bind(target, {
          type,
          listener: function(...args) {
              if (currentEvent && currentEvent === window.event) {
                  return
              }
              return listener.apply(this, args)
          },
          options
      })

      return function cleanup() {
        clearTimeout(currentEventTimer)
        unbind()
      };
    },
    // Ignoring 'effect' from the dependency array
    // eslint-disable-next-line react-hooks/exhaustive-deps
    deps,
  );
}

@alexreardon
Copy link
Author

alexreardon commented Oct 8, 2021

Using window.event like that @Andarist is super creative 👏. Unfortunately window.event is deprecated

Another way that could work for this particular use case is to utilize the event path + event phases.

This approach assumes the event has already moved past the window, otherwise it won't work. This solution is for when the change is coming in response to an update triggered by responding to an event in react. It is mostly for the case where a react update is triggered by a setState in a ref callback or useLayoutEffect.

Given that limitation; it probably isn't all that useful.

But here we go anyway:

React adds it's event listeners to the document (react 16) or the rootElement (react 17). So, if you want to listen for an event after the one that triggered a change in react you can add an event listener to an EventTarget higher in the DOM tree (eg window) in the ↓ capture phase.

If you wanted to add an event listener to the window in the ↑ bubble phase (like I did) this can get triggered by the original event if an effect is flushed synchronously.

An approach that would work would be to:

  1. add an event listener to a higher EventTarget then react in the ↓ capture phase (this won't get picked up by the current event as the event has already travelled past that EventTarget on the event path)
  2. The ↓ capture phase event listener will be called on the next event (it won't be called for the current event)
  3. When the ↓ capture phase event listener is called, add a ↑ bubble phase event listener. You can add event listeners on the current event path and they will be called.

@alexreardon
Copy link
Author

I know I could spin on this more, but that probably isn't super helpful to others.

I think the big callout from this issue is that effects need to be designed with the understanding that they might be run synchronously, and they need to account for that! I'm not sure if there is a one size fits all solution (unless we had something like process.nextTick)

Thanks @bvaughn and @Andarist

@eps1lon
Copy link
Collaborator

eps1lon commented Oct 8, 2021

What we've done in Material-UI is keep attaching the event listener as normal but also attaching one for the capture phase that activates the handler. That way you make sure that the event listener only listens to new events e.g. https://github.com/mui-org/material-ui/blob/5687758c6bcaadc946d389e03df5fc9e9b482368/packages/mui-lab/src/internal/pickers/PickersPopper.tsx#L70-L89

That solution seemed more predictable as opposed to relying on the event loop.

I guess you could even make it so that you only attach the actual listener during the capture phase instead of during React's effect phase.

We've only tested this for handlers in the bubble phase. I don't think it'll work for handlers that need the capture phase.

@Andarist
Copy link
Contributor

Andarist commented Oct 8, 2021

Unfortunately window.event is deprecated

That is true - but there are no signs of it being removed any time soon. In fact, React itself is using this to determine a priority of an update.

The workaround with an additional capture listener is also very creative 👍

@bvaughn
Copy link
Contributor

bvaughn commented Oct 8, 2021

I went for the current approach so useDeferredEffect had the same signature as useEffect

useEffect(effect, deps);
useDeferredEffect(effect, deps);

Right. I assumed that was the intent, but effect dependencies are easy to mess up. That's why we have a lint rule to enforce proper usage for built-in effects. (In the future we may also provide tools to automatically manage effect dependencies during compilation– for built-in effects).

Those are the reasons why I would recommend third party hooks don't work with deps directly, and instead instruct people to use a built-in hook like useCallback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug
Projects
None yet
Development

No branches or pull requests

5 participants