lifeiscontent
lifeiscontent

Reputation: 593

TypeScript Polymorphic Components in React with conditional props

I'm trying to write a Polymorphic component in React that supports both host elements such as (div, button, etc) and custom React components.

but there's a constraint of needing whatever component is passed in to the as prop that it must have an onClick prop

here's my work in progress.

import React, { useCallback, useRef, useLayoutEffect } from 'react';

export type GaClickTrackerProps<Type extends React.ElementType> = {
  as: Type;
  fieldsObject: {
    hitType: UniversalAnalytics.HitType; // 'event'
    eventCategory: string;
    eventAction: string;
    eventLabel?: string | undefined;
    eventValue?: number | undefined;
    nonInteraction?: boolean | undefined;
  };
  onClick?: (...args: any[]) => any;
} & React.ComponentPropsWithoutRef<Type>;

export function GaClickTracker<Type extends React.ElementType>({
  onClick,
  fieldsObject,
  as: Component,
  ...props
}: GaClickTrackerProps<Type>) {
  const fieldsObjectRef = useRef(fieldsObject);
  const onClickRef = useRef(onClick);
  useLayoutEffect(() => {
    onClickRef.current = onClick;
    fieldsObjectRef.current = fieldsObject;
  });

  const handleClick: React.MouseEventHandler<Type> = useCallback((event) => {
    onClickRef.current?.(event);
    if (fieldsObjectRef.current) {
      ga('send', fieldsObjectRef.current);
    }
  }, []);

  return (
    <Component onClick={handleClick} {...props} />
  );
}

Upvotes: 0

Views: 954

Answers (1)

loucyx
loucyx

Reputation: 141

Instead of going into the "polymorphic component" direction, for this particular issue it would be far better to have an util function like this:

import type { MouseEventHandler } from "react";

type Fields = {
    eventAction: string;
    eventCategory: string;
    eventLabel?: string | undefined;
    eventValue?: number | undefined;
    hitType: UniversalAnalytics.HitType;
    nonInteraction?: boolean | undefined;
};

export const sendAnalytics =
    (fields?: Fields) =>
    <Type>(handler?: MouseEventHandler<Type>): MouseEventHandler<Type> =>
    event => {
        handler?.(event); // We call the passed handler first

        // And we only call `ga` if the we have fields and the default wasn't prevented
        return !event.defaultPrevented && fields
            ? ga("submit", fields)
            : undefined;
    };

And then you can use it like this:

import { sendAnalytics } from "~/utils/sendAnalytics.js";

const sendIDKAnalytics = sendAnalytics({
    eventAction: "IDK",
    eventCategory: "IDK",
    hitType: "IDK",
});

export const TrackedButton = ({ onClick, ...props }) => (
    <button onClick={sendIDKAnalytics(onClick)} {...props} />
);

Being a curried function, you could even reuse the same settings in different components if you wanted. And if you want to "Reactify" it further, you could make it a hook, like useAnalytics and go from there.

The "polymorphic" approach would make it unnecessarily complex with no real gains to DX:

import { createElement } from "react";

// This:
const Polymorphic = ({ as: Component, ...props }) => <Component {...props} />;

// Is not different from just doing this:
const Polymorphic = ({ as, ...props }) => createElement(as, props);

// And when using any of those, your code would look something like this,
// which is not ideal:
<Polymorphic as="main">
    <Polymorphic as="nav">
        <Polymorphic as="a" href="https://example.com">
            Hello
        </Polymorphic>
    </Polymorphic>
</Polymorphic>;

Upvotes: 2

Related Questions