We recently started using SpeedCurve to track Unsplash’s performance. To get its “Real User Monitoring” (a.k.a. “LUX”) working with our app, we needed to do some extra work to handle client-side navigations. Currently, the documentation on the matter is fairly sparse, so here’s an (elaborate) guide on how to do it using Redux.
NOTE: except for a minor React-related step, this guide is largely framework-agnostic so long as you have Redux.
Here’s the sequence of events we’re working with (as per SpeedCurve’s docs):
- user performs an in-app navigation, causing the URL to change
- we call
- data is fetched for the new route (optional)
- new route is rendered
- we call
We’re gonna opt to use redux-observable, as that will make it easy to listen to streams of actions and perform actions accordingly.
Most of our work will be inside a
redux-observable “epic”. Epics have the following type signature:
type Epic = (action$: Observable<Action>, state$: Observable<State>) => Observable<Action>
This means that they receive a stream of redux actions and a stream of redux state, and output a stream of redux actions. For more on that, read their docs.
NOTE: throughout this guide, I’ll briefly explain what each Observable operator does. However, if you are new to Observables, you’ll likely benefit from having these resources open for reference:
We must start by identifying when the URL changes, indicating that the user navigated to a new route.
In our specific case, we use react-router with connected-react-router, which lets us read the
react-router state from Redux, making it easy to identify URL changes. (If you don’t use these libraries, you’ll still be fine as long as you store your pathname in Redux, or have some sort of Observable emitting pathnames)
- By using
state$, we are reading the stream of redux state. Every time the state changes, a new value is emitted which will be manipulated by all the operators inside the
filterbehave on observables the way their array counterparts behave on arrays.
We then end up with
pathname$: an observable that emits all valid
pathname values. Finally,
distinctUntilChanged() will only emit a new value when it is different from the previous one. This is necessary because
location$ might emit new values even if
location.pathname hasn’t changed.
Now we have
newPathname$, a stream of differing pathnames, each one representing a new client-side navigation. We can then call
LUX.init after each one:
tap is an operator you use when you want to perform side-effects.)
Data-fetching & rendering
Now for the tricky part. Our routes can be grouped into two:
- those that require data-fetching before rendering (“dynamic” routes)
- those that don’t need data and can render immediately (“static” routes)
We need to model these scenarios in Redux. We will do so using three actions:
STATIC_ROUTE_COMPONENT_UPDATED. (This is the only React-specific bit)
PS: For convenience, we wrote a
trackRouteUpdates HOC that dispatches the
componentDidUpdate actions, and wrapped all of our route components with it. We highly suggest you do the same to avoid repeating this logic in every component.
Now that we have our actions, let’s go back to our epic and use them:
The actions inside
componentDidUpdate are bound to be dispatched multiple times, but we are only concerned with the first one.
takeOneAction will listen for a specific action, and complete the observable after the action is found once in the
action$ stream (when an observable is “completed”, it stops emitting values).
Let’s put these three action observables together:
For dynamic routes, we must wait for data to be fetched and then for the route to update, in that order. This is what
concat does, while
takeLast(1) grabs the last value emitted by the concatenated stream. It’s a handy way of knowing that both inner observables have completed.
Now we can chain this with the
newPathname$ observable we wrote earlier:
(Notice how the above code perfectly mirrors the sequence of events outlined at the start of the article).
mergeMap will map your
newPathname$ observable to the inner action observable. The
mergeMapTo variant is used when you don’t need the values of the previous observable, which is the case here (the
newPathname values aren’t needed in
Handling pending LUX tracking
We’re done!…well, not really. One caveat is the case where a user quickly navigates from page A to B to C before page B finishes rendering. We will end up calling
LUX.init twice in a row: once for page B and again for page C.
That cannot happen: each
LUX.init() must be matched with
LUX.send(). We decided to fix that by making sure
LUX.send is always called before making a new
We don’t particularly love this solution (this is one of only a couple of mutable variables in our entire codebase). However, solving this in an immutable “RxJS-way” came out to be so complicated that we were ultimately satisfied with the above. If you have a cleaner way to do it, please let us know!
Final result ✨
We’re finally done. 🤯
If you like what you read and think you’d enjoy working with us, we’re hiring! https://unsplash.com/hiring/job-posts/4/react-engineer