Sander de Bruijn Passionate self-taught web developer and JavaScript (React) and C# enthusiast.

Create a fitness tracker with React and Firebase

7 min read 2172

Create A Fitness Tracker With React And Firebase

In this article, we’ll build a fitness tracker web application using React and Firebase, which are two technologies that enable us to develop web applications with high efficiency.

This article will enable you to build full-stack apps with React and Firebase on your own. If you know the basics of React, you should be good to go. Otherwise, I would suggest tackling those first.

Note that you can find the finished application here and the source code for this project here.

Sign In Page For Fitness Tracker Application

Setting up the project

Let’s start with overwriting a fresh Create React App setup with the craco npm package. Tailwind needs the craco package to overwrite the default Create React App configuration.

Let’s set up routing as well. We’ll give our routes an extra parameter called layout so that they can wrap the page with the correct layout:

function RouteWrapper({ page: Page, layout: Layout, ...rest }) {
  return (
    <Route
      {...rest}
      render={(props) => (
        <Layout {...props}>
          <Page {...props} />
        </Layout>
      )}
    />
  );
}

We will add authentication and other routes later in this article. Inside App.js, we’ll return our router. Let’s continue with user sessions.

Authenticating user sessions with React Context

We would like to know if a user is authenticated from anywhere in our application without having to pass down this information through multiple components. To accomplish this, we will use React’s context API. We’ll wrap our entire application with the authentication context so that we can access the currently authenticated user from anywhere in our app.

First, let’s create the auth context.

We can create a new context by calling:

We made a custom demo for .
No really. Click here to check it out.

const AuthContext = createContext()

Then, we’ll provide it to other components, like so:

<AuthContext.Provider value={user}>{children}</AuthContext.Provider>

In our case, we want to subscribe to the authenticated Firebase user. We do this by calling the onAuthStateChanged() method on our exported Firebase auth function:

 auth.onAuthStateChanged(user => { … });

This will give us the currently authenticated user. If a user state changes, such as a sign-in or sign-off, we want to update our context provider accordingly. To handle this change, we will use the useEffect hook.

Our AuthContext.jsx then looks like this:

...
export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const unsubscribe = auth.onAuthStateChanged((user) => {
      setUser(user);
      setLoading(false);
    });

    return unsubscribe;
  }, []);

  const value = {
    user,
  };

  return (
    <AuthContext.Provider value={value}>
      {!loading && children}
    </AuthContext.Provider>
  );
}

We can call the useAuth() hook to return the current context value from anywhere in our application. The context value we are providing now will contain more functions later on, such as logging in and out.

Creating sign-in and sign-up forms

To be able to sign in the user, we need to use a method called signInWithEmailAndPassword() that lives on the auth object we are exporting in our Firebase file.

Instead of accessing this method directly, we can add it to our AuthContext provider so that we can easily combine the authentication methods with the authenticated user. We’ll add the signIn() function to our AuthContext provider, like so:

function signIn(email, password) {
    return auth.signInWithEmailAndPassword(email, password);
  }

  const value = {
    user,
    signIn,
  };

  return (
    <AuthContext.Provider value={value}>
      {!loading && children}
    </AuthContext.Provider>
  );

On our sign-in page, we can now easily access the signIn() method with our useAuth() hook:

const { signIn } = useAuth();

If the user signs in successfully, we’ll redirect them to the dashboard, which lives on the home router path. To check this, we’ll use a try-catch block.

You should receive an error message now saying that there is no user found, as we are not signed up yet. If so, great! This means our Firebase connection is working.

Enabling Google authentication

First, enable Google authentication inside the Firebase console. Then, add the signInWithGoogle function to the authentication context:

function signInWithGoogle() {
    return auth.signInWithPopup(googleProvider);
}

Next, we’ll import googleProvider from our Firebase file:

export const googleProvider = new firebase.auth.GoogleAuthProvider();

Back on our sign-in page, we’ll add the following code to make this work:

const handleGoogleSignIn = async () => {
    try {
      setGoogleLoading(true);
      await signInWithGoogle();
      history.push("/");
    } catch (error) {
      setError(error.message);
    }

    setGoogleLoading(false);
  };

Let’s continue with building our workout app.

Creating workout options in the fitness app

Selecting an exercise

Let’s create the actual component, called SelectExercise. We want to accomplish two things inside this component. First, we want to render a list of exercises the user has created that they can add to their workout. Second, we want to give the user the option to create a new exercise.

The workout reducer wraps our entire app with the workout state so that we can access it from anywhere in our app. Every time the user changes their workout, localStorage gets updated, as well as all components that are subscribed to the state.

We are separating the workout from the dispatch provider because some components only need to access the state or dispatch:

const WorkoutStateContext = createContext();
const WorkoutDispatchContext = createContext();

export const useWorkoutState = () => useContext(WorkoutStateContext);
export const useWorkoutDispatch = () => useContext(WorkoutDispatchContext);

