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

🕑 6 minute read

Why?

I've been using Strava for over 5 years to track my activities, and more recently analyse them too. Towards the end of 2019 I realised that I was getting close to running 1,000km in a year, so I started a basic spreadsheet to track the data so that I could plan my runs accordingly and hit my goal. The spreadsheet was a basic setup that just plotted distance run vs distance I needed to run, but it got me to the end of the year, and I managed to pass my target. As 2020 began I started to update the spreadsheet to calculate more detailed stats, and more in-depth analysis.

Google Sheets Spreadsheet v2

Adding activities manually every time I completed them is tedious and more importantly, minor disctane descrepacies were making my analysis inaccurate - Although my entries are only a few metres out per activity it all adds up after a hundred or so and the analysis ends up wrong!

Autotomation of the activity entries is the best and easiest way to manage my data. There are a few scripts out there that allow downloading of your activities to a CSV, however these also involve a manual step which is not ideal.

Let's build a web app to use the Strava API to retrieve activity details and store them in Redux.

Building the web app

We're going to do this in a few sections:

  • Create the app and install the core dependencies
  • Get a Strava API key/secret
  • Setup the API client
  • Get the Auth tokens
  • Get some activities (part 2)
  • Display activities in a table (part 2)

Create the app and install the dependencies

First we want to get the core app setup, and the needed dependencies installed.

npx npx create-react-app strava-api-app

After it creates the app and installs the dependencies go to the directory:

cd strava-api-app

Then we need to install everything that we need. I'm using yarn but npm is also good!

yarn add redux react-redux react-router react-router-dom connected-react-router redux-saga history@4.10.1

Setup the store

Create the Auth state src/redux/reducers/auth.js

// file: src/redux/reducers/auth.js
const initialState = {
    isAuthenticated: false,
};

export default function (state = initialState, action) {
    if (!action) return state;

    switch (action.type) {
        default:
            return state;
    }
}

Create a reducers index file src/redux/reducers/index.js. Here we will add our reducers to handle the app data. For now we will just add the core reducers that we start with.

// file: src/redux/reducers/index.js
import { combineReducers } from "redux";
import { connectRouter } from "connected-react-router";
import auth from "./auth";

export default (history) =>
    combineReducers({
        router: connectRouter(history),
        auth
    });

Create a Saga index file src/redux/sagas/index.js. For now this will do nothing.

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

// single entry point to start all Sagas at once
export default function* rootSaga() {
    
}

Create a file in the src directory named configureStore.js. We will import our reducers and sagas into the store.

// file: src/configureStore.js
import { createBrowserHistory } from "history";
import { applyMiddleware, compose, createStore } from "redux";
import { routerMiddleware } from "connected-react-router";
import createSagaMiddleware from "redux-saga";

import createRootReducer from "./redux/reducers";
import rootSaga from "./redux/sagas";

const sagaMiddleware = createSagaMiddleware();
export const history = createBrowserHistory();

const configureStore = (initialState = {}) => {
    const enhancers = [];
    const __DEV__ = process.env.NODE_ENV !== "production";
    if (__DEV__) {
        const { devToolsExtension } = window;
        if (typeof devToolsExtension === "function") {
            enhancers.push(devToolsExtension());
        }
    }

    const middleware = [routerMiddleware(history), sagaMiddleware];
    const rootReducer = createRootReducer(history);

    const store = createStore(rootReducer, initialState, compose(applyMiddleware(...middleware), ...enhancers));

    sagaMiddleware.run(rootSaga);

    return store;
};

export default configureStore();

Finally in the index file we need to make some updates. First add a few imports so we can use the store. Then update the render to add the <Provider></Provider> with the store, and the <ConnectedRouter></ConnectedRouter> with the history.

// file: src/index.jsx

// Add
import { ConnectedRouter } from "connected-react-router";
import { Provider } from "react-redux";
import storeConfig, { history } from "./configureStore";


