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 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 oneActivities
- Retrieving multiple pages of data
- Display the activity data on the landing page:
- Creating a
Table
component. - Using it on the
Homepage
- Creating a
- 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".
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 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.
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 activities- Use the
apiClient
to retrieve each page until it reaches a page with an empty list, meaning we have reached the end.
- Use the
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.
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:
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.