Strava API activity sync with Axios, React, Redux & Saga - part 2

With the auth and state setup from part 1 of "Strava API activity sync with Axios, React, Redux & Saga" we can now progress to getting the activities and displaying them in a table.

Overview

Now we have tokens we can start to get the data from Strava. The steps we will take cover the following:

  • Adding moment to handle date based checks
  • Create constants for getting activities
  • Create the reducers to handle the results of getting the activities
  • Using Sagas to retrieve the activities from Strava's API, including;
    • Auth - Checking the token is valid and if not getting a new one
    • Activities - Retrieving multiple pages of data
  • Display the activity data on the landing page:
    • Creating a Table component.
    • Using it on the Homepage
  • Bonus: Loading states!

Handling dates

We're going to be doing some date manipulations and checks so let's add a library to help us... Moment for some date "maths".

yarn add moment

Note: At this point in time you can, and probably should, use native JavaScript date objects. However, for the sake of simplicity I am using moment.

Create the Activities life cycle

Constants

Firstly we need to create the constants we'll be using to handle the activities. We create one to handle the start of the process, and need a second one which will be used every time we finish loading a page so that we can add the data to our Redux store.

// file: src/redux/constants/activities.js
export const ACTIVITIES_SYNC_START = "ACTIVITIES_SYNC_START";
export const ACTIVITIES_PAGE_LOADING_COMPLETE = "ACTIVITIES_PAGE_LOADING_COMPLETE";

Actions

We only have one action. It will be linked to a button that triggers our syncing process. We import our constant ACTIVITIES_SYNC_START and the use it in the action.

// file: src/redux/constants/actions/activities.js

import { ACTIVITIES_SYNC_START } from "../constants/activities";

export const getActivities = () => ({
    type: ACTIVITIES_SYNC_START
});

Reducers

Because the payload we recieve does not include all of the activities in one page we need to merge the existing activity state with the data from each page. We use unionBy to merge the state based on a property of each activity so that we only keep the unique ones and do not end up with duplicates. We use the activity id as this is unique across all Strava activities. One side effect of this is that if you update an activity and resync the new changes will never be updated because the id already exists in our state. Going forward you could write extra actions/reducers/sagas to remedy this.

import unionBy from "lodash/unionBy";

import { ACTIVITIES_PAGE_LOADING_COMPLETE} from "../constants/activities";

const initialState = {
    activities: [] // we start with no activities
};

export default function (state = initialState, action) {
    switch (action.type) {
        case ACTIVITIES_PAGE_LOADING_COMPLETE:
            // unionBy will merge 2 sets of data by ap property on the objects - our case id
            // if we reimport data at a later date then duplicate ids are ignored!
            const activities = unionBy(state.activities, action.payload, "id");
            return { ...state, activities };
        default:
            return state;
    }
};

Then update the src/reducers/index.js to import and use the activities reducer we just created:

import { combineReducers } from "redux";
import { connectRouter } from "connected-react-router";
import auth from "./auth";
import activities from "./activities"; // import this

export default (history) =>
    combineReducers({
        router: connectRouter(history),
        auth,
        activities // and use it!
    });

Reboot your app and you should have an empty redux state for activities:

activities : {
    activities: []
}

Why do we have activities nested in activities. It's useful to keep the loading state within the outer activities so that we can show loading spinners and display error states. In our tutorial, all we know is that there is a list of activities and have no idea about what currently being updated by the app. This will be covered in a future tutorial.

Updating the token

After a few hours your Strava token will be invalid as it expires. We will use the refresh_token to get a new access token. Starting in validateAuthTokens() we check if the current expires timestamp is still in the future and if it is not, then we know that we need a new set of tokens.

updateRefreshToken calls the token endpoint using our client_id, client_secret, the refresh_token which will return your new tokens. If you hit this endpoint before the tokens expire then it just returns the current ones. Since you have to wait approx 6 hours for tokens to expire you can manually edit the expiresAt in the dev tools > application > localstorage and set it to 0 - This will then think the token has expired!