// Update
ReactDOM.render(
    <React.StrictMode>
        <Provider store={store}>
                <ConnectedRouter history={history}>
                    <App />
                </ConnectedRouter>
        </Provider>
    </React.StrictMode>,
  document.getElementById('root')
);

With this you can start the app with yarn start and when it boots you should see the normal CRA landing page and in the console you should see "Hello!".

Get a Strava API key/secret

Strava API Page

Setup the API client

For the API client we're going to use axios - It's very similar to the built in fetch but is more compatible with older browsers out of the box and I prefer the configuration. If you prefer fetch then you can use it but you will need to "translate" the API calls accordingly.

Create a file for the api configurations in src/api/index.js. This will contain 2 clients configs and an inteceptor for the apiClient that handles the extra authorization.

import axios from "axios";

export const tokenClient = axios.create({
    baseURL: "https://www.strava.com/oauth",
    timeout: 3000
});

const apiClient = axios.create({
    baseURL: "https://www.strava.com/api/v3",
    timeout: 3000
});

apiClient.interceptors.request.use(
    (config) => {
        const token = localStorage.getItem("accessToken");

        if (token) {
            // eslint-disable-next-line no-param-reassign
            config.headers.Authorization = `Bearer ${token}`;
        }

        return config;
    },
    (error) => {
        Promise.reject(error);
    }
);

export { apiClient };

Get the Auth tokens

The strava API allows a user authorize your app, which redirects back to your app with a token. The token can then be used to check the required scopes are correct and obtain a short-lived access token, refresh token and expiry date. These tokens are then used to retrieve data or a new token.

NOTE: In this tutorial I will be sending my application secret key from the front end. This is NOT SECURE! The token is visible in the code for all to see. It should be sent by a backend service. In a future post I will cover setting up Netlify functions to keep the secret a secret. DON'T use ths for production code.

Let's create some constants to be used around the app...

// file: src/redux/constants/auth.js
export const STRAVA_AUTH_START  = "STRAVA_AUTH_START";
export const STRAVA_AUTH_TOKEN_VALIDATE = "STRAVA_AUTH_TOKEN_VALIDATE";
export const STRAVA_AUTH_TOKEN_UPDATE = "STRAVA_AUTH_TOKEN_UPDATE";
export const STRAVA_AUTH_LOG_OUT = "STRAVA_AUTH_LOG_OUT";

Then lets start creating actions for the authentication life cycle... Authenticate, validate, update, deauthenticate.

// file: src/redux/actions/auth.js
import { STRAVA_AUTH_LOG_OUT, STRAVA_AUTH_START, STRAVA_AUTH_TOKEN_UPDATE, STRAVA_AUTH_TOKEN_VALIDATE } from "../constants/auth";

// To begin auth we request app access from strava and the user logs in. A token is returned
export const beginStravaAuthentication = () => ({
    type: STRAVA_AUTH_START
});

// The token must be validated against the app client id and secret
export const validateStravaToken = (code) => ({
    type: STRAVA_AUTH_TOKEN_VALIDATE,
    payload: code
});

// A validated app access only lasts so long. After a while we need to request a new token. 
// When we do the new tokens must be saved
export const updateAuthTokens = ({ refresh_token: refreshToken, expires_at: expiresAt, access_token: accessToken }) => ({
    type: STRAVA_AUTH_TOKEN_UPDATE,
    payload: {
        isAuthenticated: true,
        refreshToken,
        expiresAt,
        accessToken
    }
});

// A user must be able to log out. When the app recieves this request we will remove all data from storage, remove tokens and deauth the app with Strava.
export const beginStravaDeauthentication = () => ({
    type: STRAVA_AUTH_LOG_OUT
});

Create a src/pages/HomePage.jsx component - We will use this to login / out and sync data.

const HomePage = () => {
    return (
        <div>Home</div>
    )
}

export default HomePage;

Create a src/pages/Token.jsx component - This will eventually handle the incoming token from Strava. For now we just add a placeholder contents.

const Token = () => {
    return (
        <div>Token recieved</div>
    )
}

export default Token;

Open up src/App.js and replace the contents with the following which includes the routes for the initial app and the 2 new components we just created.

