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):
- outer area registers mouse event handlers
- inner area registers mouse event handlers
- user presses mouse down in the inner area
- inner area's mouseDown listener triggers
- outer area's mouseDown listener triggers
- user releases mouse up
- inner area's mouseUp listener triggers
- console logs "inner area click"
- outer area's mouseUp listener triggers
- 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:
- both
OuterClickableArea
andInnerClickableArea
are created with propclickable
equal totrue
(because app state interactionBusy defaults to false) - outer area registers mouse event handlers
- inner area registers mouse event handlers
- user presses mouse down in the inner area
- inner area's mouseDown listener triggers
- inner area's onClicking is fired
- container runs 'setInteractionBusy' action
- redux app state
interactionBusy
is set totrue
- outer area's mouseDown listener triggers (it's
clickable
prop is stilltrue
because even though the app stateinteractionBusy
istrue
, react did not yet cause a re-render; the render operation is put at the end of the Javascript event loop) - user releases mouse up
- inner area's mouseUp listener triggers
- console logs "inner area click"
- outer area's mouseUp listener triggers
- console logs "outer area click"
- react re-renders
Component.jsx
and passes inclickable
asfalse
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?