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

[react-interactions] Add Listener API + useEvent hook #17651

Closed
wants to merge 5 commits into from

Conversation

trueadm
Copy link
Contributor

@trueadm trueadm commented Dec 18, 2019

Note: This API is intentionally meant to be a low-level way of creating events and assigning listeners to them. It's meant to be verbose so larger building blocks can be created on top of them.

This PR is an alternative solution and system to that of my other PR: #17508. Specifically, based off feedback internally, I've tried to tackle some of the problems that were brought up with the createListener approach in #17508:

  • createListener largely depended on events being registered in the commit phase, meaning that there would likely be issues around needing to flush more to ensure we register DOM events. The new approach enforces all events are registered unconditionally via hooks in the render phase, mitigating this issue.
  • createListener allowed for listeners to update and change their properties between renders, which again is problematic for performance and would also require more flushes to ensure we have committed the latest version of each listener.
  • createListener had a complex diffing process to ensure we stored the latest listeners, but this meant that the process had additional overhead and memory usage – which is no longer the case with this PR.
  • createListener required listeners to be put on nodes via the listeners prop. Furthermore, it required using arrays to combine multiple listeners, which some felt was not idealistic and might be confusing during debugging as to which listeners occurred at which stages. Also, there was general dislike to introducing another internal prop – as it would mean we'd have to first forbid listeners and wait for React 17 to introduce these APIs, as they might be used in the wild for other reasons (custom elements).
  • createListener didn't provide an idiomatic way to conditionally add/remove root events (they're called delegated events in this new PR). With the new approach, there's a streamlined approach to ensure this is easier to do, and by default, no root events can be added unconditionally, which is a code-smell and a good cause of memory leaks/performance issues.

Taking the above points into consideration, the design of this new event system aims at bringing the same capabilities as described in #17508 whilst also providing some other nice features, that should allow for bigger event sub-systems to be built on top.

ReactDOM.useEvent

This hook allows for the registration to a native DOM event, similar to that of addEventListener on the web. useEvent takes a given event type and registers it to the DOM then returns an object unique to that event that allows listeners to be attached to their targets in an effect or another event handler. The object provides three different methods to setup and handle listeners:

  • setListener(target: window | element, listener: ?(Event => void)) set a listener to be active for a given DOM node. The node must be a DOM node managed by React or it can be the window node for delegation purposes. If the listener is null or undefined then we remove the given listener for that DOM node or window object.
  • clear() remove all listeners

The hook takes three arguments:

  • type the DOM event to listen to
  • options an optional object allowing for additional properties to be defined on the event listener. The options are:
    • passive provide an optional flag that tells the listener to register a passive DOM event listener or an active DOM event listener
    • capture provide an optional flag that tells the listener callback to fire in the capture phase or the bubble phase
    • priority provide an optional Scheduler priority that allows React to correct schedule the listener callback to fire at the correct priority.

Note

For propagation, the same rules of stopPropagation and stopImmediatePropagation apply to these event listeners. These methods are actually monkey-patched, as we use the actual native DOM events with this system and API, rather than Synthetic Events. currentTarget and eventPhase are also respectfully monkey-patched to co-ordinate and align with the propagation system involved internally within React.

Furthermore, all event listeners are passive by default. If is desired to called event.preventDefault on an event listener, then the event listener should be made active via the passive option.

Examples

An example of a basic clickable button:

import {useRef, useEffect} from 'react';
import {useEvent} from 'react-dom';

function Button({children, onClick}) {
  const buttonRef = useRef(null);
  const clickEvent = useEvent('click');

  useEffect(() => {
    clickEvent.setListener(buttonRef.current, onClick);
  });

  return <button ref={buttonRef}>{children}</button>;
}

If you want to listen to events that are delegated to the window, you can do that:

import {useRef, useEffect} from 'react';
import {useEvent} from 'react-dom';

function Button({children, onClick}) {
  const clickEvent = useEvent('click');

  useEffect(() => {
    // Pass in `window`, which is supported with this API
    // Note: `window` is not supported, as React doesn't
    // listen to events that high up.
    clickEvent.setListener(window, onClick);
  });

  return <button>{children}</button>;
}

