React: Support Passive Event Listeners

Created on 7 Apr 2016  ·  62Comments  ·  Source: facebook/react

https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md

It would be good to have everything be passive by default and only opt-in to active when needed. E.g. you could listen to text input events but only preventDefault or used controlled behavior when you have active listeners.

Similarly, we could unify this with React Native's threading model. E.g. one thing we could do there is synchronously block the UI thread when there are active listeners such as handling keystrokes.

cc @vjeux @ide

DOM React Core Team Big Picture Feature Request

Most helpful comment

I just hit a warning in chrome about handling the wheel event, which could be optimized if it were registered as a passive event handler. So having this in React would be neat!

All 62 comments

This landed in Chrome 51. Is there any updated plan to support this in React? :O

How is this possible if React has only one event listener on document, and then delegates to others?
@sebmarkbage

What's the current status of issue with Passive Events ?

I just hit a warning in chrome about handling the wheel event, which could be optimized if it were registered as a passive event handler. So having this in React would be neat!

You'll also want to handle arbitrary options, such as once which has already landed in Firefox nightly: https://twitter.com/mozhacks/status/758763803991474176. Full list: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener

FWIW, Facebook listens to active wheel events to block outer scrolling when sidebars or chat windows are scrolled. We can't implement the UI without it. We still want to support this as an option but the problem space is still incomplete so there might evolve alternative solutions to this problem that doesn't involve passive event listeners. So it is still an active design space.

It's important to keep both active listeners and add support passive ones.
On desktop applications you don't see any difference, but on mobile apps passive scroll listeners give a great speed boost.

Little suggestion:

<SomeElement
  onScroll={this.onScrollThatCallsPreventDefault}
  onScrollPassive={this.onScrollThatJustListens}
  ...this.props
/>

@romulof yeah, this is how you register events on the capture phase as well

<SomeElement
  onClick={this.onClick}
  onClickCapture={this.onClickCapture}
  onScrollPassive={this.onScrollPassive}
/>

so I imagine this would be the proper API to support passive events as well.

Side note: a tricky question is - how would you register passive events for the capture phase? I suppose this is not possible, by the nature of passive events. Since they are even not allowed to call event.preventDefault(), so probably this is a non-issue.

@radubrehar, onScrollCapturePassive looks like the whole bible in camel-case.

:) It's not the case, since there are no passive events on the capture phase.

Sure it doesn't make sense, but I would't count on it. There's also other types event binding, such as once.

Another suggestion:

<SomeElement
  onScroll={this.onScrollThatCallsPreventDefault}
/>
<SomePassiveElement
  onScroll={{
    passive: true,
    capture: true,
    handler: this.onScrollThatJustListens,
  }}
/>

This way React would have to detect whether the event handler is a function (normal binding), or and object containing binding options and the handler function.

I think the object approach with options makes more sense than onFooPassive, since there are other options that might be needed. If combined with @sebmarkbage's suggestion that events should be passive by default, this probably wouldn't be too cumbersome.

Another approach that comes to mind would be to attach properties to the event handler to allow them to opt out of passive mode (or toggle other options). Something like this:

class Foo extends React.Component {
  constructor() {
    this.handleScroll = this.handleScroll.bind(this);
    this.handleScroll.passive = false;
  }

  handleScroll() {
    ...
  }

  render() {
    return <div onScroll={this.handleScroll} />;
  }
}

In theory, this would work pretty nicely with decorators, once they land.

Thinking about this a little more, I think it would be better to add an event options property to the function, instead of individual options. That would allow React to only have to worry about one property instead of potentially many. So, to adjust my example above:

class Foo extends React.Component {
  constructor() {
    this.handleScroll = this.handleScroll.bind(this);
    this.handleScroll.options = { passive: false };
  }

  handleScroll() {
    ...
  }

  render() {
    return <div onScroll={this.handleScroll} />;
  }
}

