nytimes / react-tracking

🎯 Declarative tracking for React apps.

Home Page:https://open.nytimes.com/introducing-react-tracking-declarative-tracking-for-react-apps-2c76706bb79a

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Expand React Hooks API support

tizmagik opened this issue · comments

To build on the work done in #124 (issue #120) -- we currently support "grabbing" the contextual tracking data via useTracking() hook, but we don't currently provide a way to add additional tracking context via hooks. I'm not entirely sure if doing so would be an anti-pattern, but if it's possible and doesn't break React Hooks paradigm/patterns to do so, it would be great to not have to use the track() HoC wrapper and just use a React Hooks API.

The tricky thing here is that the useContext hook doesn't have the ability to create a new context provider. It can only consume the current context. This means that there is no way to simply pass contextual data to the useTracking hook and have it set a new context all on its own. The API I've been batting around in my head would be a familiar one in that it looks similar to the useState hook—it would return a tuple containing the same tracking prop that it currently returns, along with a new context Provider component. Where it diverges a bit from the familiar pattern, is in requiring the developer to wrap the JSX returned by their component in that provider.

It would look something like this:

track decorator/HoC

function MyComponent({ data, tracking }) {
  const handleClick = () => {
    tracking.trackEvent({
      element: 'My button',
      label: 'Click me',
    });
  };

  return (
    <div>
      <button type="button" onClick={handleClick}>Click me</button>
      Some other JSX here
    </div>
  );
}

export default track(props => ({
  module: {
    name: 'My module',
  },
  someData: props.data,
}))(MyComponent);

useTracking hook

function MyComponent({ data }) {
  const [TrackingProvider, tracking] = useTracking({
    module: {
      name: 'My module',
    },
    someData: data,
  });

  const handleClick = () => {
    tracking.trackEvent({
      element: 'My button',
      label: 'Click me',
    });
  };

  return (
    <TrackingProvider>
      <button type="button" onClick={handleClick}>Click me</button>
      Some other JSX here
    </TrackingProvider>
  );
}

export default MyComponent;

I'm looking for feedback re: the ergonomics of this API, particularly whether people would find it to be a dealbreaker for the onus to be on the consumer of the library to wrap their component's JSX in the provider returned by the hook.

Thanks for the exploration, @bgergen ! I'm not crazy about having to add TrackingProviders to the component hierarchy and I'm wondering if there might be a way to avoid that. Thinking out loud, since the tracking doesn't/shouldn't otherwise affect what's rendered it shouldn't be necessary to include it in the actual markup -- I wonder if there's some way we could just collect the tracking objects as we go and then only merge them (in order) when a dispatch needs to happen. The useTracking hook might be able to add the passed in objects (or functions) to some contextual array or map, in order, and then merge them upwards when we need to dispatch.

I'm not sure if that's practically possible, but theoretically I feel like it should be. I'll need to experiment with it a bit, but curious if you think that makes sense? I'm also wondering if there's any prior-art of something similar in the wild?

@tizmagik Yeah, I had the exact same thought when I was trying to work through how to avoid wrapping the markup in a new provider. I considered the possibility of having one provider near the root of the component tree, which would make an updater function available to all consumers. Where my mental model broke down was in trying to envision how a dispatch would know which branch(es) of the map to follow in merging; i.e., how does a given useTracking call get a reference to its parent data object?

I definitely agree both that this feels like it should be possible, and that if we can make it work on a practical level, it would result in a far preferable API to the one I proposed above. I will continue to do further research on this and would also be down to talk it through in further depth.

Somehow you need to be aware of what branch of the tree you're in.

One possible solution is to use the DOM tree as the source of truth. You could use event bubbling to pass tracking data up the DOM tree. To add contexts you could add listeners for the bubbling events that would append their contexts to the event as it bubbles up the DOM. Eventually once the event hits the root (or Provider) it would grab all the appended contexts off the event and add it to the event queue. I hacked together a codesandbox using CustomEvents to bubble tracking data up to the root provider and tack on tracking contexts along the way.

Edit:
Another potential solution is to use the VDOM as the source of truth. In this case you could use a custom ErrorBoundry to define a tracking context and trackEvent would throw the events to be caught by the custom ErrorBoundry. The custom ErrorBoundry would append it's tracking context to the event and throw again until the event hits the root where it would get added to the event queue. From my understanding it would be similar to how React.Suspense works but instead of throwing/catching promises it's throwing/catching tracking events.

Ah! Both brilliant suggestions, @ianduvall ! Thank you.

