13

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?

Tom
  • 8,536
  • 31
  • 133
  • 232

3 Answers3

6

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.

Reed Dunkle
  • 3,408
  • 1
  • 18
  • 29
  • imho this is the best try so far if CanvasElement is always Selectable. HOC still makes sense in this case because you still can make other components always selectable. The main drawback is that your enhanced component needs to know it is being wrapped. I will also give it a shot when I have a bit of time. – Logar May 24 '18 at 08:09
  • That's what I think the main drawback is as well. But, all of the OP's solutions still have that knowledge present too. – Reed Dunkle May 24 '18 at 12:27
  • Could you elaborate on why this is better than passing in callback functions? My primary purpose of the HoC is such that I do not need to imperatively add event listeners etc. I just want to declare that the element is selectable, and not have to worry about adding event listeners in the right place. – Tom May 27 '18 at 02:27
  • Let's [continue this in chat](https://chat.stackoverflow.com/rooms/171866/how-to-pass-in-an-instance-variable-from-a-react-component-to-its-hoc) – Reed Dunkle May 27 '18 at 13:50
2

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

Community
  • 1
  • 1
Allen
  • 4,431
  • 2
  • 27
  • 39
  • Thanks but this seems like bad practice since the HoC is now implicitly depending on `.interactionElRef`. It wouldn't be reuseable since you cannot use it in other components that don't use this exact naming. Isn't this why redux has mapStateToProps and mapDispatchToProps etc.? Or why react DND has collect? – Tom May 18 '18 at 17:21
  • React encourages one way flow, that is data is passed from Parent to Children through props, while events should bubble. Using refs obviously you're accessing child's data from parent, creating coupling as you said, that's why the doc has included the above caveats. `mapStateToProps` and `mapDispatchToProps` is another thing, they're selectors to compute derive data from redux store, but still follows the one way flow, since redux store are global data shared across whole render tree as long as your component is "connected". – Allen May 18 '18 at 17:26
  • Refs are rarely needed, but sometimes you do want to have access to browser dom elements instead of react' vdom elements, to directly manipulate dom like `inputElement.focus()`, most of time passing child's data to parent should be done by event handlers (callback). – Allen May 18 '18 at 17:28
  • Thanks - following React's one way flow, how would you solve my problem of making an element selectable in a reuseable way? Would you suggest HoC? – Tom May 18 '18 at 19:35
  • I checked out your composition approach looked fairly good to me, i can't see any clear benefits switching to HoC, as long as Ref is needed, it doesn't improve reusability anyway. HoC is more a component approach to encapsulate/centralize business logics (auth/feature flag/analytics etc) to decouple from rendering logics, it's well adopted pattern w/ many gotchas as well, thus comes [render props](https://reactjs.org/docs/render-props.html) as a more flexible alternative. – Allen May 19 '18 at 17:05
  • Which approach do you think is better? The wrapper or the one I just posted (https://stackoverflow.com/a/50430312/45974) ? – Tom May 20 '18 at 00:19
1

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?

Tom
  • 8,536
  • 31
  • 133
  • 232