Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to pass in an instance variable from a React component to its HOC?

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?

like image 817
Tom Avatar asked May 17 '18 22:05

Tom


People also ask

How do you pass variable from one component to another in React?

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.

How do you create an instance of a component in React?

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.


3 Answers

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.

like image 75
Reed Dunkle Avatar answered Sep 16 '22 18:09

Reed Dunkle


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

like image 22
lxyyz Avatar answered Sep 16 '22 18:09

lxyyz


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?

like image 34
Tom Avatar answered Sep 20 '22 18:09

Tom