9

I'd like to implement a higher order react component which can be used to easily track events (like a click) on any React component. The purpose of this is to easily hook clicks (and other events) into our first party analytics tracker.

The challenge I've come across is that the React synthetic event system requires events (like onClick) be bound to react DOM elements, like a div. If the component I'm wrapping is a custom component, like every HOC implemented via a higher order function is, my click events don't bind correctly.

For example, using this HOC, the onClick handler will fire for button1, but not for button2.

// Higher Order Component 
class Track extends React.Component {
  onClick = (e) => {
    myTracker.track(props.eventName);
  }

  render() {
    return React.Children.map(
      this.props.children,
      c => React.cloneElement(c, {
        onClick: this.onClick,
      }),
    );
  }
}

function Wrapper(props) {
  return props.children;
}

<Track eventName={'button 1 click'}>
  <button>Button 1</button>
</Track>

<Track eventName={'button 2 click'}>
  <Wrapper>
    <button>Button 2</button>
  </Wrapper>
</Track>

CodeSandbox with working example: https://codesandbox.io/embed/pp8r8oj717

My goal is to be able to use an HOF like this (optionally as a decorator) to track clicks on any React component definition.

export const withTracking = eventName => Component => props => {
  return (
    <Track eventName={eventName}>
      {/* Component cannot have an onClick event attached to it */}
      <Component {...props} />
    </Track>
  );
};

The only solution I can think of atm is using a Ref on each child and manually binding my click event once the Ref is populated.

Any ideas or other solutions are appreciated!

UPDATE: Using the remapChildren technique from @estus' answer and a more manual way of rendering the wrapped components, I was able to get this to work as a higher order function - https://codesandbox.io/s/3rl9rn1om1

export const withTracking = eventName => Component => {
  if (typeof Component.prototype.render !== "function") {
    return props => <Track eventName={eventName}>{Component(props)}</Track>;
  }
  return class extends Component {
    render = () => <Track eventName={eventName}>{super.render()}</Track>;
  };
};
Brian Tompsett - 汤莱恩
  • 5,753
  • 72
  • 57
  • 129
jamis0n
  • 3,610
  • 8
  • 34
  • 50

1 Answers1

3

It's impossible to get a reference to underlying DOM node (and there can be more than one of them) from Wrapper React element by regular means, like a ref or ReactDOM.findDOMNode. In order to do that, element children should be traversed recursively.

An example:

remapChildren(children) {
  const { onClick } = this;

  return React.Children.map(
    children,
    child => {   
      if (typeof child.type === 'string') {
        return React.cloneElement(child, { onClick });
      } else if (React.Children.count(child.props.children)) {
        return React.cloneElement(child, { children: this.remapChildren(
          child.props.children
        ) });
      }
    }
  );
}

render() {
  return this.remapChildren(this.props.children);
}

remapChildren places onClick only on DOM elements. Notice that this implementation skips nested DOM elements.

Estus Flask
  • 206,104
  • 70
  • 425
  • 565
  • This is pretty clever. If I could summarize, were recursively traversing each child for the first DOM element rendered (identified by its type being a string), attaching an `onClick` handler. A child itself may have many children, possibly with mixed types, but this is accounted for with a recursive method which iterates over all children and checks their types. – jamis0n Oct 08 '18 at 19:54
  • Yes, that's correct. I suppose it's the only way besides direct DOM access. – Estus Flask Oct 08 '18 at 19:59
  • Just tested this approach and it works great with the `` use cases above but not as an HOC in the `withTracking` case, as `Component` when rendered in the HOC doesn't have any children (``) – jamis0n Oct 09 '18 at 01:55
  • Looks like rendering the children via `React.createElement` doesnt work with the `remapChildren` function. However, using `super.render()` with a class component and invoking a functional component instead works (see updated example). – jamis0n Oct 09 '18 at 04:30
  • Yes, that's because component's rendered children aren't exposed as `props.children`. This is acceptable hack here, I suppose. Notice that Component is not necessarily a function. You're limited to functions this way, but there's a bunch of other components that occur often in real app, e.g. a context (also a context can occur anywhere in element hierarchy). That's a nice proof of concept but I'm not sure how practical it is. Would be likely more efficient to get children with direct DOM access and set/unset `click` listener directly. Depends on the uses of Track. – Estus Flask Oct 09 '18 at 06:53
  • `Component` in the `withTracking` use case is a component _definition_ (`withTracking(eventName)(Component)`). It accounts for that definition being either a function or a class. When would a React component you wish to decorate with this functionality not be defined as either a function or a class? – jamis0n Oct 09 '18 at 14:19
  • 1
    I mean which components exactly it will be used with. You'll be limited to components that were specifically designed to work smoothly with `Track`. Any third-party component has good chances to be untrackable. Examples of components that are not functions are `React.forwardRef` or context `Consumer`. Even if outer component is a function, it can contain a component that doesn't expose `props.children`. Btw, `render = () => ` is not a good thing, it's better to make it prototype method for compatibility reasons, `render() {`. – Estus Flask Oct 09 '18 at 14:33
  • Got it. That is a good clarification that its not the HOF restriction but rather `Track` in general. For my use cases, this should cover almost everything we need. – jamis0n Oct 09 '18 at 14:44