In the real world™️, the tokenClient should be on a backend (eg Netlify function or AWS lambda) where the client_secret is not accessible to the users browser. The token client would be replaced by a backendClient or secretClient or similar which would be passed only the refreshToken.

// file: src/redux/sagas/auth.js

//... the existing auth.js js file

const updateRefreshToken = () => {
    // Get the refresh token from localstorage
    const refreshToken = localStorage.getItem("refreshToken");
    
    // using the token client we then post to the token endpoint with
    // our client_id, client_secret, the refresh_token
    // In a realworld app we would pass our refresh_token to a backend where we can keep the client_secret a secret!
    return tokenClient({
        url: "/token",
        method: "post",
        params: {
            client_id: clientID,
            client_secret: clientSecret,
            refresh_token: refreshToken,
            grant_type: "refresh_token"
        }
    })
        .then((response) => {
            // return the new tokens and timestamps to be handled
            return response.data;
        })
        .catch((error) => {
            throw new Error(error);
        });
};

const tokenIsValid = () => {
    // get the existing token from localstorage
    const expiresAt = localStorage.getItem("expiresAt");
    // create a timestamp for now down to the second
    const now = Math.floor(Date.now() / 1000);
    // Check if the expires timestamp is less than now - meaning it's in the past and has expired
    return now < expiresAt;
};

export function* validateAuthTokens() {
    // 1. call the function to check if the token is valid
    const validToken = yield call(tokenIsValid);
    
    // 2. If the token is invalid then start the process to get a new one
    if (!validToken) {
        // call the function to get new refresh tokens
        const data= yield call(updateRefreshToken);
        // put the action to handle the token updates (which stores them in localstorage)
        yield put(updateAuthTokens(data));
    }
}

Activities Saga

Now that we have a way to update the token and keep our client valid we can create our activities saga. Once triggered, this will:

  • Check/update the auth tokens (with the tokenClient we just setup)
  • Starting at an epoch and page 1 it will step through all pages of activities
    • Use the apiClient to retrieve each page until it reaches a page with an empty list, meaning we have reached the end.
import get from "lodash/get";
import moment from "moment";
import { call, put, select, takeLeading } from "redux-saga/effects";

import { apiClient } from "../../api";
import { validateAuthTokens} from "./auth";
import {ACTIVITIES_PAGE_LOADING_COMPLETE, ACTIVITIES_SYNC_START} from "../constants/activities";

const getActivity = (epoch, page = 1) => {
    // Get the data from Strava starting from `epoch` and with the page number of `page`
    return apiClient({
        url: `/athlete/activities?per_page=30&after=${epoch}&page=${page}`,
        method: "get"
    })
        .then((response) => {
            // return the data
            return response;
        })
        .catch((err) => {
            throw err;
        });
};

const getLastActivityTimestamp = (state) => {
    // The epoch is dependant on the last activity date if we have one
    // This is so that we only get activities that we don't already have 
    const lastActivity = state.activities.activities.slice(-1)[0];
    const lastDate = get(lastActivity, "start_date");

    if (lastDate) {
        // return the unix timestamp of the last date
        return moment(lastDate).unix();
    }
    
    // Manually set a year (either in the env or default to 2019)
    // I have set a recent one otherwize it's a big first sync
    // And if there are a LOT of activities then you may run out of local storage!
    const initialYear = Number(process.env.REACT_APP_INITIALYEAR || 2019);
    
    // Create a timestamp for this year
    return moment().year(initialYear).startOf("year").unix();
};

function* updateAthleteActivity() {
    // 1. Check the tokens are valid and update as needed
    yield call(validateAuthTokens);
    
    // 2. set the page and epoch - The epoch depends on the last activity date
    let page = 1;
    const epoch = yield select(getLastActivityTimestamp);

    try {
        while (true) {
            // Loop through pages until we manually break the cycle
            // Start the process of getting a page from Strava
            const { data } = yield call(getActivity, epoch, page);

            // Page has no activities - Last page reached
            if (!data.length) {
                // If the result has an empty array (no activities) then end the sync - we must have them all!
                break;
            } else {
                // put the data into redux and let the activity reducer merge it
                yield put({ type: ACTIVITIES_PAGE_LOADING_COMPLETE, payload: data });
  
                // Next slide please!
                page += 1;
            }
        }
    } catch (e) {
        yield console.log(e)
    }

}

