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
- 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".
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.
- Use the
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.
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.
Top comments (0)