The New Training Log
By: Logan Medina
Tags: React, Nextjs, Frontend, Strava, D3js

The Training Log

The new Training Log.

The new Training Log is built in React utilizing the NextJS framework. NextJS offers library-specific benefits like plugins for internationalization and bundle analysis for server-side rendering. While it’s not a requirement for every frontend app to use NextJS, and there are pages where it might not be necessary to use, the framework has provided enough benefits and stability that we anticipate nearly all of our micro frontends will use it. Having that pattern established allowed us to collaborate more closely with another team that was simultaneously developing the new Routes app, which also utilized NextJS.

We strategically divided the work into three main themes: building the components that would make up the UI, creating a global navbar that could be used across monorepo apps, and managing the massive amounts of data that our athletes may be viewing.

Building a Functional UI

Much of our existing React code was written using class-based components and Redux because it was written before and immediately following the release of React v16.8, which introduced React Hooks. Since that time, Hooks have become more prominent and we decided early on that we would try to only use functional components utilizing React Hooks to manage our state whenever possible. We feel that this decision is more in line with official statements surrounding the recommended use of React and will be easier to understand, test, and reason about in the future, even though there was a slight learning curve to understand the best use of writing functional React.

Some of the main components of the Training Log App.

Despite clear documentation from React, React Hooks were especially tricky to implement with our initial component organization. Many of our component functions were designed to be very small and use JavaScript-only helpers to format and organize data. By hard-coding data within our helper functions, we were effectively setting state within them, which worked fine initially, but became an issue as we started using Hooks to conditionally format data or localize strings.

// DO NOT DO THIS const helperWithContext = (value) => {

const context = useContext(Context)

context.doSomething(value)

...

}



const Component = ({ foo }) => {

return(

<>

...

{ foo ? helperWithContext(foo) : -- }

...

</>

)

}

Hooks are designed to manage state at the top level component. React expects them to be called the same number of times and in the same order between renders. By using hooks within conditionally called helper functions, we ended up varying the number of times the hook was called and altering the state of our component in ways that React couldn’t understand. There were two solutions, one was to call the hook in the top level component and then pass it into the helper function or, alternatively, we could call the hook in the top level and move our helper within the same scope, nesting it within our main component.

// DO THIS INSTEAD const helperPassedContext = (value, context) => {

context.doSomething(value)

...

}



const Component = ({ foo, bar }) => {

const context = useContext(SomeContext) return(

<>

...

{ foo ? helperPassedContext(bar, context) : -- }

...

</>

)

}

As a consequence of hooks, our components ended up growing larger and encompassing more logic, while our helper functions ended up becoming either nested functions within our component or pure JavaScript helpers. Overall, we found the usage of hooks to be more of a mental shift than anything particularly difficult to use and it also promoted a clean separation of React functions from regular JavaScript functions.

D3.js is a library that allows for data to be bound to the DOM and that can then transform the DOM from manipulating that data. This poses a challenge to React, which we feel should be the owner of the DOM. In order to resolve that conflict, we avoided using the select, append, enter or exit methods from D3, and instead only use `d3-scale` to provide the logarithmic scale with which the bubbles are sized. The rest of the SVG including fill, patterns, labeling, or whether it should even appear on the page, is all controlled by React. That way, we avoid any tug of war between D3 and React for control of the DOM.

In the end, the Training Log consists of 15 custom components that are unique to the app and it utilizes a number of components from our internal UI library.

Composites

The global navbar composite, wrapping the Training Log, with notification menu open.

The global navbar provided its own unique difficulties. As mentioned before, the monorepo contains a packages directory that is shared between apps and can also be exported as a library to other repos. But what happens if a component has a dependency on a framework like NextJS, a framework that may not be available in every place we might import it? We decided that there was a class of components, which we called a composite, that was meant to be shared between apps, but only those apps within the monorepo that utilize NextJS.

Directory structure of the monorepo with composites.

The navbar was our first composite. It is a functional component that accepts a set of props, manages state using React Hooks, and implements a few behaviors around notifications. It pushes the boundaries of a base component by requesting its own initial, server-side props using NextJS. To help integrate this new composite within an app, it exports a higher-order component, taking any other app as an argument, then merges that app’s call to get initial props with its own, ultimately returning a React fragment that contains the global navbar and the containing app.

function withGlobalHeader(WrappedPage) {

const WithGlobalHeader = ({ ...props }) => {

return (

<>

<GlobalHeader {...props} />

<WrappedPage {...props} />

</>

);

}; WithGlobalHeader.getInitialProps = async (context) => {

const componentInitialProps =

await WrappedPage.getInitialProps(context);



return {...componentInitialProps};

}; return WithGlobalHeader;

} export default withGlobalHeader;

Any app that needs the global navbar can easily be wrapped in that higher-order component without needing to know anything about the inner-workings of the navbar itself.

Data Management

From the first conversations around our training log implementation we knew that we would eventually run into performance issues with the large number of activities that many of our athletes have completed. Just rendering thousands of activities could be a performance headache, but we also wanted our athletes to have the ability to scroll infinitely into their training history or use the timeline functionality to jump to a specific date in the past.

Initial tests with some of our power-user employees showed that requesting all of an athlete’s activities on page load took longer than we were willing to allow. The easiest solution was to load the first few months of an athlete’s activity history on page load and populate the rest with placeholder rows. Indexing activity data by timestamp allows us an efficient lookup of a week’s row, regardless of whether the data has been loaded or not.

In order to track each week’s rows we use the Intersection Observer API. On first render, we attach a ref to each entry. We then target each entry ref to be observed by our Intersection Observer. As entries enter and exit the viewport, we can use the events triggered by the Intersection Observer callback to set the currently viewed entry timestamp. When clicking on the timeline, we can then scroll the page down or up until we are sure that our desired entry is in the viewport. The reverse also occurs, scrolling up and down triggers the callback, which can be used to set the currently viewed year and month on the timeline.

Unloaded weekly entries showing the loading state.

When scrolling, if we have the week’s data loaded, it’s easy to reference it and render it. Otherwise, if a placeholder row enters the viewport, we reference its timestamp, make a request for that week’s activities, and replace the placeholder data with the actual data. This allows us to infinitely scroll without requiring that all the data be loaded in-between. Additionally, having the data indexed by timestamp also allows us to use the timeline to jump forward or backward in time. When clicking on the timeline, if the data hasn’t previously been loaded, we populate it, then jump to that row in the calendar view.