I think the VDOM/ErrorBoundary approach might actually work out better because for any solution it needs to:

  • Work with the existing HoC API
  • Work without a DOM (react-native)

I'll munch on this some more when I get a chance, but if you have a POC codesandbox for that ErrorBoundary approach I'd love to take a look.

Thanks again!

So I thought about the ErrorBoundary approach a little more and I think it's a dead end for a couple reasons:

  1. Error boundaries do not catch errors inside event handlers - how-about-event-handlers
  2. Existing error boundaries would have to be modified to not render errors for caught tracking events

Another point that doesn't make me feel great about this approach is it breaks React's unidirectional data flow pattern. Making an exception for this doesn't seem worth it.

Ah, good point. Thanks for thinking through that approach! Maybe the event bubbling approach might work then, but will have to think through how to handle it for react-native

I just learned "[Context] Providers can be nested to override values deeper within the tree" from the docs. Similar to @bgergen 's approach of using Context Providers, you could use Providers to create new tracking contexts by merging its ancestor's tracking context with a new context and passing that to the Provider to override the context value in that subtree. I forked my original codesandbox and updated it with this provider value override approach. You provide the root tracking context to TrackingContextRootProvider, and any subtree tracking contexts to TrackingContextProvider, and any local tracking contexts to useTracking. The result is all ancestor tracking contexts are merged with the data passed to trackEvent.

@tizmagik I know you mentioned hoping to avoid adding Providers to component tree but I can't think of a better way to differentiate what branch of the VDOM you're in.

I know you mentioned hoping to avoid adding Providers to component tree but I can't think of a better way to differentiate what branch of the VDOM you're in.

Yea, I think that's the problem, because otherwise it's not much different than just using the HoC API. I wonder if it would even be worth introducing this nested context provider API vs just recommending folks stick to the HoC API. I'm not sure the added complexity to the API surface is worth it. 🤔

Feels like we could really use something like this: facebook/react#4595

I think I'm warming up to the idea of returning a component from the useTracking() hook. The API could mimic what's currently supported by the HoC, but transparently return a Provider as long as it's the first tracked node in the tree (e.g. there is no tracking context available).

It feels like something around these lines should be possible:

test('will deep-merge tracking data', () => {
  const mockDispatchTrackingEvent = jest.fn();
  const testData1 = { key: { x: 1, y: 1 } };
  const testData2 = { key: { x: 2, z: 2 }, page: 'TestDeepMerge' };

  const TestData1 = ({ children }) => {
    const { Track } = useTracking();
    return (
      <Track data={testData1} dispatch={mockDispatchTrackingEvent}>
        {children}
      </Track>
    );
  };

  const TestData3 = () => {
    const { Track } = useTracking();

    return (
      <Track data={{ key: { x: 3, y: 3 } }} dispatchOnMount>
        <div />
      </Track>
    );
  };

  const TestData2 = () => {
    const { Track } = useTracking();

    return (
      <Track data={testData2}>
        <TestData3 />
      </Track>
    );
  };

  mount(
    <TestData1>
      <TestData2 />
    </TestData1>
  );

  expect(mockDispatchTrackingEvent).toHaveBeenCalledWith({
    page: 'TestDeepMerge',
    key: { x: 3, y: 3, z: 2 },
  });
});

Thoughts?

I think I like this. I ran into one of the nuisances of not having something like this available recently, which is the need to swap out the hook for the HoC whenever you want a component to add contextual data where it didn't do so before. I want to make sure I understand what you mean by:

as long as it's the first tracked node in the tree (e.g. there is no tracking context available)

Does this just mean that Track would need to wrap the rest of the markup tree returned by a given component?

Does this just mean that Track would need to wrap the rest of the markup tree returned by a given component?

Yes, in fact, I think this is the only way to enable adding contextual tracking to the tree via a Hooks-only API.

I started some work on this in PR #165 which basically takes all of our e2e tests and re-writes them to only use the useTracking() API. The tests are failing now, but it's a TDD approach we can code against.

After rewriting a few of the tests I actually ended up settling on what you originally suggested here @bgergen but instead of calling it <TrackingProvider /> I called it <Track />, which felt a little cleaner to me. I went back and forth on whether or not the <Track /> component should take in props (e.g. <Track data={trackingData} dispatchOnMount />) but settled on keeping all tracking object inputs and configuration options to be passed in to the hook: useTracking(data, config).

Curious to get everyone's feedback on how that API feels? If it looks good, and anyone's interested, feel free to code against those tests for a working implementation.

Closing this issue with the release of v8.1.0