1

I would like to add analytics tracking events to all buttons in my app. All the buttons are mui buttons, but I feel my issue would be the same if I was just using native HTML buttons.

My idea is to create a "wrapper" react component that I replace all Button components with, which basically wraps the Button component, adds some further logic to its event listeners, and then continues to propagate whatever listeners are attached.

I've reached a point where I'm not sure how best to do this, while also forwarding refs. The question may be "how do I both forward refs AND use local createRef refs, though I'm not sure.

The component looks like this:

CustomButton.tsx

import _ from 'lodash';
import { Button, ButtonProps } from '@mui/material';
import { analyticsTrack, mergeRefs } from 'utils/functions';
import { forwardRef, ForwardedRef, createRef } from 'react';

interface CustomButtonProps extends ButtonProps {
  trackingName?: string;
}

const CustomButton = forwardRef(
  (
    { trackingName, children, ...rest }: CustomButtonProps,
    ref: ForwardedRef<any>
  ) => {
    const buttonRef = createRef();
    function handleClick(...args: any) {
      analyticsTrack('Button Clicked', {
        props: rest,
        ariaLabel: rest['aria-label'],
        trackingName,
        buttonText: typeof children === 'string' ? children : '',
      });
      if (rest.onClick) {
        rest.onClick(args);
      }
    }

    return (
      // @ts-ignore
      // eslint-disable-next-line react/jsx-no-bind
      <Button
        {..._.omitBy(rest, ['onClick'])}
        onMouseDown={(event) => event.stopPropagation()}
        onTouchStart={(event) => event.stopPropagation()}
        onClick={(event) => {
          event.stopPropagation();
          event.preventDefault();
          handleClick();
          // @ts-ignore
          buttonRef.current && buttonRef.current.dispatchEvent(event);
        }}
        // @ts-ignore
        ref={mergeRefs(ref, buttonRef)}
      >
        {children}
      </Button>
    );
  }
);

export default CustomButton;

I'm quite confused about the types as well, not quite sure why there's a difference between the type of ForwardRef, Ref., and RefObject. The mergeRefs function looks as follows:

export const mergeRefs = (...refs: RefObject<Element | null>[]) => {
  return ( node: Element ) => {
    for (const ref of refs) {
      // @ts-ignore
      ref && !ref.current ? ref.current = node : null;
    }
  }
}

I've read through the forwardRefs page on the react docs, which led me to believe that this is the path I should go down in general for adding tracking event handling to all my button components in the app. I've also been reading various stack overflow answers on stopping, and then re-issuing, native events such as submit (which various parts of the app use to be captured by Formik, for example, hence why simple click handling alone isn't sufficient). These three answers were helpful in getting me to where I am now:

How can I call React useRef conditionally in a Portal wrapper and have better code?

How to use refs in React with Typescript

Higher Order React Component for Click Tracking

At this point though, I'm getting an error about not being able to invoke dispatchEvent on the event object, as apparently SyntheticEvent types can't be passed to dispatchEvent, and I'm concerned I'm down the wrong fork on this path here.

In short, what is the correct, or perhaps react-recommended, way for "invisibly" wrapping a component, operating on its functions and event handlers, and then continuing invocations on all said functions and event handlers without stopping them? In this case with the specific usecase of implementing a tracking library.

I searched around the react docs, as well as googled for some various tutorials and looking through codebases of various tracking libraries and react integrations (such as Segment and Google Analytics) but failed to find examples along these lines, which makes me worried that I'm on an untrodden and thus incorrect path and having a fundamental misunderstanding.

Caleb Jay
  • 2,159
  • 3
  • 32
  • 66

1 Answers1

0

There should be no need to handle an internal ref for your use case, just forwarding it.

Simply make sure to wrap the onClick event handler with your own implementation, to call your analyticsTrack function first:

const CustomButton = forwardRef<any, CustomButtonProps>(
  ({ trackingName, children, onClick, ...rest }, ref) => {
    function handleClick(event: any) {
      analyticsTrack("Button Clicked", {
        props: rest,
        ariaLabel: rest["aria-label"],
        trackingName,
        buttonText: typeof children === "string" ? children : ""
      });
      if (onClick) {
        onClick(event);
      }
    }

    return (
      <Button {...rest} onClick={handleClick} ref={ref}>
        {children}
      </Button>
    );
  }
);

Demo: https://codesandbox.io/s/nameless-frog-jtdves?file=/src/App.tsx

ghybs
  • 47,565
  • 6
  • 74
  • 99
  • This already works, my concern is capturing and rebubbling events such as `submit` – Caleb Jay Jan 12 '23 at 06:23
  • If so, for what reason do you `stopPropagation()`? – ghybs Jan 12 '23 at 08:40
  • That's... a great question... I don't remember, and removing it does solve my issue lol. Technically that accomplishes what I was seeking, but I'm still really interested in how I would access the Button element so I could continue event propagation off it, or do whatever else. – Caleb Jay Jan 12 '23 at 10:30
  • 1
    If you _do_ need to manage an internal `ref`, and at the same time exposing it (but the consuming component may not provide it), do it as described in https://stackoverflow.com/questions/73724784/react-typescript-when-using-ref-i-get-typescript-error-property-current-do/73725085#73725085 for example. That being said, be careful when dispatching your event, not to create an infinite loop... – ghybs Jan 12 '23 at 10:37
  • well good enough for me, technically this comment is the answer – Caleb Jay Jan 16 '23 at 05:30