Logging analytics events in a testable way with React and Redux
One of my main responsibilites at TotallyMoney was to take care of the in-house analytics/event logging framework. Like lots of companies, understanding what users do and how they interact with a product is to get good insight on. In this regard, people reinvent the logging wheel with various krimskrams attached, me being no exception. What I want to show in this post is how to integrate an event logging framework into a React/Redux application in a way that's scaleable and testable. Unit testable logging is important when the rest of the business relies heavily on the events and the data in them like many companies do.
tl;dr: pass your logger to React's context
to make logging from deeply nested components much
easier.
In this article, I'm assuming you've got an existing React/Redux application, and want to integrate some kind of event logging library into it.
The logging library
Let's make a fake logging library that looks like this:
// lib/logger.js
;
This could be anything, even something like Google Analytics or Firebase. How you log events and to where doesn't really matter as the logging code will be contained inside an action creator.
Redux – Action creator
Action creators in Redux can have side effects like making API calls or, in our case, logging something. We'll create a simple action creator like this:
// actions/logger.js
❶ Define a default empty object, so we don't have to worry about handling undefined or null values.
❷ Returning something from this action creator isn't strictly necessary for just logging events using the logger, but if you want to record those events in your Redux store, you need to return an action for the reducers to respond to.
We now have an action creator that, when called, will log an event over our pretend websocket-based logging library. Let's hook it up to a button in React.
Integration with React (the bad way)
The simplest and perhaps obvious way is to pass down an event handler to the elment you want to log
an event from. React encourages this pattern for handling other events, so why not logs? When an
event is fired, your code would handle that in the top level Redux-connected page component. We
could pepper Redux' connect()
through our component tree, but that makes testing harder, which
is the opposite of what we want to do. It also tightly couples our app to Redux instead of dealing
with plain old Javascript objects.
This following example is for a login page where we want to log an event when the submit button is
clicked. Here, logging that event isn't too bad because we already have a click handler for the
submit button. But what if we had a different component with no other props? In this case, we'd have
to write a handleClick
function in the top level component, and pass an onClick
prop all the way
through the component tree. This is noisy, boilerplate-y and error prone. In the next section, I'll
show a better way of doing this.
// pages/Login.jsx
Integration with React (the good better way)
A better way to handle this logging is to use React's context functionality. Context should be used sparingly and its caveats like coupling global state, but in this case it can help us create a far cleaner logging implementation. First, we need to make a context provider that will make the logger available to all child components in our app:
// components/LoggerProvider.jsx
Here we:
❶ Define childContextTypes
to allow child components to ask for logEvent
using their
contextTypes
property.
❷ Make the definition of logEvent
. In this case, it's a function that will be called like
context.logEvent('foo', { bar: 'true '})
which matches the Redux action signature written earlier.
❸ Don't need any state from the store, so we can just return an empty object.
This component won't work without a Redux store. We can add that by wrapping it in a Provider
component from react-redux
. An example application might
look like this:
// index.jsx
;
;
;
Make sure LoggerProvider
is a child of Provider
. If it's the other way round, you won't be able
to access the store. Now let's modify the <SubmitButton />
component we want to log events from to
give it access to context
:
// components/LoginButton.jsx
❷SubmitButton.contextTypes =
❶ Stateless functional components access context
through the second argument of the function
declaration.
❷ React requires you to explicitly mark which pieces of the context you want using
Component.contextTypes
. In this case, just logEvent
which is a function. If you don't add this,
logEvent
will be undefined!
Assuming your app uses LoggerProvider
somewhere near the top of its component tree, you should now
be able to call context.logEvent
which will dispatch a Redux action with the functionality
provided by LoggerProvider
. This should work wherever this component is in the component tree.
Testing with Mocha and Enzyme
Easy testability is a very useful side-effect of using the context method. You could add spies and
assertions around a Redux store, but this then requires you to store logged events in your store
which you might not want to do (e.g. with third party logging libraries). It also leads to a much
more complex test harness involving Redux and a <Provider>
.
I'm using Mocha, Enzyme, Chai and Sinon for this article, but this technique should be applicable to most test environments.
Using context
in a component comes with the caveat that a context obejct must be present in Enzyme
unit tests. This can get a little frustrating and introduces some obtuse errors if one is not aware
of the context
requirement. However I think the benefits outweigh these extra steps. Context
can easily be passed into components,
making it extremely simple to pass spies and catch logging calls.
It's not strictly necessary, but I'm going to use
sinon-chai
to make error messages a bit clearer.
Here's our test:
// test/components/SubmitButton.spec.js
;
"<SubmitButton />",;
We've got two tests here:
-
Calls the onClick event when clicked
. Checks to make sureonSubmit()
is called when the button is clicked. This isn't relevant to event log testing, it just shows another Enzyme test being used. -
Logs an event when clicked
. Checks thatlogEvent
was called, and with the correct arguments..calledWith('loginSubmit', { foo: 'bar', baz: 'quux' })
should be replaced with whatever shape of event you're logging. In this example, it's hardcoded to('loginSubmit', { foo: 'bar', baz: 'quux' })
.
If you've set everything up right, you should see something like this:
<SubmitButton />
✓ Calls the onClick event when clicked
✓ Logs an event when clicked
2 passing (2ms
Hooray!
Conclusion
React's context
can be pretty powerful when used sparingly. Here, I've presented an approach to
event collection that makes logging from deeply nested React nodes easier, simpler and more
testable. However, I think it's important to take multiple approaches to event logging depending on
where the event needs to be fired from. If you can do it without using context
, do that! You lose
some ability to test using a spy in your tests, but it makes component testing simpler with less
configuration by not requiring a fake context. If you can log an event inside another action
creator, that's the best way to go if possible. If not, using a context provider leads to a more
scalable approach for larger apps with deeply nested component trees.