export const WorkoutProvider = ({ children }) => {
  const [workoutState, dispatch] = useReducer(rootReducer, initializer);

  // Persist workout state on workout update
  useEffect(() => {
    localStorage.setItem("workout", JSON.stringify(workoutState));
  }, [workoutState]);

  return (
    <WorkoutStateContext.Provider value={workoutState}>
      <WorkoutDispatchContext.Provider value={dispatch}>
        {children}
      </WorkoutDispatchContext.Provider>
    </WorkoutStateContext.Provider>
  );
};

Now we can dispatch actions to our reducer. This is a concept from Redux — it simply means that we want the workout state to change with a value we provide.

Our addExercise function looks like this:

const exercise = {
      exerciseName,
      sets: { [uuidv4()]: DEFAULT_SET },
    };

    dispatch({
      type: "ADD_EXERCISE",
      payload: { exerciseId: uuidv4(), exercise },
    });

It dispatches the ADD_EXERCISE action to our reducer, which will add the given exercise to our state. Using Immer, our reducer will then look like this:

 export const rootReducer = produce((draft, { type, payload }) => {
  switch (type) {
    ...
    case ACTIONS.ADD_EXERCISE:
      draft.exercises[payload.exerciseId] = payload.exercise;
      break;
    …

For our exercises, instead of using an array of objects, we will use objects of objects.

 case ACTIONS.UPDATE_WEIGHT:
      draft.exercises[payload.exerciseId].sets[payload.setId].weight =
        payload.weight;
      break;

This is much more efficient than filtering an array each time we update the state because the reducer knows exactly which item to update.

SelectExercise also has to be able to add an exercise to the database. So, we first need access to our Firestore database.

Here’s the functionality for saving a new exercise to the database:

const { user } = useAuth();
...
const saveExercise = async () => {
    if (!exerciseName) {
      return setError("Please fill in all fields");
    }

    setError("");

    try {
      await database.exercises.add({
        exerciseName,
        userId: user.uid,
        createdAt: database.getCurrentTimestamp(),
      });

      toggleShowCreateExercise();
    } catch (err) {
      setError(err.message);
    }
};

We’ll also want to retrieve the list of exercises that are stored in the database that the user has created. We don’t want to wrap our entire application with these exercises, so we’ll keep it locally to our SelectExercise component.

To retrieve the database exercises, we do not need the context API. For the sake of learning, we will create a custom hook that uses the useReducer hook for managing state. This way, we have efficient state management for retrieving an up-to-date list of exercises every time the user requests them.

function useWorkoutDb() {
  const [workoutDbState, dispatch] = useReducer(reducer, initialState);

  const { user } = useAuth();

  useEffect(() => {
    dispatch({ type: ACTIONS.FETCHING_EXERCISES });

    return database.exercises
      .where("userId", "==", user.uid)
      .onSnapshot((snapshot) => {
        dispatch({
          type: ACTIONS.SET_EXERCISES,
          payload: snapshot.docs.map(formatDocument),
        });
      });
  }, [user]);

  useEffect(() => {
    dispatch({ type: ACTIONS.FETCHING_WORKOUTS });

    return database.workouts
      .where("userId", "==", user.uid)
      .onSnapshot((snapshot) => {
        dispatch({
          type: ACTIONS.SET_WORKOUTS,
          payload: snapshot.docs.map(formatDocument),
        });
      });
  }, [user]);

  return workoutDbState;
}

You may notice the difference between our other useReducer, where we are using objects of objects and Immer for mutating the state.

You should now be able to add an exercise and see them in the list. Awesome! Let’s continue with the workout timer.

Building the workout timer

For the timer, we’ll create a custom hook called useTimer. We’ll set an interval every second to update the secondsPassed number variable. Our stop and pause functions clear the interval to start from 0 again. Every second, we’ll update the time inside the user’s localStorage as well so that the user can refresh the screen and still have the timer running correctly.

function useTimer() {
  const countRef = useRef();
  const [isActive, setIsActive] = useState(false);
  const [isPaused, setIsPaused] = useState(false);
  const [secondsPassed, setSecondsPassed] = useState(
    persist("get", "timer") || 0
  );

  useEffect(() => {
    const persistedSeconds = persist("get", "timer");
    if (persistedSeconds > 0) {
      startTimer();
      setSecondsPassed(persistedSeconds);
    }
  }, []);

  useEffect(() => {
    persist("set", "timer", secondsPassed);
  }, [secondsPassed]);

  const startTimer = () => {
    setIsActive(true);
    countRef.current = setInterval(() => {
      setSecondsPassed((seconds) => seconds + 1);
    }, 1000);
  };

  const stopTimer = () => {
    setIsActive(false);
    setIsPaused(false);
    setSecondsPassed(0);
    clearInterval(countRef.current);
  };

  const pauseTimer = () => {
    setIsPaused(true);
    clearInterval(countRef.current);
  };

  const resumeTimer = () => {
    setIsPaused(false);
    startTimer();
  };

  return {
    secondsPassed,
    isActive,
    isPaused,
    startTimer,
    stopTimer,
    pauseTimer,
    resumeTimer,
  };
}

The timer should now be working. Let’s continue with the actual workout scheme.

Creating the workout scheme in React

In our app, we want the user to be able to:

  • Add and remove exercises
  • Add and remove sets
  • For each set, add weight and reps
  • For each set, mark as finished or unfinished

Dashboard To Edit Workout In Fitness Tracker Application

We can update our workout by dispatching actions to the reducer we made earlier. To update the weight, we would dispatch the following action:

dispatch({
      type: "UPDATE_WEIGHT",
      payload: {
        exerciseId,
        setId,
        newWeight,
      },
});

Our reducer then will update the state accordingly:

case ACTIONS.UPDATE_WEIGHT:
      draft.exercises[payload.exerciseId].sets[payload.setId].weight =
        payload.weight;

The reducer knows which record to update, as we give it the exerciseId and setId:

<Button
                      icon="check"
                      variant={isFinished ? "primary" : "secondary"}
                      action={() =>
                        dispatch({
                          type: "TOGGLE_FINISHED",
                          payload: {
                            exerciseId,
                            setId,
                          },
                        })
                      }
/>

Creating dashboards

The dashboard consists of two charts: total workouts and calories burned per day. We also want to display the total amount of workouts and the calories of today, this week, and this month.

Dashboard Tracking Calories Burned Per Day On Graph

This means we want to retrieve all the workouts from the database, which we can get from our custom useWorkoutDb() hook:

const { isFetchingWorkouts, workouts } = useWorkoutDb();

We can already display the total amount of workouts:

{isFetchingWorkouts ? 0: workouts.length}

Calories burned per day, week, and month

If the workout has changed and has at least one exercise, we want to recalculate the calories:

useEffect(() => {
  if (!isFetchingWorkouts && workouts.length) {
    calcCalories();
  }
}, [workouts])

For each workout, we’ll check if the date is the same as today, in this week, or this month.

const formattedDate = new Date(createdAt.seconds * 1000);
const day = format(formattedDate, "d");

If so, we’ll update the calories accordingly by multiplying it with the minutes passed of that workout:

const newCalories = CALORIES_PER_HOUR * (secondsPassed / 3600);

    if (dayOfYear === day) {
      setCalories((calories) => ({
        ...calories,
        today: calories.today + newCalories,
      }));
    }
} 

Workout chart

We want a simple line chart with the months on the x-axis and the number of calories on the y-axis. It’s also nice to style the area beneath the line, so we’ll use the recharts AreaChart component. We simple pass it a data array:

 <AreaChart data={data}>

Let’s format the data array next. To let recharts know it needs to use the month for the x-axis, we’ll add <XAxis dataKey="month" /> inside our AreaChart.

For this to work, we need to use this format:

 [{ month: "Feb", amount: 13 }, ...]

We want to show the workout amount for the last three months, even if there were no workouts in those months. So, let’s fill an array with the last three months using date-fns and setting the amount to 0.

const [data, setData] = useState([]);
let lastMonths = [];

    const addEmptyMonths = () => {
      const today = new Date();

      for (let i = 2; i >= 0; i--) {
        const month = format(subMonths(today, i), "LLL");
        lastMonths.push(month);
        setData((data) => [...data, { month, amount: 0 }]);
      }
    };

Creating the calorie chart

For the calorie chart, we want to show the number of calories per day for the last week. Similar to the WorkoutChart, we fill our data with an array of the days of the last week with 0 calories per day.

 let lastDays = [];

    const addEmptyDays = () => {
      const today = new Date();

      for (let i = 6; i >= 0; i--) {
        const day = format(subDays(today, i), "E");
        lastDays.push(day);
        setData((data) => [...data, { day, calories: 0 }]);
      }
    };

For each workout, we’ll check to see if it occurred within the past seven days. If so, we’ll calculate the number of calories burned for that workout and add it to our data array:

    const addCaloriesPerDay = () => {
      for (const { createdAt, secondsPassed } of workouts) {
        const day = format(new Date(createdAt.seconds * 1000), "E");
        const index = lastDays.indexOf(day);
        if (index !== -1) {
          const calories = CALORIES_PER_HOUR * (secondsPassed / 3600);

          setData((data) => {
            data[index].calories = data[index].calories + parseInt(calories);
            return data;
          });
        }
      }
    }; 

If you save a new workout, you should now see your dashboard statistics and charts being updated.

Congratulations, you’ve built your React fitness app! Thanks for following this tutorial.

You can find the finished application here: fitlife-app.netlify.app. The source code can be found here: github.com/sanderdebr/FitLife/

Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket is like a DVR for web apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — .

Sander de Bruijn Passionate self-taught web developer and JavaScript (React) and C# enthusiast.

Leave a Reply