export function* watchUpdateAthleteActivitiesAsync() {
    yield takeLeading(ACTIVITIES_SYNC_START, updateAthleteActivity);
}

Import the new saga to the root saga so we can start to listen for ACTIVITIES_SYNC_START.

// file src/redux/sagas/index.js
import { all } from "redux-saga/effects";

import { beginStravaAuthAsync, validateStravaTokenAsync } from "./auth";
import { watchUpdateAthleteActivitiesAsync} from "./activities"; // Add

// single entry point to start all Sagas at once
export default function* rootSaga() {
    yield all([ 
                beginStravaAuthAsync(), 
                validateStravaTokenAsync(), 
                watchUpdateAthleteActivitiesAsync() // Add
            ]);
}

Create the "front end"

Display activities in a table

Create a table component. This takes an array of activities and renders the title.

const Table = ({activities}) => {

    return (
        <table>
            <thead>
                <tr>
                    <th>Name</th>
                </tr>
            </thead>
            <tbody>
                {activities.map(activity => (
                    <tr key={activity.id}>
                        <td>{activity.name}</td>
                    </tr>
                ))}
            </tbody>
        </table>
    )
}

export default Table;

There is a lot more that can be displayed if you wish!

{
    resource_state:2
    name:"Night Ride"
    distance:20800.4
    moving_time:4649
    elapsed_time:4649
    total_elevation_gain:96
    type:"Ride"
    workout_type:null
    id:398412246
    external_id:"Ride390811004.gpx"
    upload_id:447540868
    start_date:"2010-01-01T00:00:00Z"
    start_date_local:"2010-01-01T00:00:00Z"
    timezone:"(GMT+00:00) Europe/London"
    utc_offset:0
    location_city:"Malpas"
    location_state:null
    location_country:"United Kingdom"
    start_latitude:55.06
    start_longitude:-1.68
    achievement_count:0
    kudos_count:0
    comment_count:0
    athlete_count:1
    photo_count:0
    trainer:false
    commute:false
    manual:false
    private:false
    visibility:"everyone"
    flagged:false
    gear_id:"b1131808"
    from_accepted_tag:false
    upload_id_str:"447540868"
    average_speed:4.474
    max_speed:5.4
    average_watts:106.7
    kilojoules:496
    device_watts:false
    has_heartrate:false
    heartrate_opt_out:false
    display_hide_heartrate_option:false
}

The Home page

We need to import our syncActivities action and our Table into the HomePage.

import { useSelector, useDispatch } from "react-redux";

import {beginStravaAuthentication} from "../redux/actions/auth";
import {syncActivities} from "../redux/actions/activities"; // New
import Table from "../components/table"; // New

const HomePage = () => {
    const auth = useSelector((state) => state.auth);
    const activities = useSelector((state) => state.activities.activities); // New: Get the activities from the state

    const dispatch = useDispatch();

    return (
        <div>
            <h1>Home</h1>

            { auth.isAuthenticated ? (
                <>
                    <h2>Logged in</h2>
                    {/* Add a button to  dispatch our syncActivities action  */}
                    <button type="button" onClick={() => dispatch(syncActivities())}>Sync activities</button>
                    
                    {/* Display the activities in the table  */}
                    <Table activities={activities} />
                </>
            ): (
                // add the dispatch to the button onClick
                <button type="button" onClick={() => dispatch(beginStravaAuthentication())}>Authorise on Strava</button>
            )}
        </div>
    )
}

export default HomePage;

With this added you can now sync your activities.

Table rendered

