Habit Tracker Mobile App
Build a daily habits tracker with streak logic, reminders, and simple mobile UI
Time to implement the project: ~ 12-18 hours
- React Native
- Mobile UI Design
- State Management
- Local Storage
- User Interaction
In this beginner React Native project, you will build a mobile application that allows users to create, track, and manage daily habits. The app should include a list of habits where each item can be marked as completed for the day. You will also implement streak tracking logic, meaning the app should count how many consecutive days a habit has been completed without interruption.
The application must include basic reminder functionality or simulated notifications to encourage daily engagement. You will design a simple mobile interface where users can add new habits, view current streaks, and interact with daily tasks. Data should persist locally so that habits and progress remain available after restarting the app. The focus is on building a reliable, user-friendly experience rather than adding complex features.
What You Will Learn from This Project
This project is designed to introduce core concepts of mobile development using React Native. You will learn how to manage application state in response to user actions, handle dynamic lists, and update the UI based on changes in data. Tracking streaks will also require you to think about time-based logic and how data evolves across sessions.
Additionally, you will gain experience designing mobile-friendly interfaces and structuring application logic so it remains easy to extend in the future.
Requirements and Prerequisites
To complete this project successfully, you should have a basic understanding of JavaScript and React Native fundamentals. The task focuses on simple data handling and UI updates rather than complex system design.
- Basic knowledge of JavaScript variables and arrays
- Understanding of React Native components and JSX
- Ability to handle simple user input and events
- Familiarity with local storage concepts
- Basic understanding of mobile UI structure
Core Requirements for the Habit Tracker
The application should remain simple but functional. Each feature should serve a clear purpose and improve the usability of the app. The focus is on correctness, consistency, and smooth interaction rather than advanced design patterns.
| Requirement | Explanation |
| Habit creation and listing | Users must be able to add habits and view them in a structured list for daily tracking. |
| Daily completion toggle | Each habit should support marking completion status for the current day. |
| Streak tracking logic | The system should calculate consecutive completion days to motivate consistent usage. |
| Local data persistence | All habits and progress must be stored locally to survive app restarts. |
| Reminder or notification simulation | The app should encourage users to complete habits through reminders. |
| Mobile-friendly layout | The interface must be simple, readable, and optimized for small screens. |
Tips for Building a Reliable Habit Tracker
Start by defining the data structure for a habit, including its name, completion status, and streak count. Keep the logic simple and avoid overengineering early. Focus on ensuring that each interaction updates the state correctly and reflects immediately in the UI. Testing daily streak logic carefully is important, as small mistakes can break user expectations.
Prioritize usability and clarity in your design so users can quickly understand how to interact with the app.
- Keep the habit data model simple and consistent across components
- Test streak calculations with different edge cases such as missed days
- Use clear buttons and labels to improve user understanding
- Ensure local storage updates correctly after every interaction
- Design the layout so it remains usable on small mobile screens
Common Mistakes When Building a Habit Tracker Mobile App
1. Treating habits like simple to-do items
A habit tracker is not just a to-do list with different labels. A to-do item is usually completed once and then disappears or stays archived. A habit is recurring by
design: it can repeat every day, on selected weekdays, several times per week, or according to a custom routine. If you model habits as simple tasks with only
title and completed, the app will become difficult to extend as soon as you add streaks, reminders, weekly goals, history, or progress charts.
This mistake usually appears early because the first screen looks simple: a list of habits with checkboxes. But behind that screen, the app needs to answer more complex questions: Was this habit completed today? Was it completed yesterday? Does it count on weekends? Should it appear on Wednesday? Should one missed day break the streak? These questions cannot be answered reliably if the whole habit is reduced to one boolean value.
For a mobile habit tracker, the data model should separate the habit definition from the completion history. The habit definition describes what the user wants to track. The completion history records what happened on specific dates. This structure makes the app ready for progress calendars, streak calculations, reminders, and statistics.
Problematic approach:
type Habit = {
id: string;
title: string;
completed: boolean;
};
const habits = [
{
id: "habit_1",
title: "Drink water",
completed: true
}
];
This model can only tell whether a habit is completed right now. It cannot tell when it was completed, how often it should repeat, or whether today is even a valid tracking day for that habit.
Better approach:
type HabitFrequency = "daily" | "weekly" | "custom";
type Weekday =
| "monday"
| "tuesday"
| "wednesday"
| "thursday"
| "friday"
| "saturday"
| "sunday";
type Habit = {
id: string;
title: string;
frequency: HabitFrequency;
activeDays: Weekday[];
reminderTime?: string;
createdAt: string;
};
type HabitCompletion = {
habitId: string;
date: string;
completedAt: string;
};
Checking whether a habit is completed today:
function getTodayKey(): string {
return new Date().toISOString().slice(0, 10);
}
function isHabitCompletedToday(
habit: Habit,
completions: HabitCompletion[]
): boolean {
const today = getTodayKey();
return completions.some((completion) => {
return completion.habitId === habit.id && completion.date === today;
});
}
Pay attention to: Model habits as recurring behaviors, not one-time tasks. Store the habit settings separately from the daily completion records. This gives your app enough structure for streaks, statistics, reminders, and calendar views.
2. Calculating streaks from the visible list instead of completion history
Streaks are one of the most motivating features in a habit tracker, but they are also easy to calculate incorrectly. A common mistake is calculating the streak from the
current visible habit list or from a single completed flag. That may work for a demo, but it fails when the user closes the app, changes the date, edits
the habit schedule, or completes habits on non-consecutive days.
A streak should be based on dated completion records. The app should walk backward from today and check whether the habit was completed on each expected tracking day. For a daily habit, missing yesterday usually breaks the streak. For a weekly habit, the logic may be different: the user may need three completions in a week, or completion on specific weekdays. This is why the streak logic should live in a small utility function instead of being scattered across UI components.
It is also important to avoid making streaks feel unfair. Users often open mobile apps late at night, after midnight, or across time zones. If your date logic uses raw UTC dates without thinking about the user's local day, the app may mark a habit as missed even though the user completed it “today” from their perspective.
Problematic approach:
function getStreak(habit: Habit): number {
if (habit.completed) {
return habit.streak + 1;
}
return 0;
}
This logic does not know when the completion happened. It also risks increasing the streak multiple times if the user taps the same habit repeatedly.
Better approach:
function getLocalDateKey(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function subtractDays(date: Date, days: number): Date {
const nextDate = new Date(date);
nextDate.setDate(nextDate.getDate() - days);
return nextDate;
}
function getCompletionDateSet(
habitId: string,
completions: HabitCompletion[]
): Set<string> {
return new Set(
completions
.filter((completion) => completion.habitId === habitId)
.map((completion) => completion.date)
);
}
function calculateDailyStreak(
habitId: string,
completions: HabitCompletion[]
): number {
const completionDates = getCompletionDateSet(habitId, completions);
let streak = 0;
let dayOffset = 0;
while (true) {
const dateKey = getLocalDateKey(subtractDays(new Date(), dayOffset));
if (!completionDates.has(dateKey)) {
break;
}
streak += 1;
dayOffset += 1;
}
return streak;
}
Prevent duplicate completions for the same day:
function completeHabitToday(
habit: Habit,
completions: HabitCompletion[]
): HabitCompletion[] {
const today = getLocalDateKey(new Date());
const alreadyCompleted = completions.some((completion) => {
return completion.habitId === habit.id && completion.date === today;
});
if (alreadyCompleted) {
return completions;
}
return [
...completions,
{
habitId: habit.id,
date: today,
completedAt: new Date().toISOString()
}
];
}
Pay attention to: Streaks should come from completion history, not from a temporary UI flag. Store one completion per habit per date, use local date keys, and keep streak calculation in a testable utility function.
3. Saving mobile app data without hydration and fallback states
Habit tracker apps often store data locally, especially if the app is privacy-first or does not require login. Local storage is useful, but it introduces a mobile-specific problem: the app must load saved habits before rendering the final UI. If you render the habit list immediately with empty state and then load saved data later, users may briefly see “No habits yet,” which feels like their data disappeared.
Another issue is corrupted or outdated storage. Mobile apps can stay installed for months. During that time, your data model may change: maybe a habit used to have only
title, but now it needs frequency, activeDays, or reminderTime. If you read storage without validation, one old
record can break the entire screen.
A clean implementation should have an explicit hydration state. While the app loads saved data, show a loading screen or skeleton. After storage is loaded and validated, render the real habit list or the real empty state. This makes the app feel reliable and prevents users from thinking their habits were deleted.
Problematic approach:
const [habits, setHabits] = useState<Habit[]>([]);
useEffect(() => {
async function loadHabits() {
const saved = await AsyncStorage.getItem("habits");
if (saved) {
setHabits(JSON.parse(saved));
}
}
loadHabits();
}, []);
This code has no loading state, no validation, and no recovery path if the saved JSON is invalid. The user may see an empty list before the real data appears.
Better approach:
type StorageState =
| { status: "loading" }
| { status: "ready"; habits: Habit[]; completions: HabitCompletion[] }
| { status: "error"; message: string };
function isHabit(value: unknown): value is Habit {
if (!value || typeof value !== "object") {
return false;
}
const habit = value as Record<string, unknown>;
return (
typeof habit.id === "string" &&
typeof habit.title === "string" &&
typeof habit.frequency === "string" &&
Array.isArray(habit.activeDays) &&
typeof habit.createdAt === "string"
);
}
async function loadHabitData(): Promise<StorageState> {
try {
const habitsRaw = await AsyncStorage.getItem("habits");
const completionsRaw = await AsyncStorage.getItem("habitCompletions");
const habitsUnknown: unknown = habitsRaw ? JSON.parse(habitsRaw) : [];
const completionsUnknown: unknown = completionsRaw
? JSON.parse(completionsRaw)
: [];
const habits = Array.isArray(habitsUnknown)
? habitsUnknown.filter(isHabit)
: [];
const completions = Array.isArray(completionsUnknown)
? completionsUnknown.filter(isHabitCompletion)
: [];
return {
status: "ready",
habits,
completions
};
} catch {
return {
status: "error",
message: "Saved habit data could not be loaded."
};
}
}
Rendering state safely:
if (storageState.status === "loading") {
return <LoadingHabitsScreen />;
}
if (storageState.status === "error") {
return (
<ErrorState
title="Could not load habits"
description={storageState.message}
/>
);
}
if (!storageState.habits.length) {
return <EmptyHabitsState />;
}
return (
<HabitList
habits={storageState.habits}
completions={storageState.completions}
/>
);
Pay attention to: Local-first apps need careful loading behavior. Add hydration state, validate stored data, and show the empty state only after storage has actually been loaded.
4. Creating reminders without a clear permission and rescheduling strategy
Notifications can make a habit tracker much more useful, but they can also make the app feel annoying or unreliable. A common mistake is asking for notification permission as soon as the app opens, before the user has created any habit or understood why reminders matter. On mobile, this is especially harmful because users may deny permission once and never enable it again.
Another frequent problem is scheduling reminders only once when the habit is created. If the user changes the reminder time, disables a habit, edits active weekdays, or deletes the habit, old notifications may still fire. The app then reminds users about habits they no longer track, which makes the product feel broken.
A better strategy is to ask for permission only when the user intentionally enables reminders for a habit. Store the notification IDs linked to the habit, and reschedule them whenever reminder settings change. If the habit is deleted or reminders are disabled, cancel the scheduled notifications.
Problematic approach:
useEffect(() => {
Notifications.requestPermissionsAsync();
}, []);
async function createHabit(input: CreateHabitInput) {
const habit = createHabitFromInput(input);
await Notifications.scheduleNotificationAsync({
content: {
title: habit.title,
body: "Time to complete your habit."
},
trigger: {
hour: 9,
minute: 0,
repeats: true
}
});
saveHabit(habit);
}
This asks for permission too early and does not connect the scheduled notification to the habit record. Later, the app will not know which notification belongs to which habit.
Better reminder model:
type HabitReminder = {
enabled: boolean;
time: string;
notificationIds: string[];
};
type Habit = {
id: string;
title: string;
frequency: "daily" | "weekly" | "custom";
activeDays: Weekday[];
reminder: HabitReminder;
createdAt: string;
};
Permission and scheduling flow:
async function ensureNotificationPermission(): Promise<boolean> {
const currentStatus = await Notifications.getPermissionsAsync();
if (currentStatus.granted) {
return true;
}
const requestedStatus = await Notifications.requestPermissionsAsync();
return requestedStatus.granted;
}
async function scheduleHabitReminder(habit: Habit): Promise<string[]> {
if (!habit.reminder.enabled) {
return [];
}
const hasPermission = await ensureNotificationPermission();
if (!hasPermission) {
return [];
}
const [hour, minute] = habit.reminder.time.split(":").map(Number);
const notificationId = await Notifications.scheduleNotificationAsync({
content: {
title: habit.title,
body: "A small check-in keeps the streak alive."
},
trigger: {
hour,
minute,
repeats: true
}
});
return [notificationId];
}
Cancel old reminders before updating:
async function cancelHabitReminders(habit: Habit): Promise<void> {
await Promise.all(
habit.reminder.notificationIds.map((notificationId) => {
return Notifications.cancelScheduledNotificationAsync(notificationId);
})
);
}
async function updateHabitReminder(habit: Habit, nextTime: string): Promise<Habit> {
await cancelHabitReminders(habit);
const updatedHabit = {
...habit,
reminder: {
...habit.reminder,
enabled: true,
time: nextTime,
notificationIds: []
}
};
const notificationIds = await scheduleHabitReminder(updatedHabit);
return {
...updatedHabit,
reminder: {
...updatedHabit.reminder,
notificationIds
}
};
}
Pay attention to: Notification UX is part of product trust. Ask permission only after clear user intent, store notification IDs, cancel outdated reminders, and reschedule notifications when habit settings change.
5. Rendering long habit histories without mobile performance planning
Habit apps can accumulate a lot of small records: daily completions, weekly summaries, calendar marks, streak history, rewards, notes, and statistics. A screen may look lightweight with five demo habits, but after several months the user may have thousands of completion records. If you render everything at once, the app can feel slow, especially on older Android devices.
The most common version of this mistake is using ScrollView for a growing list because it feels easier than FlatList.
ScrollView renders all children immediately. That is fine for a short settings screen, but it is not appropriate for a long habit list, completion history,
or statistics feed. For dynamic lists, React Native's list components are a better fit because they virtualize rows and render only what is needed.
Performance planning also applies to derived statistics. Do not recalculate full-year heatmaps, longest streaks, and completion percentages on every render if the underlying data has not changed. Use memoized selectors or utility functions that run only when habits or completions change. This keeps the app smooth and protects battery life.
Problematic approach:
function HabitHistoryScreen({ completions }) {
return (
<ScrollView>
{completions.map((completion) => (
<CompletionRow
key={`${completion.habitId}-${completion.date}`}
completion={completion}
/>
))}
</ScrollView>
);
}
This renders every history row at once. As the history grows, the screen may become slower to open and more expensive to update.
Better list rendering:
function HabitHistoryScreen({ completions }) {
return (
<FlatList
data={completions}
keyExtractor={(completion) => {
return `${completion.habitId}-${completion.date}`;
}}
renderItem={({ item }) => (
<CompletionRow completion={item} />
)}
initialNumToRender={12}
windowSize={7}
removeClippedSubviews
/>
);
}
Memoized statistics:
const habitStats = useMemo(() => {
return habits.map((habit) => {
const streak = calculateDailyStreak(habit.id, completions);
const completionRate = calculateCompletionRate(habit, completions);
return {
habitId: habit.id,
streak,
completionRate
};
});
}, [habits, completions]);
Pay attention to: Use FlatList for growing lists, keep keys stable, avoid recalculating statistics on every render, and test the app with
realistic data volume instead of only three demo habits.
6. Making habit completion hard to use on a real phone
Mobile habit trackers live or die by daily usability. If completing a habit requires too many taps, tiny touch targets, unclear feedback, or a confusing screen hierarchy, users will stop using the app even if the code is technically correct. Habit tracking should feel fast: open the app, see today's habits, check one off, and move on.
Beginners sometimes design habit cards like desktop components: small buttons, dense text, tiny checkboxes, and long descriptions. On a phone, that creates friction. Users may be walking, tired, or checking habits quickly before bed. The UI should have large pressable areas, immediate visual feedback, and clear separation between completing a habit and editing a habit.
Accessibility matters here too. A checkbox without a clear label is harder for screen readers. Color-only feedback is not enough for completion state. A vibration or subtle animation can be helpful, but it should not replace a text or icon state that users can understand.
Problematic approach:
<View style={styles.row}>
<Text>{habit.title}</Text>
<Pressable onPress={() => completeHabit(habit.id)}>
<Text>✓</Text>
</Pressable>
</View>
This may work visually, but the pressable area can be too small, the accessibility label is missing, and users do not get enough feedback about the completed state.
Better mobile interaction:
function HabitCard({ habit, completedToday, onToggle }) {
return (
<Pressable
onPress={() => onToggle(habit.id)}
accessibilityRole="button"
accessibilityLabel={
completedToday
? `${habit.title} completed today. Tap to undo.`
: `${habit.title} not completed today. Tap to complete.`
}
style={[
styles.card,
completedToday && styles.completedCard
]}
>
<View style={styles.content}>
<Text style={styles.title}>{habit.title}</Text>
<Text style={styles.meta}>
{completedToday ? "Completed today" : "Not completed yet"}
</Text>
</View>
<View style={styles.statusIcon}>
<Text>{completedToday ? "✓" : "○"}</Text>
</View>
</Pressable>
);
}
Touch target styling idea:
const styles = StyleSheet.create({
card: {
minHeight: 64,
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 16
},
content: {
flex: 1
},
title: {
fontSize: 16,
fontWeight: "600"
},
meta: {
marginTop: 4,
fontSize: 13
}
});
Pay attention to: Design completion as a fast mobile action. Use large touch targets, accessible labels, clear completed states, and immediate feedback. The best habit tracker is not only correct; it is easy to use every day.
By completing this project, you'll gain a solid understanding of building a mobile application with React Native, managing user-driven state, and implementing basic persistence. You will learn how to track progress over time, design simple mobile interfaces, and create interactive features that respond to user behavior. This foundation will prepare you for more advanced mobile applications with complex logic and real-time features.
Reference Implementations Worth Studying
Direct React Native habit tracker reference:
artsiomshaitar - Habits Tracker React Native
This is the most direct reference for a clean React Native habit tracker. The project is built with React Native, Expo, and Tailwind-style styling through NativeWind. It is described as an iOS-first simple habits tracker that supports daily and weekly tasks, shows progress, and stores data locally.
Pay particular attention to:
- How the app separates screens, components, hooks, storage, types, and utilities.
- How daily and weekly tasks can be represented differently inside one habit tracker.
- How progress display makes the app feel more useful than a plain checklist.
- How local storage supports an offline-first habit tracking experience.
- How Expo and NativeWind can speed up mobile development without building a custom native setup first.
Use this repository as the closest practical baseline. It is especially helpful for understanding how a small mobile habit tracker can be structured with Expo while still leaving room for better streak logic, reminders, statistics, and accessible habit cards.
More complete privacy-first habit app reference:
0xPratikPatil - Habit Flow
This implementation is more product-oriented because it expands the habit tracker into a broader daily routine app. It is built with Expo and React Native for Android and iOS, stores data locally for privacy, and includes timeline management, quick habit completion, smart notifications, energy monitoring, rewards, streaks, statistics, and a full-year calendar heatmap.
When studying the code, focus on:
- How privacy-first local storage changes the architecture compared with account-based apps.
- How timeline and timetable features help users plan habits during the day instead of only checking them off.
- How streaks, longest runs, completion history, and heatmaps make progress visible over time.
- How notifications can support habits without requiring cloud sync or login.
- How rewards and energy tracking add motivation features beyond simple completion state.
Use this repository as the stronger feature reference. It shows how a Habit Tracker Mobile App can become a real productivity product with daily planning, reminders, statistics, motivation systems, and local-first privacy.
Minimal local-data implementation reference:
dnlthn - React Native Habit Tracker
This repository is useful as a minimal alternative because it keeps the concept small and focused. It is a minimal habit tracker built in React Native, runs through
yarn start, and saves all data locally without requests to an outside data source. That makes it a helpful comparison point for a beginner-friendly offline
habit tracking flow.
While reviewing this project, examine:
- How a habit tracker can work without authentication, backend APIs, or cloud sync.
- How local-only data simplifies the product but increases responsibility for safe persistence.
- How folders such as components, containers, data, layouts, and habit screens can organize a small React Native app.
- How the app can be launched through Expo, an iOS emulator, or an Android emulator during development.
- What you would add in a modern version: typed models, storage validation, reminders, streak calculations, and accessible touch interactions.
Use this implementation as the simplest local-first comparison. It is not the most feature-rich option, but it helps learners see the core habit-tracking idea before adding more advanced scheduling and analytics behavior.