Gamification is a powerful tool in web development for boosting user engagement. One of the most effective features is the "streak," which encourages users to return daily to maintain their progress. Think of Duolingo's daily lesson streak or GitHub's contribution graph. In this tutorial, we'll build a custom React hook, useGoalStreak, to track a daily goal completion streak.
We will build a simple application that tracks a daily step goal. The core of our logic will be a custom hook that uses the browser's localStorage to persist the streak data. This ensures that when a user revisits the application, their streak is right where they left it. We'll also handle the logic to reset the streak if a day is missed.
Prerequisites
- Basic understanding of React and React Hooks (
useState,useEffect). - Node.js and npm (or yarn) installed on your machine.
- A code editor like VS Code.
Understanding the Problem
Tracking a daily streak seems simple on the surface, but there are some tricky edge cases to consider:
- Persistence: How do we remember a user's streak when they close the browser tab?
- Date Changes: How do we know if the last completion was yesterday or a week ago?
- Timezones: How can we ensure a "day" is consistent for the user?
- Reusability: How can we make this logic easy to reuse for different goals?
Our solution will be a custom hook that encapsulates all this complexity, providing a clean and straightforward API to any component that needs it. We will use localStorage for persistence, which is a simple, browser-based storage solution perfect for this kind of non-sensitive data.
Prerequisites
Let's set up a new React project to get started.
npx create-react-app goal-streak-tutorial
cd goal-streak-tutorial
No external libraries are needed for this tutorial; we'll be using standard React Hooks and browser APIs.
Step 1: Creating the useGoalStreak Custom Hook
First, create a new file src/useGoalStreak.js. This is where all our logic will live.
What we're doing
We'll define the structure of our custom hook. It will need to:
- Initialize its state from
localStorage. - Provide a way to get the current streak count.
- Offer a function to mark the daily goal as complete.
Implementation
Here's the basic structure of our hook:
// src/useGoalStreak.js
import { useState, useEffect }
from 'react';
export const useGoalStreak = (storageKey) => {
const [streak, setStreak] = useState(0);
// More logic will go here
return {
streak
};
};```
### How it works
We're starting with a simple custom hook that takes a `storageKey` as an argument. This will allow us to reuse the hook for different goals by storing their streaks under different keys in `localStorage`. For now, it just returns a hardcoded streak of 0.
## Step 2: Reading from LocalStorage
Now, let's make our hook read from `localStorage` on the initial render.
### What we're doing
We'll use `useState`'s lazy initialization to get the initial state from `localStorage`. This is more efficient because it only runs once, on the initial render. We'll also need to handle the case where there's no data in `localStorage` yet.
### Implementation
```javascript
// src/useGoalStreak.js
import {
useState
} from 'react';
// A helper function to check if two dates are the same day
const isSameDay = (date1, date2) =>
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate();
export const useGoalStreak = (storageKey) => {
const [streak, setStreak] = useState(() => {
try {
const storedData = window.localStorage.getItem(storageKey);
if (storedData) {
const data = JSON.parse(storedData);
const today = new Date();
const lastCompletion = new Date(data.lastCompletion);
// If the last completion was today, we're still on the same streak
if (isSameDay(today, lastCompletion)) {
return data.streak;
}
// Check if the last completion was yesterday to continue the streak
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (isSameDay(yesterday, lastCompletion)) {
return data.streak;
}
}
} catch (error) {
console.error("Error reading from localStorage", error);
}
// Reset streak if more than a day has passed
return 0;
});
// ...
return {
streak
};
};
How it works
- We use a function inside
useStateto lazily initialize our state. - We read from
localStorageusing the providedstorageKey. - If there's data, we parse it and compare the
lastCompletiondate with today's and yesterday's date. - If the last completion was yesterday, we keep the streak. If it was before that, the streak is broken, and we return
0. - The
isSameDayhelper function is crucial for accurate date comparisons, ignoring the time part of theDateobject.
Step 3: Writing to LocalStorage and Updating the Streak
Next, we need a function to let the user mark their goal as complete for the day.
What we're doing
We'll create a function called markGoalAsCompleted that will be returned by our hook. This function will update the streak count and save the new state to localStorage.
Implementation
// src/useGoalStreak.js
import {
useState
} from 'react';
const isSameDay = (date1, date2) =>
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate();
export const useGoalStreak = (storageKey) => {
const [streakData, setStreakData] = useState(() => {
try {
const storedData = window.localStorage.getItem(storageKey);
if (storedData) {
const data = JSON.parse(storedData);
const today = new Date();
const lastCompletion = new Date(data.lastCompletion);
// If last completion was yesterday, the streak continues
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (isSameDay(yesterday, lastCompletion)) {
return { ...data, streak: data.streak };
}
// If last completion was today, don't change anything
if (isSameDay(today, lastCompletion)) {
return data;
}
}
} catch (error) {
console.error(error);
}
// Default state
return {
streak: 0,
lastCompletion: null
};
});
const markGoalAsCompleted = () => {
const today = new Date();
const lastCompletion = streakData.lastCompletion ? new Date(streakData.lastCompletion) : null;
// If goal was already completed today, do nothing
if (lastCompletion && isSameDay(today, lastCompletion)) {
return;
}
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
const newStreak = (lastCompletion && isSameDay(yesterday, lastCompletion)) ?
streakData.streak + 1 :
1;
const newData = {
streak: newStreak,
lastCompletion: today.toISOString(),
};
setStreakData(newData);
window.localStorage.setItem(storageKey, JSON.stringify(newData));
};
return {
streak: streakData.streak,
markGoalAsCompleted
};
};
How it works
- The
markGoalAsCompletedfunction first checks if the goal has already been completed today. - It then determines if the
lastCompletionwas yesterday. If so, it increments the streak. Otherwise, it starts a new streak of 1. - A new data object is created with the updated streak and the current date as the
lastCompletion. - This new data is saved to both our React state (with
setStreakData) andlocalStorage.
Putting It All Together
Now let's use our new useGoalStreak hook in a component.
Implementation
Update src/App.js with the following code:
// src/App.js
import React from 'react';
import {
useGoalStreak
} from './useGoalStreak';
import './App.css';
function App() {
const {
streak,
markGoalAsCompleted
} = useGoalStreak('stepGoal');
return ( <
div className = "App" >
<
header className = "App-header" >
<
h1 > Daily Step Goal Tracker < /h1> <
div className = "streak-display" >
Your current streak is: <
span className = "streak-count" > {
streak
} < /span> day(s) 🔥 <
/div> <
button onClick = {
markGoalAsCompleted
} >
I met my 10, 000 step goal today!
<
/button> <
/header> <
/div>
);
}
export default App;
And for some basic styling, update src/App.css:
/* src/App.css */
.App {
text-align: center;
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.streak-display {
margin: 20px 0;
}
.streak-count {
font-size: 1.5em;
font-weight: bold;
color: #61dafb;
}
button {
font-size: calc(8px + 2vmin);
padding: 10px 20px;
border-radius: 8px;
border: none;
background-color: #61dafb;
cursor: pointer;
transition: background-color 0.3s;
}
button:hover {
background-color: #4fa8c5;
}
Now, run your app:
npm start
You should see your goal tracker! Click the button to start your streak. Refresh the page or close the tab and reopen it – your streak will still be there. Wait until tomorrow, and you can continue your streak. If you miss a day, it will reset to 0.
Security Best Practices
For this application, localStorage is generally safe. However, remember that localStorage is not secure storage. It's accessible via JavaScript on the same domain, so it's vulnerable to cross-site scripting (XSS) attacks. Never store sensitive information like user tokens or personal data in localStorage. For our goal streak, it's perfectly fine.
Alternative Approaches
- Session Storage: If you only wanted the streak to persist for the current browser session, you could use
sessionStorageinstead oflocalStorage. The API is identical. - Backend Database: For a more robust solution that syncs across devices, you would store the streak information in a backend database associated with a user account. This is more complex but necessary for production applications with user authentication.
- Third-party libraries: Libraries like
date-fnscan make date comparisons more robust and readable, especially when dealing with timezones.
Conclusion
We've successfully built a reusable custom React hook, useGoalStreak, that encapsulates the logic for tracking a daily goal streak. We've seen how to use localStorage for data persistence and how to handle date-based logic to accurately calculate the streak.
This pattern is incredibly versatile. You can use it to track any daily habit, from writing a journal entry to completing a workout.
Next Steps
- Try adding another goal to the app with a different
storageKey. - Implement a "last completed" date display in the UI.
- Add animations or confetti when the streak increases.