When you do this you will notice that the list slowly starts to grow as it syncs and the only way you know that it is complete is that it stops growing after a while (and you can see your latest activities!).

Some extra bonuses

Save the activities

Now that we have activities it would be nice if they are saved on a refresh! Open up your configureStore and update the saveState to:

saveState({
    auth: store.getState().auth,
    activities: store.getState().activities // Additional state to save
});

Show a loading state

To add a loading state to the activities we need to add a loading state. First add a new constant to handle this:

// file: src/redux/constants/activities.js
export const ACTIVITIES_SYNC_STATE = "ACTIVITIES_SYNC_STATE";
// ... the rest

Then add an acompanying action. This will take a payload as an argument that we can use to update the state. In our case we will be passing it something like {loading: true}.

// file: src/redux/actions/activities.js
export const setActivitiesState = (data) => ({
    type: ACTIVITIES_SYNC_STATE,
    payload: data
});

We now need to update our reducers to handle this new action:

// file: src/redux/reducers/activities.js
import unionBy from "lodash/unionBy";

import { ACTIVITIES_PAGE_LOADING_COMPLETE, ACTIVITIES_SYNC_STATE } from "../constants/activities"; // add ACTIVITIES_SYNC_STATE

const initialState = {
    activities: [],
    loading: false // add the default loading state (false)
};

export default function (state = initialState, action) {
    switch (action.type) {
        // Add the action here
        case ACTIVITIES_SYNC_STATE: 
            // merge our payload in
            return { ...state, ...action.payload}

        case ACTIVITIES_PAGE_LOADING_COMPLETE:
            const activities = unionBy(state.activities, action.payload, "id");
            return { ...state, activities };

        default:
            return state;
    }
};

Our saga must now be updated to put the actions into redux so we can change the loading state.

// file: src/redux/sagas/activities.js
// ... other imports

import { setActivitiesState} from "../actions/activities"; // import the state new action


// ... the rest

function* updateAthleteActivity() {
    // Add the put for ACTIVITIES_SYNC_STATE and we pass a payload of loading: true
    yield put(setActivitiesState({loading: true})) 

    yield call(validateAuthTokens);

    let page = 1;
    const epoch = yield select(getLastActivityTimestamp);
    try {
        while (true) {
            const { data } = yield call(getActivity, epoch, page);

            // Page has no activities - Last page reached
            if (!data.length) {
                break;
            } else {
                yield put({ type: ACTIVITIES_PAGE_LOADING_COMPLETE, payload: data });
                page += 1;
            }
        }
    } catch (e) {
        yield console.log(e)
    }
    
    // I like to add a small delay to the final action just so that the loading state is visible for a short while
    // so that users *feel* like something has happened and don't miss it!
    yield delay(1000)
    // Add the put for ACTIVITIES_SYNC_STATE and we pass a payload of loading: false
    // This sets it back to the non loading state and the table should show with everything in!
    yield put(setActivitiesState({loading: false}))
}

// ... the rest

On the HomePage update the activities state to it returns the full activity object so we can hook into the loading state:

    // Old
    // const activities = useSelector((state) => state.activities.activities);

    // New
    const activities = useSelector((state) => state.activities);
    // Old
    // <Table activities={activities} />

    // New
    <Table data={activities} />

Finally, update the Table component to take the new data prop, then extract the loading state and activity data, and update the render to display a loading state.

const Table = ({ data }) => {

    const { loading, activities } = data;

    return (
        <>
            {loading ? (
                <div>
                    <h3>Loading...</h3>
                </div>
            ) : (
                <table>
                    <thead>
                    <tr>
                        <th>Name</th>
                    </tr>
                    </thead>
                    <tbody>
                    {activities.map(activity => (
                        <tr>
                            <td>{activity.name}</td>
                        </tr>
                    ))}
                    </tbody>
                </table>
            )}
        </>
    )
}

export default Table;

Next

And that's it! In the final part of the Strava API app (coming soon) we will move our client_secret secret to a Netlify function to keep things safer.