// file: src/App.js

import { Route, Switch } from "react-router";

import Token from "./pages/Token";
import HomePage from "./pages/HomePage";

const App = () => {

  return (
      <div>
        <Switch>
          <Route path="/token" exact component={Token} />

          <Route path="/" exact component={HomePage} />
        </Switch>
      </div>
  )
}

export default App;

Update the HomePage to the following:

// import redux hooks
import { useSelector, useDispatch } from "react-redux";
// import the begin auth action
import {beginStravaAuthentication} from "../redux/actions/auth";

const HomePage = () => {
    //  get the auth state (so we can use to hide the button when the user is authenticated)
    const auth = useSelector((state) => state.auth);
    // create a dispatch function
    const dispatch = useDispatch();

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

            {/* Use the auth state to show hide the button */}
            { auth.isAuthenticated ? (
                <h2>Logged in</h2>
            ): (
                // add the dispatch to the button onClick
                <button type="button" onClick={() => dispatch(beginStravaAuthentication())}>Authorise on Strava</button>
            )}
        </div>
    )
}

export default HomePage;

Now if you click the button then the STRAVA_AUTH_START action appear in the Redux dev tools. Next we need to handle it.

Now we need to use the details from the Strava API app we setup earlier.

In the root create a .env file with the following, then stop and restart your app or the new ENV variables will not be in your app.

REACT_APP_STRAVA_CLIENT_ID=your_id
REACT_APP_STRAVA_CLIENT_SECRET=your_secret

NOTE: Again, in this tutorial I will be sending my application secret key from the front end as REACT_APP_STRAVA_CLIENT_SECRET. This is NOT SECURE! The token is visible in the code for all to see. It should be sent by a backend service. In a future post I will cover setting up Netlify functions to keep the secret a secret. DON'T use ths for production code. REACT_APP_STRAVA_CLIENT_ID is ok to be public and will always remain this way.

Create src/redux/sagas/auth.js

import { takeLeading, call } from "redux-saga/effects";
import {STRAVA_AUTH_START} from "../constants/auth";

const clientID = process.env.REACT_APP_STRAVA_CLIENT_ID;

function handOffToStravaAuth() {
    // Get the current address of the app running eg localhost or mycoolapp.io
    const { origin } = window;
    // push the Strava authorise url onto the browser window with our PUBLIC clientID and the return url (origin)
    // Note the redirect_uri=${origin}/token - this is the token page we setup earlier.
    window.location.assign(`https://www.strava.com/oauth/authorize?client_id=${clientID}&redirect_uri=${origin}/token&response_type=code&scope=activity:read_all`);
}

function* beginStravaAuthentication() {
    // A simple generator function
    // Just yeilds one other function - handOffToStravaAuth
    yield call(handOffToStravaAuth);
}

export function* beginStravaAuthAsync() {
    // This will listen for the first STRAVA_AUTH_START and then call beginStravaAuthentication
    yield takeLeading(STRAVA_AUTH_START, beginStravaAuthentication);
}

If you click the Authorise on Strava button now, nothing will happen as we need to link up the sagas:

import { all } from "redux-saga/effects";

// Import the new async function we created above
import { beginStravaAuthAsync } from "./auth";


export default function* rootSaga() {
    // Add beginStravaAuthAsync to the rootSaga
    yield all([ beginStravaAuthAsync()]);
}

Now when you click the button:

  • beginStravaAuthentication is dispatched
  • The saga middleware handles the action STRAVA_AUTH_START to every function in the yield all([...])
  • The beginStravaAuthAsync responds to it as it's listening for STRAVA_AUTH_START. takeLeading means that if you click the button multiple times only the first action is handled until it completes.
  • beginStravaAuthentication yields a single function using call - handOffToStravaAuth
  • handOffToStravaAuth pushes the authorise url to the browser which loads Strava's auth dialog
  • The user authorises the app
  • Strava returns the app to the redirect_uri with a token as a querystring parameter. eg. http://localhost:3000/token?state=&code=ee7c5a... ...1f073&scope=read,activity:read_all