Another thought that occurred to me is what might this look like if we modified JSX syntax in a way that allowed for these options to be passed in via the JSX. Here's a random example that I haven't put much thought into:

return <div onScroll={this.handleScroll, { passive: false }} />;

I've also been thinking about whether events should be passive by default or not, and I'm a bit on the fence. On one hand, this would certainly be nice for events like scroll handlers, but I worry that it would cause too much turbulence and unexpected behavior for many click handlers. We could make it so some events are passive by default and others are not, but that would probably just end up being confusing for folks, so probably not a good idea.

This way is pretty similar to what I proposed earlier, without modifying JSX syntax.

return <div onScroll={{ handler: this.handleScroll, passive: true }} />;

And documentation would be straightforward:

div.propTypes = {
  ...
  onScroll: React.PropTypes.oneOf([
    React.PropTypes.func,
    React.PropTypes.shape({
      handler: React.PropTypes.func.isRequired,
      capture: React.PropTypes.bool,
      passive: React.PropTypes.bool,
      once: React.PropTypes.bool,
    }),
};

Are react events passive by default? It seems to be that way for touch events, at least. I am not able to preventDefault unless I fall back to vanilla document-level event listeners.

@joshjg React handlers are passed "synthetic events," which are sort of like native events, but different. By the way, someone with more knowledge should correct what I'm about to say because I haven't actually read the code that does this.

I'm not super familiar with the implementation details, but I know that preventDefault works at least _as long as the handlers you're preventing are also React event handlers_. That's been my experience, anyway.

With stopPropagation you're more likely to be out of luck (e.g. you have a document click listener which can't be bound with React, and you want to avoid bubbling up if you click inside a certain element). In that case you can use:

function stopPropagation (e) {
  e.stopPropagation();
  e.nativeEvent.stopImmediatePropagation();
}

[[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Event/stopImmediatePropagation)]

This got slightly off the main topic, but the short answer is that React doesn't use passive events, they're just sometimes handled in a strange order.

@joshjg @benwiley4000 @gaearon Recently the chrome team has changed their approach to document-level touch events, making them passive by default. And since React attaches events at document-level, you get this new behaviour.

See https://www.chromestatus.com/features/5093566007214080

This has indirectly changed they way React behaves - I suppose React does not explicitly mention passive: false when attaching events - hence the change in behavior.

I just hit this as well - so you need to register touch events by hand, with addEventListener

Note that the Chrome passive-by-default intervention only applies to touchstart and touchmove, not wheel. So a wheel event without an explicit {passive: true} will still force synchronous scrolling, for mousewheel and two-finger trackpad scrolling. (I wrote a blog post about some of the subtleties here.)

Also we (the Edge team) have no intent to implement the same intervention, so when we ship passive event listeners you'll still want to explicitly specify {passive: true}.

FYI, I started going down the passive: false path to prevent body from scrolling on mobile when there is a scrolling div, but it's a little heavy to use preventDefault() to to block scrolling. I could add and remove the handler depending if the div is present, or fall back to a body.height = 100% approach. The body.height fix feels a little hacky, but then I wouldn't need passive: false at all.

My use case it that I'd like to use event.preventDefault() method to prevent container scrolling when user is dragging element inside of it.

For that, I need to register event listener as non-passive (passive: false).
As browsers are switching to passive: true by default, I'd like to be able to do opposite

Unfortunately I'm not able to use touch-action: none; style because it's being applied after touch has started and probably that's why it doesn't have any effect.

It is indeed going to be a problem really soon, I'm surprised that in two years no solution was found. And creating event listeners manually is an anti-pattern in React.

And if it creates breaking changes, then so be it. I may be missing a part of the story though.

I like new event listener signature proposed by @romulof in https://github.com/facebook/react/issues/6436#issuecomment-254331351.
Besides fixing issue described here it would be possible to specify other EventListenerOptions such as once

I just ran into this issue. I have a canvas where the user can draw. When drawing on Android it will sometimes 'pull to refresh' instead of doing a paint-stroke. This shows it is a real-world problem. I'll be avoiding onTouch{Start,Move,End} for now and manually use addEventListener as a workaround.

I very much like the approach @romulof suggested. It seems that solution also doesn't require breaking changes.

@bobvanderlinden Adding touch-action: none; to your canvas style should work for you, it really shines for that use case. The other possible values can also be quite convenient.

However as @piotr-cz pointed out, touch-action doesn't universally solve this passive events issue as a whole. I'm also running into the same issue of preventing a container from scrolling while dragging a child element. All workarounds are pretty hacky and add technical debt.

Unfortunately the presence of any non-passive listener can cause significant jank, even if there are no userland handlers actually hooked up to it. Chrome will tell you about it at verbose log levels: [Violation] Handling of 'wheel' input event was delayed for 194 ms due to main thread being busy. Consider marking event handler as 'passive' to make the page more responsive. (this is the top-level handler added by https://github.com/facebook/react/blob/92b7b172cce9958b846844f0b46fd7bbd8c5140d/packages/react-dom/src/events/ReactDOMEventListener.js#L155)

@romulof @lencioni @radubrehar Are you aware of the fact that the passive flag is not meant for usage on scroll event listeners? It should be used on events like touchmove etc. to not interfere with the browser's scrolling performance. Your examples are highly confusing to me.

Setting passive isn't important for the basic scroll event, as it cannot be canceled, so its listener can't block page rendering anyway.

Source: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Improving_scrolling_performance_with_passive_listeners

Additional info: https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md

Yes, I am aware of this now. I was misinformed when I wrote my earlier
comments. Thanks for clarifying!

On Wed, Dec 6, 2017, 8:25 AM Martin Hofmann notifications@github.com
wrote:

@romulof https://github.com/romulof @lencioni
https://github.com/lencioni @radubrehar https://github.com/radubrehar
Are you aware of the fact that the passive flag is not meant for usage on
scroll event listeners? It should be used on events like touchmove etc.
to not interfere with the browser's scrolling performance. Your examples
are highly confusing to me.

Setting passive isn't important for the basic scroll event, as it cannot
be canceled, so its listener can't block page rendering anyway.

Source:
https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Improving_scrolling_performance_with_passive_listeners

Additional info:
https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md


You are receiving this because you were mentioned.

Reply to this email directly, view it on GitHub
https://github.com/facebook/react/issues/6436#issuecomment-349691618,
or mute the thread
https://github.com/notifications/unsubscribe-auth/AAL7zgbNCpHNui-TX7r2FYdhVxZfdBX8ks5s9r_4gaJpZM4ICWsW
.

@el-moalo-loco, I'm pretty sure that I read some documentation on Google Developers site about using passive listeners for scroll events to improve performance. I must have misread or something changed along the way. Anyway, thanks a lot for the clarification!

@romulof @lencioni @el-moalo-loco https://developers.google.com/web/tools/lighthouse/audits/passive-event-listeners
Wheel listeners should still be passive even if scroll listeners don't have to be. I think that may be the source of confusion.

@sebmarkbage What do you think? Will this passive event listener support make it's way into React sometime?

Hi,

I just had to add an active event listener in componentDidMount like this:

global.addEventListener("touchstart", this.touchStart(), { passive: false })

so that I can call e.preventDefault() to stop the default scrolling of Chrome and move an element in touchStart().

In order to know which element I have to move I had to add onTouchStart to the JSX element like this:

onTouchStart={this.touchStartSetElement(element)}

In touchStartSetElement() I set a state property element that I can read in touchStart()

If React would support active event listeners this would boil down to one line.

Thanks,

Philipp

P.S.: If you try to call e.preventDefault() in a passive event listener you get this error in Chrome 56:

[Intervention] Unable to preventDefault inside passive event listener due to target being treated as passive. See https://www.chromestatus.com/features/5093566007214080

Passive events have become default with Chrome 56 by an "intervention" of Google and breaking the web somehow - but also making scrolling faster.

This is becoming more of an issue since Safari as of iOS 11.3 also defaults to passive, and the classical workaround of touch-action:none is not supported there.

I've proposed an RFC https://github.com/reactjs/rfcs/pull/28 that would allow creating custom ref handlers that work like props (i.e. you use properties like you would onClick but instead use computed property syntax and the handler gets ref and prop value info and updates). These can be used to create libraries for just about any advanced use case you have.

  • Passive, explicitly non-passive, once, and capturing events are all easy to do with it.
  • Things even more advanced than just event handler registration can be done with them.
  • From the user's perspective they're simple to use, just pass what the library gives you as a computed property to any element. e.g. import {onScroll} from 'react-passive-events'; <div [onScroll]={scrollHandler} />

I do not think these should be the way to register all events.

However, instead of coming up with complex ways to handle registering all possible types of events (capturing, passive, etc...) I recommend deciding what the default behaviour should be for most events (passive or non-passive) and using these registered props to handle more advanced use cases.

All touch events are now passive by default in iOS 11.3. Thus calling event.preventDefault() in any touch event handler is now non-effective 😢

https://codesandbox.io/s/l4kpy569ol

Without being able to force non-passive event handlers we are having a hard time working around the iOS 11.3 changes https://github.com/atlassian/react-beautiful-dnd/issues/413

I came to see how Vue.js was handling this, and I quite like their approach:

<!-- the click event's propagation will be stopped -->
<a v-on:click.stop="doThis"></a>

<!-- the submit event will no longer reload the page -->
<form v-on:submit.prevent="onSubmit"></form>

<!-- modifiers can be chained -->
<a v-on:click.stop.prevent="doThat"></a>

<!-- just the modifier -->
<form v-on:submit.prevent></form>

There is this list of modifiers:

  • .stop
  • .prevent
  • .capture
  • .self
  • .once
  • .passive

And you can compose events the way you like. Maybe this can help as inspiration for this issue.

@KeitIG I’m working on a fork of Vue-loader which is designed for React

https://github.com/stalniy/react-webpack-loader

This makes me sad

selection_028

Not sure if this is suggested already - or if it makes any sense to anyone other but me, however:

onTouchStart={listener} 

to

onTouchStart={listener, options}

would both make passing options such as { passive, true, once: true } a natural way to do it, and it would also match addEventListener schema.

Moving towards @phaistonian's suggestion would remove the need for any of the onEventNameCapture handlers that exist today

@alexreardon I really think that's not an option, since in plain js, listener, options is an expression, that evaluates to options, so the construction above is not what you mean it to be. This would require changing the way jsx compiles to js and would be a breaking change. I doubt the react team would go this route.

Opinions?

Regarding it being an expression, it could be done differently with the same intention.

There are probably a lot of options. Options include:

import {handler} from 'React';

onTouchStart={handler(listener, options)}
onTouchStart={{listener, options}}

or

onTouchStart={[listener, options]}

or

onTouchStart={listener} onTouchStartOptions={options}

I like the idea of passing an object the most though. Either way, this needs a solution.

is there a solution yet now?

is there a solution yet now?

Your classic addEventListener and removeEventListener in componentDidMount and componentWillUnmount respectively.

Yep, that sucks.

And what do you think about to solve it using a hook?

...
const onClickPassive = useEventListener((e) => {
 console.log('passive event')
}, { passive: true })

return (
  <button onClick={onClickPassive}>Click me</button>
)

@ara4n using hook is good, but there still need a classical solution for non-hook react

@sebmarkbage any updates on this? Chrome just shipped this and broke our app.

https://www.chromestatus.com/features/6662647093133312

[Intervention] Unable to preventDefault inside passive event listener due to target being treated as passive. See https://www.chromestatus.com/features/6662647093133312
It happens again, when I tried to block the default scrolling behavior in onWheel event listener

@kychanbi Same to me, but I only meet this error on Windows chrome.

@kychanbi Oh, it's a Chrome 73's feature that treats document level Wheel/Mousewheel event listeners as Passive

You can use css property on your component container div touch-action: none

.container {
touch-action: none;
}

@madcher it seems does not work for mouse events.
I finally solved it by using native javascript
element.addEventListener("wheel", eventHandler);

A small snippet to help those facing this issue:

import React, { useRef, useEffect } from 'react'

const BlockPageScroll = ({ children }) => {
  const scrollRef = useRef(null)
  useEffect(() => {
    const scrollEl = scrollRef.current
    scrollEl.addEventListener('wheel', stopScroll)
    return () => scrollEl.removeEventListener('wheel', stopScroll)
  }, [])
  const stopScroll = e => e.preventDefault()
  return (
    <div ref={scrollRef}>
      {children}
    </div>
  )
}

const Main = () => (
  <BlockPageScroll>
    <div>Scrolling here will only be targeted to inner elements</div>
  </BlockPageScroll>
)

@madcher

Somehow onWheel props doesn't work with css touch-action: none;

    componentRef = React.createRef(null);
    handleWheel = (e) => {
      e.preventDefault();
    }
    render() {
      <Container style={{ touchAction: 'none' }} onWheel={this.handleWheel}>
        ...
      </Container>
    }

still getting this error:
[Intervention] Unable to preventDefault inside passive event listener due to target being treated as passive. See <URL>

Working Version

alternative to @markpradhan 's hooks solution, if you're still using old-style component, you can do this:

    componentRef = React.createRef();
    handleWheel = (e) => {
      e.preventDefault();
    }
    componentDidMount() {
      if (this.componentRef.current) {
        this.componentRef.current.addEventListener('wheel', this.handleWheel);
      }
    }
    componentWillUnmount() {
      if (this.componentRef.current) {
        this.componentRef.current.removeEventListener('wheel', this.handleWheel);
      }
    }
    render() {
      <Container ref={this.componentRef}>...</Container>
    }

@Fonger Probably due to #14856

Something like this has been suggested but here are a couple more ideas.

function MyComponent() {
  function onScroll(event) { /* ... */ }
  onScroll.options = {capture, passive, ...};
  return <div onScroll={onScroll} />;
}

This one would let you easily opt into passive events or capture events without the need for a breaking change. However, I was intrigued by the idea of a passive by default event listener. I remember preventDefault being a major obstacle (among others) that blocks React from running in a worker.

js function MyComponent() { function onScroll(event) { /* ... */ } onScroll.shouldPreventDefault = (event): boolean => { // some logic to decide if preventDefault() should be called. } onScroll.shouldStopPropagation = (event): boolean => { // some logic to decide if stopPropagation() should be called. } return <div onScroll={onScroll} />; }
It would be hard to ensure this doesn't become a breaking change, but if this was enforced, all the code to decide if an event needed to be preventDefaulted would be isolated in code and React would be able to run just that part on the main thread and run everything else in a separate worker or asynchronously.

Until this is resolved I think it would be best if references to event.preventDefault() are removed from the docs or are at least marked with a warning regarding Chrome's inability to preventDefault on passive events.

I wonder about the implications of the change of the event delegation from React v17. Lighthouse has a rule https://web.dev/uses-passive-event-listeners/ that test against non-passive events.

Previously, <div onTouchStart /> would be registered on the document, which is passive by default. However, with React v17, the event is registered on the root of the React tree, which is no longer passive without specifically asking for it.

Reproduction: https://codesandbox.io/s/material-demo-forked-e2u72?file=/demo.js, live: https://csb-e2u72.netlify.app/

Capture d’écran 2020-08-19 à 16 11 31

Yeah. Seems concerning. I'll file a new issue.

Filed https://github.com/facebook/react/issues/19651 for React 17 discussion.

Was this page helpful?
0 / 5 - 0 ratings