I typically use component composition to reuse logic the React way. For example, here is a simplified version on how I would add interaction logic to a component. In this case I would make CanvasElement
selectable:
CanvasElement.js
import React, { Component } from 'react'
import Selectable from './Selectable'
import './CanvasElement.css'
export default class CanvasElement extends Component {
constructor(props) {
super(props)
this.state = {
selected: false
}
this.interactionElRef = React.createRef()
}
onSelected = (selected) => {
this.setState({ selected})
}
render() {
return (
<Selectable
iElRef={this.interactionElRef}
onSelected={this.onSelected}>
<div ref={this.interactionElRef} className={'canvas-element ' + (this.state.selected ? 'selected' : '')}>
Select me
</div>
</Selectable>
)
}
}
Selectable.js
import { Component } from 'react'
import PropTypes from 'prop-types'
export default class Selectable extends Component {
static propTypes = {
iElRef: PropTypes.shape({
current: PropTypes.instanceOf(Element)
}).isRequired,
onSelected: PropTypes.func.isRequired
}
constructor(props) {
super(props)
this.state = {
selected: false
}
}
onClick = (e) => {
const selected = !this.state.selected
this.setState({ selected })
this.props.onSelected(selected)
}
componentDidMount() {
this.props.iElRef.current.addEventListener('click', this.onClick)
}
componentWillUnmount() {
this.props.iElRef.current.removeEventListener('click', this.onClick)
}
render() {
return this.props.children
}
}
Works well enough. The Selectable wrapper does not need to create a new div because its parent provides it with a reference to another element that is to become selectable.
However, I've been recommended on numerous occasions to stop using such Wrapper composition and instead achieve reusability through Higher Order Components. Willing to experiment with HoCs, I gave it a try but did not come further than this:
CanvasElement.js
import React, { Component } from 'react'
import Selectable from '../enhancers/Selectable'
import flow from 'lodash.flow'
import './CanvasElement.css'
class CanvasElement extends Component {
constructor(props) {
super(props)
this.interactionElRef = React.createRef()
}
render() {
return (
<div ref={this.interactionElRef}>
Select me
</div>
)
}
}
export default flow(
Selectable()
)(CanvasElement)
Selectable.js
import React, { Component } from 'react'
export default function makeSelectable() {
return function decorateComponent(WrappedComponent) {
return class Selectable extends Component {
componentDidMount() {
// attach to interaction element reference here
}
render() {
return (
<WrappedComponent {...this.props} />
)
}
}
}
}
The problem is that there appears to be no obvious way to connect the enhanced component's reference (an instance variable) to the higher order component (the enhancer).
How would I "pass in" the instance variable (the interactionElRef
) from the CanvasElement to its HOC?
For passing the data from the child component to the parent component, we have to create a callback function in the parent component and then pass the callback function to the child component as a prop. This callback function will retrieve the data from the child component.
In the case of the class component, React creates an instance of the class using the new keyword: const instance = new Component(props); This instance is an object. When we say a component is a class, what we actually mean is that it is an object.
I came up with a different strategy. It acts roughly like the Redux connect
function, providing props that the wrapped component isn't responsible for creating, but the child is responsible for using them as they see fit:
CanvasElement.js
import React, { Component } from "react";
import makeSelectable from "./Selectable";
class CanvasElement extends Component {
constructor(props) {
super(props);
}
render() {
const { onClick, selected } = this.props;
return <div onClick={onClick}>{`Selected: ${selected}`}</div>;
}
}
CanvasElement.propTypes = {
onClick: PropTypes.func,
selected: PropTypes.bool,
};
CanvasElement.defaultProps = {
onClick: () => {},
selected: false,
};
export default makeSelectable()(CanvasElement);
Selectable.js
import React, { Component } from "react";
export default makeSelectable = () => WrappedComponent => {
const selectableFactory = React.createFactory(WrappedComponent);
return class Selectable extends Component {
state = {
isSelected: false
};
handleClick = () => {
this.setState({
isSelected: !this.state.isSelected
});
};
render() {
return selectableFactory({
...this.props,
onClick: this.handleClick,
selected: this.state.isSelected
});
}
}
};
https://codesandbox.io/s/7zwwxw5y41
I know that doesn't answer your question. I think you're trying to let the child get away without any knowledge of the parent.
The ref
route feels wrong, though. I like the idea of connecting the tools to the child. You can respond to the click in either one.
Let me know what you think.
Just as you did on DOM element for CanvasElement, Ref
can be attached to class component as well, checkout the doc for Adding a Ref to a Class Component
export default function makeSelectable() {
return function decorateComponent(WrappedComponent) {
return class Selectable extends Component {
canvasElement = React.createRef()
componentDidMount() {
// attach to interaction element reference here
console.log(this.canvasElement.current.interactionElRef)
}
render() {
return (
<WrappedComponent ref={this.canvasElement} {...this.props} />
)
}
}
}
}
Also, do checkout Ref forwarding if you need child instance reference in ancestors that's multiple levels higher in the render tree. All those solutions are based on assumptions that you're on react 16.3+.
Some caveats:
In rare cases, you might want to have access to a child’s DOM node from a parent component. This is generally not recommended because it breaks component encapsulation, but it can occasionally be useful for triggering focus or measuring the size or position of a child DOM node.
While you could add a ref to the child component, this is not an ideal solution, as you would only get a component instance rather than a DOM node. Additionally, this wouldn’t work with functional components. https://reactjs.org/docs/forwarding-refs.html
I've now come up with an opinionated solution where the HoC injects two callback functions into the enhanced component, one to register the dom reference and another to register a callback that is called when an element is selected or deselected:
makeElementSelectable.js
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import movementIsStationary from '../lib/movement-is-stationary';
/*
This enhancer injects the following props into your component:
- setInteractableRef(node) - a function to register a React reference to the DOM element that should become selectable
- registerOnToggleSelected(cb(bool)) - a function to register a callback that should be called once the element is selected or deselected
*/
export default function makeElementSelectable() {
return function decorateComponent(WrappedComponent) {
return class Selectable extends Component {
static propTypes = {
selectable: PropTypes.bool.isRequired,
selected: PropTypes.bool
}
eventsAdded = false
state = {
selected: this.props.selected || false,
lastDownX: null,
lastDownY: null
}
setInteractableRef = (ref) => {
this.ref = ref
if (!this.eventsAdded && this.ref.current) {
this.addEventListeners(this.ref.current)
}
// other HoCs may set interactable references too
this.props.setInteractableRef && this.props.setInteractableRef(ref)
}
registerOnToggleSelected = (cb) => {
this.onToggleSelected = cb
}
componentDidMount() {
if (!this.eventsAdded && this.ref && this.ref.current) {
this.addEventListeners(this.ref.current)
}
}
componentWillUnmount() {
if (this.eventsAdded && this.ref && this.ref.current) {
this.removeEventListeners(this.ref.current)
}
}
/*
keep track of where the mouse was last pressed down
*/
onMouseDown = (e) => {
const lastDownX = e.clientX
const lastDownY = e.clientY
this.setState({
lastDownX, lastDownY
})
}
/*
toggle selected if there was a stationary click
only consider clicks on the exact element we are making interactable
*/
onClick = (e) => {
if (
this.props.selectable
&& e.target === this.ref.current
&& movementIsStationary(this.state.lastDownX, this.state.lastDownY, e.clientX, e.clientY)
) {
const selected = !this.state.selected
this.onToggleSelected && this.onToggleSelected(selected, e)
this.setState({ selected })
}
}
addEventListeners = (node) => {
node.addEventListener('click', this.onClick)
node.addEventListener('mousedown', this.onMouseDown)
this.eventsAdded = true
}
removeEventListeners = (node) => {
node.removeEventListener('click', this.onClick)
node.removeEventListener('mousedown', this.onMouseDown)
this.eventsAdded = false
}
render() {
return (
<WrappedComponent
{...this.props}
setInteractableRef={this.setInteractableRef}
registerOnToggleSelected={this.registerOnToggleSelected} />
)
}
}
}
}
CanvasElement.js
import React, { PureComponent } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import PropTypes from 'prop-types'
import flowRight from 'lodash.flowright'
import { moveSelectedElements } from '../actions/canvas'
import makeElementSelectable from '../enhancers/makeElementSelectable'
class CanvasElement extends PureComponent {
static propTypes = {
setInteractableRef: PropTypes.func.isRequired,
registerOnToggleSelected: PropTypes.func
}
interactionRef = React.createRef()
componentDidMount() {
this.props.setInteractableRef(this.interactionRef)
this.props.registerOnToggleSelected(this.onToggleSelected)
}
onToggleSelected = async (selected) => {
await this.props.selectElement(this.props.id, selected)
}
render() {
return (
<div ref={this.interactionRef}>
Select me
</div>
)
}
}
const mapStateToProps = (state, ownProps) => {
const {
canvas: {
selectedElements
}
} = state
const selected = !!selectedElements[ownProps.id]
return {
selected
}
}
const mapDispatchToProps = dispatch => ({
selectElement: bindActionCreators(selectElement, dispatch)
})
const ComposedCanvasElement = flowRight(
connect(mapStateToProps, mapDispatchToProps),
makeElementSelectable()
)(CanvasElement)
export default ComposedCanvasElement
This works, but I can think of at least one significant issue: the HoC injects 2 props into the enhanced component; but the enhanced component has no way of declaratively defining which props are injected and just needs to "trust" that these props are magically available
Would appreciate feedback / thoughts on this approach. Perhaps there is a better way, e.g. by passing in a "mapProps" object to makeElementSelectable
to explicitly define which props are being injected?
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With