Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to achieve re-useable components with React and mouse event propagation?

Consider the following typical React document structure:

Component.jsx

<OuterClickableArea>
    <InnerClickableArea>
        content
    </InnerClickableArea>
</OuterClickableArea>

Where these components are composed as follows:

OuterClickableArea.js

export default class OuterClickableArea extends React.Component {
    constructor(props) {
        super(props)

        this.state = {
            clicking: false
        }

        this.onMouseDown = this.onMouseDown.bind(this)
        this.onMouseUp = this.onMouseUp.bind(this)
    }

    onMouseDown() {
        if (!this.state.clicking) {
            this.setState({ clicking: true })
        }
    }

    onMouseUp() {
        if (this.state.clicking) {
            this.setState({ clicking: false })
            this.props.onClick && this.props.onClick.apply(this, arguments)
            console.log('outer area click')
        }
    }

    render() {
        return (
            <div
                onMouseDown={this.onMouseDown}
                onMouseUp={this.onMouseUp}
            >
                { this.props.children }
            </div>
        )
    }
}

With, for the sake of this argument, InnerClickableArea.js having pretty much the same code except for the class name and the console log statement.

Now if you were to run this app and a user would click on the inner clickable area, the following would happen (as expected):

  1. outer area registers mouse event handlers
  2. inner area registers mouse event handlers
  3. user presses mouse down in the inner area
  4. inner area's mouseDown listener triggers
  5. outer area's mouseDown listener triggers
  6. user releases mouse up
  7. inner area's mouseUp listener triggers
  8. console logs "inner area click"
  9. outer area's mouseUp listener triggers
  10. console logs "outer area click"

This displays typical event bubbling/propagation -- no surprises so far.

It will output:

inner area click
outer area click

Now, what if we are creating an app where only a single interaction can happen at a time? For example, imagine an editor where elements could be selected with mouse clicks. When pressed inside the inner area, we would only want to select the inner element.

The simple and obvious solution would be to add a stopPropagation inside the InnerArea component:

InnerClickableArea.js

    onMouseDown(e) {
        if (!this.state.clicking) {
            e.stopPropagation()
            this.setState({ clicking: true })
        }
    }

This works as expected. It will output:

inner area click

The problem?

InnerClickableArea hereby implicitly chose for OuterClickableArea (and all other parents) not to be able to receive an event. Even though InnerClickableArea is not supposed to know about the existence of OuterClickableArea. Just like OuterClickableArea does not know about InnerClickableArea (following separation of concerns and reusability concepts). We created an implicit dependency between the two components where OuterClickableArea "knows" that it won't mistakenly have its listeners fired because it "remembers" that InnerClickableArea would stop any event propagation. This seems wrong.

I'm trying not to use stopPropagation to make the app more scalable, because it would be easier to add new features that rely on events without having to remember which components use stopPropagation at which time.

Instead, I would like to make the above logic more declarative, e.g. by defining declaratively inside Component.jsx whether each of the areas is currently clickable or not. I would make it app state aware, passing down whether the areas are clickable or not, and rename it to Container.jsx. Something like this:

Container.jsx

<OuterClickableArea
    clickable={!this.state.interactionBusy}
    onClicking={this.props.setInteractionBusy.bind(this, true)} />

    <InnerClickableArea
        clickable={!this.state.interactionBusy}
        onClicking={this.props.setInteractionBusy.bind(this, true)} />

            content

    </InnerClickableArea>

</OuterClickableArea>

Where this.props.setInteractionBusy is a redux action that would cause the app state to be updated. Also, this container would have the app state mapped to its props (not shown above), so this.state.ineractionBusy will be defined.

OuterClickableArea.js (almost same for InnerClickableArea.js)

export default class OuterClickableArea extends React.Component {
    constructor(props) {
        super(props)

        this.state = {
            clicking: false
        }

        this.onMouseDown = this.onMouseDown.bind(this)
        this.onMouseUp = this.onMouseUp.bind(this)
    }

    onMouseDown() {
        if (this.props.clickable && !this.state.clicking) {
            this.setState({ clicking: true })
            this.props.onClicking && this.props.onClicking.apply(this, arguments)
        }
    }

    onMouseUp() {
        if (this.state.clicking) {
            this.setState({ clicking: false })
            this.props.onClick && this.props.onClick.apply(this, arguments)
            console.log('outer area click')
        }
    }

    render() {
        return (
            <div
                onMouseDown={this.onMouseDown}
                onMouseUp={this.onMouseUp}
            >
                { this.props.children }
            </div>
        )
    }
}

The problem is that the Javascript event loop seems to run these operations in the following order:

  1. both OuterClickableArea and InnerClickableArea are created with prop clickable equal to true (because app state interactionBusy defaults to false)
  2. outer area registers mouse event handlers
  3. inner area registers mouse event handlers
  4. user presses mouse down in the inner area
  5. inner area's mouseDown listener triggers
  6. inner area's onClicking is fired
  7. container runs 'setInteractionBusy' action
  8. redux app state interactionBusy is set to true
  9. outer area's mouseDown listener triggers (it's clickable prop is still true because even though the app state interactionBusy is true, react did not yet cause a re-render; the render operation is put at the end of the Javascript event loop)
  10. user releases mouse up
  11. inner area's mouseUp listener triggers
  12. console logs "inner area click"
  13. outer area's mouseUp listener triggers
  14. console logs "outer area click"
  15. react re-renders Component.jsx and passes in clickable as false to both components, but by now this is too late

This will output:

inner area click
outer area click

Whereas the desired output is:

inner area click

App state, therefore, does not help in trying to make these components more independent and reusable. The reason seems to be that even though the state is updated, the container only re-renders at the end of the event loop, and the mouse event propagation triggers are already queued in the event loop.

My question, therefore, is: is there an alternative to using app state to be able to work with mouse event propagation in a declarative manner, or is there a better way to implement/execute my above setup with app (redux) state?

like image 244
Tom Avatar asked May 11 '18 18:05

Tom


2 Answers

Easiest alternative to this is to add a flag before firing stopPropogation and the flag in this case is an argument.

const onMouseDown = (stopPropagation) => {
stopPropagation && event.stopPropagation();
}

Now even the application state or prop can even decide to trigger the stoppropagation

<div onMouseDown={this.onMouseDown(this.state.stopPropagation)} onMouseUp={this.onMouseUp(this.props.stopPropagation)} >
    <InnerComponent stopPropagation = {true}>
</div>
like image 41
karthik Avatar answered Oct 31 '22 22:10

karthik


As @Rikin mentioned and as you alluded to in your answer, event.stopPropagation would solve the your issue in its current form.

I'm trying not to use stopPropagation to make the app more scalable, because it would be easier to add new features that rely on events without having to remember which components use stopPropagation at which time.

Is this not a premature concern in terms of the current scope of your component architecture? If you wish for innerComponent to be nested within outerClickableArea the only way to achieve that is to stopPropagation or to somehow determine which events to ignore. Personally, I would stop propagation for all events, and for those click events for which you selectively want to share state between components, capture the events and dispatch an action (if using redux) to the global state. That way you don't have to worry about selectively ignoring/capturing bubbled events and can update a global source of truth for those events that imply shared state

like image 122
john_mc Avatar answered Oct 31 '22 22:10

john_mc