”Who This Guide Is For
This guide is for React developers building habit tracking and gamification features. You should have solid understanding of React hooks, browser storage APIs, and date handling. If you're creating wellness apps, habit trackers, goal-setting applications, or any product requiring user engagement features, this guide is for you.
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.
”Key Definition: Custom React Hooks & localStorage Custom React Hooks are functions that start with "use" and can call other hooks, allowing you to extract and reuse stateful logic between components. Unlike class components, hooks can't be used inside loops or conditions—they must be called in the same order on every render. localStorage is a synchronous web storage API that persists data as key-value strings with no expiration time, typically limited to 5-10MB per domain. For habit tracking apps, localStorage provides perfect persistence since streak data is small (kilobytes) and needs to survive page closes. According to LocalStorage browser compatibility stats, localStorage has 98%+ support across modern browsers. The combination—custom hooks for state logic, localStorage for persistence—creates reusable, maintainable streak tracking components that work across sessions and devices.
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.
Frequently Asked Questions
How does the streak handle timezone changes or travel across time zones?
The isSameDay function compares local calendar dates using getFullYear(), getMonth(), and getDate(), which automatically accounts for timezone changes. When a user travels across time zones, the browser's local time adjusts, and the streak logic uses the user's current local date. This means if a user completes a goal at 11 PM in New York, then flies to London (5 hours ahead), they have until 11 PM London time the next day to maintain their streak—effectively giving them extra time when traveling east. For strict midnight-to-midnight regardless of timezone, store completion times in UTC and compare UTC dates instead.
What happens if a user clears their browser data or uses incognito mode?
localStorage is cleared when users: 1) Manually clear browser data, 2) Use incognito/private browsing (data persists during session, cleared on close), 3) Uninstall the browser or reset settings. In these cases, streak data is lost and resets to 0. Solutions: 1) Add backup sync—periodically save streak data to your backend server, 2) Multiple storage layers—combine localStorage with sessionStorage or IndexedDB for redundancy, 3) Export functionality—let users export/import their streak data as JSON, 4) Clear warning—show a warning if localStorage is unavailable or cleared. For critical streak data, backend storage is the only reliable solution—localStorage should be treated as a cache, not the source of truth.
Can I use this pattern for hourly, weekly, or monthly streaks instead of daily?
Yes, modify the date comparison logic. For weekly streaks, compare ISO weeks: const getWeek = (d) => getISOWeek(d); if (getWeek(today) === getWeek(lastCompletion) + 1) continueStreak(). For monthly streaks, compare year and month: if (today.getFullYear() === lastCompletion.getFullYear() && today.getMonth() === lastCompletion.getMonth() + 1). For hourly habits (e.g., "drink water every hour"), compare timestamps directly: const hoursDiff = (today - lastCompletion) / (1000 * 60 * 60); if (hoursDiff <= 2) continueStreak(). Adjust the reset condition accordingly—weekly streaks reset if a full week is missed, monthly if a month passes. The core pattern—state, date comparison, conditional increment—remains the same.
How do I add streak recovery options for users who missed one day?
Streak recovery or freeze features improve user engagement by reducing penalty for minor lapses. Implement recovery tokens: const freezesAvailable = 3; const useFreeze = () => { if (freezesAvailable > 0) { setFreezesAvailable(f - 1); restoreStreak(); } }. Track streak history: store an array of completion dates, allow users to retroactively mark a missed day as complete (with limitations—only past 7 days, max 2 recovery uses per month). Pre-check-in feature lets users mark future completions in advance. Streak insurance—users earn a recovery day after every 7-day streak. Show these options when a streak would otherwise break: "Oops! You missed yesterday. Use a streak freeze to keep your 42-day streak going?"