Building a React Water Tracker
Introduction
I recently completed Meta's React Basics Course, and while the final project — a calculator app — was a solid introduction, I wanted to challenge myself with something more complex. I decided to build a water consumption tracker that would push me to really understand React's core concepts.
Why a water tracker? It's a practical app people could actually use, and it gave me the perfect excuse to dive deep into state management, localStorage persistence, time-based logic, and component architecture. Plus, it's a nice homage to the classic React counter tutorial — just with a lot more functionality!
What the App Does
The app tracks daily water consumption with a simple click interface. Users click "Add Cup" to log each glass of water they drink. The app displays a personalised greeting based on time of day, a weekly total of cups consumed, a daily cup count capped at 8, and motivational messages that change based on progress.
The clever part? All data persists in localStorage, the daily counter automatically resets at midnight, the weekly counter resets every Monday, and there's a cooldown timer to prevent spam clicking.
Core React Concepts I Applied
Component Architecture
I broke the app into four focused components: Greeting.jsx for the personalised time-based greeting, WeeklyTotal.jsx to display the accumulated weekly count, Counter.jsx for the main interaction, and Summary.jsx for contextual motivational messages. Each component has a single responsibility, making the code easier to maintain and understand.
Lifting State Up
This was one of the most important patterns I learned. Both dailyCount and weeklyCount need to be updated when the button is clicked, and multiple components need to display this data. The solution is to keep state in the parent App.jsx and pass it down as props:
const [dailyCount, setDailyCount] = useState(0);
const [weeklyCount, setWeeklyCount] = useState(0);
<Counter count={dailyCount} onDrink={handleDrinkCup} />
<WeeklyTotal weeklyCount={weeklyCount} />
Props and Callback Functions
The Counter component receives count (data flowing down) and onDrink (a function to update parent state). This pattern of "data down, events up" clicked for me during this project. The child component doesn't need to know how to update state — it just calls the function the parent provides.
Conditional Rendering
I used two different approaches for conditional logic. A switch statement in Greeting.jsx for discrete time-of-day cases:
switch (timeOfDay) {
case 'morning':
greeting = `Good morning, ${user}!`;
break;
case 'afternoon':
greeting = `Good afternoon, ${user}!`;
break;
}
And if/else in Summary.jsx for ranges:
if (count === 0) {
message = "Start your hydration journey!";
} else if (count <= 2) {
message = "Good start, keep going!";
} else if (count <= 5) {
message = "You're doing well, stay hydrated!";
}
Advanced Features: Where It Got Interesting
localStorage Persistence
This was my first time implementing data persistence. Reading from localStorage on initialisation uses lazy initialisation:
const [dailyCount, setDailyCount] = useState(() => {
const saved = localStorage.getItem('dailyCount');
return saved ? JSON.parse(saved) : 0;
});
Writing to localStorage whenever state changes uses useEffect:
useEffect(() => {
localStorage.setItem('dailyCount', JSON.stringify(dailyCount));
}, [dailyCount]);
The key learning: localStorage only stores strings, so JSON.stringify() is needed to save and JSON.parse() to retrieve. I also added error handling with try/catch in case the data gets corrupted.
Time-Based Reset Logic
This was the most challenging feature. I needed the daily counter to reset at midnight and the weekly counter to reset every Monday. The approach was to save a timestamp whenever a cup is added, compare it to today on app load, and reset the appropriate counter if it's a different day or week.
Daily reset — the isNewDay function:
function isNewDay(savedDateString) {
if (!savedDateString) return true;
const savedDate = new Date(savedDateString);
const today = new Date();
return (
savedDate.getFullYear() !== today.getFullYear() ||
savedDate.getMonth() !== today.getMonth() ||
savedDate.getDate() !== today.getDate()
);
}
Weekly reset — the isNewWeek function:
This was trickier. I needed to figure out which Monday each date belongs to and handle the Sunday edge case — Sunday is 6 days from Monday, not -1:
function isNewWeek(savedDateString) {
if (!savedDateString) return true;
const savedDate = new Date(savedDateString);
const today = new Date();
const currentDay = today.getDay();
const daysFromMonday = currentDay === 0 ? 6 : currentDay - 1;
const currentMonday = new Date(today);
currentMonday.setDate(today.getDate() - daysFromMonday);
currentMonday.setHours(0, 0, 0, 0);
const savedDay = savedDate.getDay();
const savedDaysFromMonday = savedDay === 0 ? 6 : savedDay - 1;
const savedMonday = new Date(savedDate);
savedMonday.setDate(savedDate.getDate() - savedDaysFromMonday);
savedMonday.setHours(0, 0, 0, 0);
return currentMonday.getTime() !== savedMonday.getTime();
}
setHours(0, 0, 0, 0) was crucial — without it I was comparing exact millisecond timestamps, which would always be different even for the same Monday.
Lazy initialisation then checks for resets before setting initial state, so users never see a flash of incorrect data:
const [dailyCount, setDailyCount] = useState(() => {
const saved = localStorage.getItem('dailyCount');
const lastDrinkTime = localStorage.getItem('lastDrinkTime');
if (isNewDay(lastDrinkTime)) return 0;
return saved ? JSON.parse(saved) : 0;
});
Button Cooldown Timer
To prevent spam clicking I added a 30-second cooldown:
const [isOnCooldown, setIsOnCooldown] = useState(false);
const handleDrinkCup = () => {
setDailyCount(prev => prev + 1);
setWeeklyCount(prev => prev + 1);
localStorage.setItem('lastDrinkTime', new Date().toISOString());
setIsOnCooldown(true);
setTimeout(() => setIsOnCooldown(false), 30000);
};
<button disabled={count >= 8 || isOnCooldown}>
Challenges & What I Learned
Date logic is hard. Calculating which week a date belongs to took multiple attempts. Breaking the problem into smaller pieces and testing edge cases like Sunday was the key to getting it right.
The functional form of setState. Using prev => prev + 1 instead of count + 1 ensures you're always working with the latest state value — especially important when updating multiple states in one function.
localStorage can contain bad data. Always validate what you retrieve — localStorage might contain "undefined" or corrupted JSON. Error handling here is essential.
Design before code. Mocking up the UI in Figma first saved significant time. Knowing exactly what I was building meant less CSS experimentation.
Next Steps
Now that the core functionality works, the plan is to enhance the counter UI and add data visualisation to the weekly total component.