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.
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.
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:
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.
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.
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.
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.
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.
In our app, we want the user to be able to:
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, }, }) } />
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.
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}
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, })); } }
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 }]); } };
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/
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]