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

Dec 16, 2020

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 checksCreate constants for getting activitiesCreate the reducers to handle the results of getting the activitiesUsing Sagas to retrieve the activities from Strava's API, including;Auth - Checking the token is valid and if not getting a new oneActivities - Retrieving multiple pages of dataDisplay the activity data on the landing page:Creating a Table component.Using it on the HomepageBonus: 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".

1yarn 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 cycleConstants

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.

1// file: src/redux/constants/activities.js 2export const ACTIVITIES_SYNC_START = "ACTIVITIES_SYNC_START"; 3export 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.

1// file: src/redux/constants/actions/activities.js 2import { ACTIVITIES_SYNC_START } from "../constants/activities"; 3 4export 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.

1import unionBy from "lodash/unionBy"; 2 3import { ACTIVITIES_PAGE_LOADING_COMPLETE} from "../constants/activities"; 4const initialState = { 5 activities: [] // we start with no activities 6}; 7 8export default function (state = initialState, action) { 9 switch (action.type) { 10 case ACTIVITIES_PAGE_LOADING_COMPLETE: 11 // unionBy will merge 2 sets of data by ap property on the objects - our case id 12 // if we reimport data at a later date then duplicate ids are ignored! 13 const activities = unionBy(state.activities, action.payload, "id"); 14 return { ...state, activities }; 15 16 default: 17 return state; 18 19 } 20};

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

1import { 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:

1activities : { 2 activities: [] 3}

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.

1// file: src/redux/sagas/auth.js 2 3//... the existing auth.js js file 4 5const updateRefreshToken = () => { 6 // Get the refresh token from localstorage 7 const refreshToken = localStorage.getItem("refreshToken"); 8 9 // using the token client we then post to the token endpoint with 10 // our client_id, client_secret, the refresh_token 11 // In a realworld app we would pass our refresh_token to a backend where we can keep the client_secret a secret! 12 return tokenClient({ 13 url: "/token", 14 method: "post", 15 params: { 16 client_id: clientID, 17 client_secret: clientSecret, 18 refresh_token: refreshToken, 19 grant_type: "refresh_token" 20 } 21 }) 22 .then((response) => { 23 // return the new tokens and timestamps to be handled 24 return response.data; 25 }) 26 .catch((error) => { 27 throw new Error(error); 28 }); 29}; 30 31const tokenIsValid = () => { 32 // get the existing token from localstorage 33 const expiresAt = localStorage.getItem("expiresAt"); 34 // create a timestamp for now down to the second 35 const now = Math.floor(Date.now() / 1000); 36 // Check if the expires timestamp is less than now - meaning it's in the past and has expired 37 return now < expiresAt; 38}; 39 40export function* validateAuthTokens() { 41 // 1. call the function to check if the token is valid 42 const validToken = yield call(tokenIsValid); 43 44 // 2. If the token is invalid then start the process to get a new one 45 if (!validToken) { 46 // call the function to get new refresh tokens 47 const data= yield call(updateRefreshToken); 48 // put the action to handle the token updates (which stores them in localstorage) 49 yield put(updateAuthTokens(data)); 50 } 51}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 activitiesUse the apiClient to retrieve each page until it reaches a page with an empty list, meaning we have reached the end.1import get from "lodash/get"; 2import moment from "moment"; 3import { call, put, select, takeLeading } from "redux-saga/effects"; 4 5import { apiClient } from "../../api"; 6import { validateAuthTokens} from "./auth"; 7import {ACTIVITIES_PAGE_LOADING_COMPLETE, ACTIVITIES_SYNC_START} from "../constants/activities"; 8 9const getActivity = (epoch, page = 1) => { 10 // Get the data from Strava starting from `epoch` and with the page number of `page` 11 return apiClient({ 12 url: `/athlete/activities?per_page=30&after=${epoch}&page=${page}`, 13 method: "get" 14 }) 15 .then((response) => { 16 // return the data 17 return response; 18 }) 19 .catch((err) => { 20 throw err; 21 }); 22}; 23 24const getLastActivityTimestamp = (state) => { 25 // The epoch is dependant on the last activity date if we have one 26 // This is so that we only get activities that we don't already have 27 const lastActivity = state.activities.activities.slice(-1)[0]; 28 const lastDate = get(lastActivity, "start_date"); 29 30 if (lastDate) { 31 // return the unix timestamp of the last date 32 return moment(lastDate).unix(); 33 } 34 35 // Manually set a year (either in the env or default to 2019) 36 // I have set a recent one otherwize it's a big first sync 37 // And if there are a LOT of activities then you may run out of local storage! 38 const initialYear = Number(process.env.REACT_APP_INITIALYEAR || 2019); 39 40 // Create a timestamp for this year 41 return moment().year(initialYear).startOf("year").unix(); 42}; 43 44function* updateAthleteActivity() { 45 // 1. Check the tokens are valid and update as needed 46 yield call(validateAuthTokens); 47 48 // 2. set the page and epoch - The epoch depends on the last activity date 49 let page = 1; 50 const epoch = yield select(getLastActivityTimestamp); 51 52 try { 53 while (true) { 54 // Loop through pages until we manually break the cycle 55 // Start the process of getting a page from Strava 56 const { data } = yield call(getActivity, epoch, page); 57 58 // Page has no activities - Last page reached 59 if (!data.length) { 60 // If the result has an empty array (no activities) then end the sync - we must have them all! 61 break; 62 } else { 63 // put the data into redux and let the activity reducer merge it 64 yield put({ type: ACTIVITIES_PAGE_LOADING_COMPLETE, payload: data }); 65 66 // Next slide please! 67 page += 1; 68 } 69 } 70 } catch (e) { 71 yield console.log(e) 72 } 73 74} 75 76export function* watchUpdateAthleteActivitiesAsync() { 77 yield takeLeading(ACTIVITIES_SYNC_START, updateAthleteActivity); 78}

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

1// file src/redux/sagas/index.js 2import { all } from "redux-saga/effects"; 3 4import { beginStravaAuthAsync, validateStravaTokenAsync } from "./auth"; 5import { watchUpdateAthleteActivitiesAsync} from "./activities"; // Add 6 7// single entry point to start all Sagas at once 8export default function* rootSaga() { 9 yield all([ 10 beginStravaAuthAsync(), 11 validateStravaTokenAsync(), 12 watchUpdateAthleteActivitiesAsync() // Add 13 ]); 14}Create the "front end"Display activities in a table

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

1const Table = ({activities}) => { 2 3 return ( 4 <table> 5 <thead> 6 <tr> 7 <th>Name</th> 8 </tr> 9 </thead> 10 <tbody> 11 {activities.map(activity => ( 12 <tr key={activity.id}> 13 <td>{activity.name}</td> 14 </tr> 15 ))} 16 </tbody> 17 </table> 18 ) 19} 20 21export default Table;

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

1{ 2 resource_state:2 3 name:"Night Ride" 4 distance:20800.4 5 moving_time:4649 6 elapsed_time:4649 7 total_elevation_gain:96 8 type:"Ride" 9 workout_type:null 10 id:398412246 11 external_id:"Ride390811004.gpx" 12 upload_id:447540868 13 start_date:"2010-01-01T00:00:00Z" 14 start_date_local:"2010-01-01T00:00:00Z" 15 timezone:"(GMT+00:00) Europe/London" 16 utc_offset:0 17 location_city:"Malpas" 18 location_state:null 19 location_country:"United Kingdom" 20 start_latitude:55.06 21 start_longitude:-1.68 22 achievement_count:0 23 kudos_count:0 24 comment_count:0 25 athlete_count:1 26 photo_count:0 27 trainer:false 28 commute:false 29 manual:false 30 private:false 31 visibility:"everyone" 32 flagged:false 33 gear_id:"b1131808" 34 from_accepted_tag:false 35 upload_id_str:"447540868" 36 average_speed:4.474 37 max_speed:5.4 38 average_watts:106.7 39 kilojoules:496 40 device_watts:false 41 has_heartrate:false 42 heartrate_opt_out:false 43 display_hide_heartrate_option:false 44}The Home page

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

1import { useSelector, useDispatch } from "react-redux"; 2 3import {beginStravaAuthentication} from "../redux/actions/auth"; 4import {syncActivities} from "../redux/actions/activities"; // New 5import Table from "../components/table"; // New 6 7const HomePage = () => { 8 const auth = useSelector((state) => state.auth); 9 const activities = useSelector((state) => state.activities.activities); // New: Get the activities from the state 10 11 const dispatch = useDispatch(); 12 13 return ( 14 <div> 15 <h1>Home</h1> 16 17 { auth.isAuthenticated ? ( 18 <> 19 <h2>Logged in</h2> 20 {/* Add a button to dispatch our syncActivities action */} 21 <button type="button" onClick={() => dispatch(syncActivities())}>Sync activities</button> 22 23 {/* Display the activities in the table */} 24 <Table activities={activities} /> 25 </> 26 ): ( 27 // add the dispatch to the button onClick 28 <button type="button" onClick={() => dispatch(beginStravaAuthentication())}>Authorise on Strava</button> 29 )} 30 </div> 31 ) 32} 33 34export 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 bonusesSave 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:

1saveState({ 2 auth: store.getState().auth, 3 activities: store.getState().activities // Additional state to save 4});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:

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

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}.

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

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

1// file: src/redux/reducers/activities.js 2import unionBy from "lodash/unionBy"; 3 4import { ACTIVITIES_PAGE_LOADING_COMPLETE, ACTIVITIES_SYNC_STATE } from "../constants/activities"; // add ACTIVITIES_SYNC_STATE 5 6const initialState = { 7 activities: [], 8 loading: false // add the default loading state (false) 9}; 10 11export default function (state = initialState, action) { 12 switch (action.type) { 13 // Add the action here 14 case ACTIVITIES_SYNC_STATE: 15 // merge our payload in 16 return { ...state, ...action.payload} 17 18 case ACTIVITIES_PAGE_LOADING_COMPLETE: 19 const activities = unionBy(state.activities, action.payload, "id"); 20 return { ...state, activities }; 21 22 default: 23 return state; 24 } 25};

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

1// file: src/redux/sagas/activities.js 2// ... other imports 3 4import { setActivitiesState} from "../actions/activities"; // import the state new action 5 6 7// ... the rest 8 9function* updateAthleteActivity() { 10 // Add the put for ACTIVITIES_SYNC_STATE and we pass a payload of loading: true 11 yield put(setActivitiesState({loading: true})) 12 13 yield call(validateAuthTokens); 14 15 let page = 1; 16 const epoch = yield select(getLastActivityTimestamp); 17 try { 18 while (true) { 19 const { data } = yield call(getActivity, epoch, page); 20 21 // Page has no activities - Last page reached 22 if (!data.length) { 23 break; 24 } else { 25 yield put({ type: ACTIVITIES_PAGE_LOADING_COMPLETE, payload: data }); 26 page += 1; 27 } 28 } 29 } catch (e) { 30 yield console.log(e) 31 } 32 33 // I like to add a small delay to the final action just so that the loading state is visible for a short while 34 // so that users *feel* like something has happened and don't miss it! 35 yield delay(1000) 36 // Add the put for ACTIVITIES_SYNC_STATE and we pass a payload of loading: false 37 // This sets it back to the non loading state and the table should show with everything in! 38 yield put(setActivitiesState({loading: false})) 39} 40 41// ... the rest

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

1 // Old 2 // const activities = useSelector((state) => state.activities.activities); 3 4 // New 5 const activities = useSelector((state) => state.activities);1 // Old 2 // <Table activities={activities} /> 3 4 // New 5 <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.

1const Table = ({ data }) => { 2 3 const { loading, activities } = data; 4 5 return ( 6 <> 7 {loading ? ( 8 <div> 9 <h3>Loading...</h3> 10 </div> 11 ) : ( 12 <table> 13 <thead> 14 <tr> 15 <th>Name</th> 16 </tr> 17 </thead> 18 <tbody> 19 {activities.map(activity => ( 20 <tr> 21 <td>{activity.name}</td> 22 </tr> 23 ))} 24 </tbody> 25 </table> 26 )} 27 </> 28 ) 29} 30 31export 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.