If you wanted to extract the verbosity out of this into a custom hook, then it's possible to do so:

import {useRef, useEffect} from 'react';
import {useEvent} from 'react-dom';

function useClick(ref, onClick) {
  const clickEvent = useEvent('click');

  useEffect(() => {
    clickEvent.setListener(ref.current, onClick);
  });

  return buttonRef;
}

function Button({children}) {
  const buttonRef = useRef(null);
  useClick(buttonRef , () => {
    console.log('Hello world!')
  });
  return <button ref={buttonRef}>{children}</button>;
}

A more complex button that tracks when the button is being pressed with the mouse:

import {useRef, useEffect} from 'react';
import {useEvent} from 'react-dom';

function Button({children, onClick}) {
  const buttonRef = useRef(null);
  const [pressed, setPressed] = useState(false);
  const click = useEvent('click');
  const mouseUp = useEvent('mouseup');
  const mouseDown = useEvent('mousedown');

  useEffect(() => {
    const button = buttonRef.current;

    const handleMouseUp = () => {
      setPressed(false);
    };
    const handleMouseDown = () => {
      setPressed(true); 
    };

    click.setListener(button, onClick);
    if (pressed) {
      mouseUp.setListener(button, handleMouseUp);
    } else {
      mouseDown.setListener(button, handleMouseDown);
    }

    return () => {
      click.setListener(button, null);
      mouseDown.setListener(button, null);
      mouseUp.setListener(button, null);
    };
  }, [pressed, onClick]);

  return (
    <button ref={buttonRef} className={pressed && 'pressed'}>
      {children}
    </button>
  );
}

What about the DOM's element.addEventListener?

In many respects, this low-level API was intentionally designed to be a replacement for the DOM's addEventListener. Not only does this new Listener API provide many nice benefits, like auto-recycling listeners on unmount, but it should also help prevent bugs that will likely occur when addEventListener is used in conjunction with Concurrent Mode.

For a detailed list of differences, here are just some of the key benefits of the Listener API vs the DOM's addEventListener:

  • The event listeners automatically get recycled upon unmount
  • The event listeners correctly batch updates and flush previous ones correctly, as well as co-ordinating priority levels with the internal scheduler
  • The event listeners allow for alignment with the propagation of other events in the React event system, something not possible with manual DOM event listeners
  • The event listeners will correctly work with React Portals, Suspense and Concurrent Mode, native event listeners do not.
  • By ensuring we use a hook, like useEvent, we can determine the event needed during server-side render and ensure those events are registered on initial hydration ahead of time. This makes it possible to "replay" those events before the components themselves have concurrently hydrated on the client.

@codesandbox-ci
Copy link

codesandbox-ci bot commented Dec 18, 2019

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 416ee34:

Sandbox Source
proud-wildflower-kngkd Configuration

@sizebot
Copy link

sizebot commented Dec 18, 2019

Warnings
⚠️ Could not find build artifacts for base commit: 3e9251d

Size changes (stable)

Generated by 🚫 dangerJS against 416ee34

@sizebot
Copy link

sizebot commented Dec 18, 2019

Warnings
⚠️ Could not find build artifacts for base commit: 3e9251d

Size changes (experimental)

Generated by 🚫 dangerJS against 416ee34

@trueadm trueadm force-pushed the responder-api branch 4 times, most recently from 16df9d7 to 72ed512 Compare December 18, 2019 23:25
@necolas

This comment has been minimized.

@trueadm

This comment has been minimized.

packages/shared/ReactFeatureFlags.js Outdated Show resolved Hide resolved
packages/shared/forks/ReactFeatureFlags.www.js Outdated Show resolved Hide resolved
@necolas

This comment has been minimized.

@trueadm

This comment has been minimized.

@necolas

This comment has been minimized.

@trueadm trueadm force-pushed the responder-api branch 2 times, most recently from 81c934b to 2f076e6 Compare December 19, 2019 11:49
@trueadm