At this point we need to handle this token and authorise that it's correct.

Next we need to install another package to handle querystrings:

yarn add query-string

Then in the Token component we need to update it to handle an incoming token on load.

import React, {useEffect} from "react"
import queryString from "query-string";

const Token = () => {

    const location = useSelector((state) => state.router.location);

    useEffect (() => {
        // pull the code out of the query string
        const {code} = queryString.parse(location.search);

        console.log(code)

    }, [])

    return (
        <div>Token recieved</div>
    )
}

export default Token;

We can now get the token from the return query from Strava. You can test this out now from the homepage by Authenticating with Strava and then logging in and confirming. You will be returned to http://localhost:3000/token?state=&code=ee7c5a... ...1f073&scope=read,activity:read_all. You should see your code in the console.

We now need to validate your code. First we import the validation action (and redux dispatch) and then we can dispatch the action when we get the code. We also have a check to see if the app is already validated, and if it is just return to the homepage as we can't validate a code again.

import React, {useEffect} from "react"
import queryString from "query-string";
// New imports
import {push} from "connected-react-router";
import { useSelector, useDispatch } from "react-redux";
import { validateStravaToken } from "../redux/actions/auth"


const Token = () => {
    
    const location = useSelector((state) => state.router.location);
    // Get the current auth state and setup a dispatch action
    const auth = useSelector((state) => state.auth);
    const dispatch = useDispatch();

    useEffect (() => {
        // Check if the user/app is authenticated
        if (!auth.isAuthenticated && code) {
            // pull the code out of the query string
            const {code} = queryString.parse(location.search);
            // dispatch the validation code to be handled by saga
            dispatch(validateStravaToken(code));
        } else {
            // Push back to home page because the user is authenticated or there is no code!
            dispatch(push("/"));
        }

    }, [])
    
    // Return nothing because this page had nothing to display
    return null;
}

export default Token;

If you now reauthenticate, or refresh the page if the token code is still in the url then you should see a redux action in the dev tools but nothing else will happen:

{
    type: "STRAVA_AUTH_TOKEN_VALIDATE"
    payload: "ee7c5a... ...1f073"
}

Next we need to create a saga to handle this action type. In your src/redux/sagas/auth.js we add the following:

import { takeLeading, call, put } from "redux-saga/effects"; // Update to add put
import {STRAVA_AUTH_START, STRAVA_AUTH_TOKEN_VALIDATE} from "../constants/auth"; // add STRAVA_AUTH_TOKEN_VALIDATE
import {push} from "connected-react-router"; // add

import {tokenClient} from "../../api"; // add

const clientID = process.env.REACT_APP_STRAVA_CLIENT_ID;
// Add! But remember this should only ever be done for non PROD. 
// The secret should be on a non client app (ie backend or lambda like Netlify)
const clientSecret = process.env.REACT_APP_STRAVA_CLIENT_SECRET; 

// ... the existing sagas

// Add the new sagas

const apiValidateToken = (code) => {
    return tokenClient({
        url: "/token",
        method: "post",
        params: {
            client_id: clientID,
            client_secret: clientSecret,
            code,
            grant_type: "authorization_code"
        }
    })
        .then((response) => {
            return response.data;
        })
        .catch((error) => {
            Promise.reject(error);
        });
};

function* validateStravaToken({ payload: code }) {
    // here you could dispatch a loading start action!

    // we dispatch a api call to validate the tokens and it returns a data object with the values we need.
    const data = yield call(apiValidateToken, code);

    // push an the action type: STRAVA_AUTH_TOKEN_UPDATE with the data
    yield put(updateAuthTokens(data));

    yield put(push("/")); // Push the browser to the root when we are done!

    // here you could dispatch a loading end action!
}

export function* validateStravaTokenAsync() {
    // Listen for STRAVA_AUTH_TOKEN_VALIDATE
    yield takeLeading(STRAVA_AUTH_TOKEN_VALIDATE, validateStravaToken);
}