This comment has been minimized.

@trueadm trueadm changed the title [react-interactions] Add Listener API + useEvent/useDelegatedEvent [react-interactions] Add Listener API + useEvent hook Dec 20, 2019
@trueadm
Copy link
Contributor Author

trueadm commented Dec 20, 2019

I've redesign and refactored the code in this PR after much delibration and feedback today. Notably: this design is far more low-level and more like how events are added with addEventListener today. This is intentional and with this design, it actually makes it possible to be approaches like the createListener propsoal and PR with this design – plus it reduces the complexity and overhead needed when thinking about some of the problems to do with adding/removing events.

Here's another example of this looks like now:

function Component() {
  const divRef = useRef(null)
  // Creates a clickEvent that acts like a-kind of
  // Event Map. This should always happen in the
  // render phase, so we can properly setup the listeners
  // unconditionally. Furthermore, we can detect
  // this listeners during SSR, and eagerly setup
  // the right event listeners on init, to better
  // enhance event replaying (for custom events etc).
  const clickEvent = useEvent('click', {
    // You can leverage the same benefits
    // available today on the web, such as
    // passive event listeners.
    passive: true,
    capture: false,
  })

  useEffect(() => {
    const div = divRef.current;
    // Set a listener for a given DOM node
    clickEvent.setListener(div, nativeEvent => {
      console.log(nativeEvent);
    });
    // You can set a listener to the `window` or
    // any other node managed by React. Nodes
    // not managed by React will result in
    // an error being fired.
    clickEvent.setListener(window, nativeEvent => {
      console.log(nativeEvent);
    });
    // You can also delete a listener by passing `null`
    clickEvent.setListener(div, null);
    
    // If you have many listeners, you can clear them
    // all without having to know of them
    clickEvent.clear();

    // Note: listeners will automatically be
    // destroyed if the component with the hook
    // is detatched, or the DOM node that the
    // listener is attached to is detatched.
  }, [clickEvent])

  return <div ref={divRef} />
}

@trueadm trueadm requested a review from gaearon December 20, 2019 00:41
@necolas
Copy link
Contributor

necolas commented Dec 22, 2019

I don't think this API is sufficient for creating a global gesture system like the responder system in React Native. In that case you might only want to set up one listener for each touch event type on the window to track touches across the entire surface, and to coordinate how the interaction lock is managed across multiple views (accounting for views not managed by react).

@trueadm
Copy link
Contributor Author

trueadm commented Dec 22, 2019

@necolas How would this API be more suffecient for that use-case? Would you mind explaining what is missing?

I've also tweaked the API after @TrySound's suggestion to make setListener take null/undefined as the second argument to clear a listener rather than needing deleteListener. This improves code-size further dropping the entire implementation to under 1kb min+gzip! It also makes for less boilerplate for when you map a prop to a listener (which might be be nullish) which also helps reduce the size of userland custom hooks!

@necolas
Copy link
Contributor

necolas commented Dec 23, 2019

I don't think there's anything wrong with this hooks API or that it necessarily needs (or can be) adjusted to support "global" systems like modality tracking and interaction-locks. They rely on a single coordinator that listens only to "top" level events on the window/document.

@devknoll
Copy link
Contributor

devknoll commented Jan 6, 2020

Out of curiosity: is there a chance that setting listeners in useEffect (rather than useLayoutEffect) could cause some events to be missed (however rare), or is there something else done to avoid that?

@trueadm
Copy link
Contributor Author

trueadm commented Jan 7, 2020

@devknoll You're right, in some cases you'd want to use useLayoutEffect, which works more like how events happen today in React. This is important for focus and load etc, but really, the most common cases can move into useEffect which means we do less work in the commit phase! :)

@trueadm trueadm force-pushed the responder-api branch 2 times, most recently from 9d4acaf to 9514f59 Compare February 4, 2020 12:11
Fix flow

fix conflict

Add missing flag
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed React Core Team Opened by a member of the React Core Team
Projects
None yet
Development

Successfully merging this pull request may close these issues.