Note! I cannot stress enough about the client secret. Anything that is on the client side is public as it is in the code that is on a users browser. In the real world™️ the token client is a backend client where the code is not available to the user!

Then we need to handle the tokens with a reducer. Update the reducers/auth.js to the following:

import { STRAVA_AUTH_TOKEN_UPDATE } from "../constants/auth"; // Import the constant for the action

const initialState = {
    isAuthenticated: false,
};

export default function (state = initialState, action) {
    if (!action) return state;

    switch (action.type) {
        // The new bits
        case STRAVA_AUTH_TOKEN_UPDATE:
            // save the tokens to the local storage
            localStorage.setItem("refreshToken", action.payload.refresh_token);
            localStorage.setItem("expiresAt", action.payload.expires_at);
            localStorage.setItem("accessToken", action.payload.access_token);

            // update the state to show the app the user is logged in
            return { ...state, isAuthenticated: true}

        default:
            return state;
    }
}

If you authenticate again you should now end back at the homepage and you should see the "Logged in" text and in the application data (dev tools) you can see the tokens we recieved after authenticating. It is these that we will send with subsequent requests to get data or new tokens when our existing token expires.

Tokens in localstorage

Our auth redux state should also be showing as isAuthenticated: true

Redux state for auth isAuthenticated

Although the tokens have been stored, the redux state is not stored, so refreshing the page will reset the store. There are a few ways to persist the state:

  • localstorage. This has file size limits and will eventually be full and break your app, however it's an good initial step. See below
  • IndexDB - The size limit is your machine drive space so I doubt this will run out, however it's more complicated to setup so I'm not going to cover it here!

Save the state

Create a file named src/localStorage.js:

export const loadState = () => {
    try {
        const serializedState = localStorage.getItem("state");
        if (serializedState === null) {
            return undefined;
        }
        return JSON.parse(serializedState);
    } catch (err) {
        return undefined;
    }
};

export const saveState = (state) => {
    try {
        const serializedState = JSON.stringify(state);
        localStorage.setItem("state", serializedState);
    } catch (error) {
        console.log(error)
    }
};

Then update the configureStore.js:

import { createBrowserHistory } from "history";
import { applyMiddleware, compose, createStore } from "redux";
import { routerMiddleware } from "connected-react-router";
import createSagaMiddleware from "redux-saga";
import throttle from "lodash/throttle"; // Add lodash - we will need to throttle the saving
import { loadState, saveState } from "./localStorage"; // Import the load and save

import createRootReducer from "./redux/reducers";
import rootSaga from "./redux/sagas";

const sagaMiddleware = createSagaMiddleware();
export const history = createBrowserHistory();

const persistedState = loadState(); // Get the state from the localstorage

// remove the initial State from the function
const configureStore = () => {
    const enhancers = [];
    const __DEV__ = process.env.NODE_ENV !== "production";
    if (__DEV__) {
        const { devToolsExtension } = window;
        if (typeof devToolsExtension === "function") {
            enhancers.push(devToolsExtension());
        }
    }

    const middleware = [routerMiddleware(history), sagaMiddleware];
    const rootReducer = createRootReducer(history);
    
    // replace initialState with persistedState so on boot the app will use the data that exists in the state
    const store = createStore(rootReducer, persistedState, compose(applyMiddleware(...middleware), ...enhancers));

    sagaMiddleware.run(rootSaga);
    
    // subscribe to store updates so we can action saves on change to state 
    store.subscribe(
        // throttle the save
        throttle(() => {
            saveState({
                auth: store.getState().auth // Only save these sections of the auth
                // if we want to save other state we can add it in here
                // activities: store.getState().activities, etc!
            });
        }, 1000) // 1 per second max!
    );

    return store;
};

export default configureStore();

we also need to add lodash to help throttling the saving or every single redux action would trigger it...

yarn add lodash

Authenticate your app again... then reload and you should still see the logged in state! Hurrah! Now let's get some activities!

Next

In part 2 of "Strava API activity sync with Axios, React, Redux & Saga" we will get the activities and display them.