Project 9: Habit Tracker with Streaks and Notifications

React Native with Expo: 10 Real Projects · Advanced Apps and Release · 10 min read · Updated

Streak and reminder

  1. The user marks a habit done and today's date key is added.
  2. The streak function counts back through consecutive days.
  3. A separate daily reminder is scheduled once, with permission.
  4. Each evening the phone shows the notification, even if the app is closed.

Habit and reminder apps feel alive because they remember your progress and nudge you at the right time. This project covers two things: a clean way to calculate streaks from dates, and local notifications, which the app schedules on the phone itself with no server.

Concepts in this project

  • Date keys: store each completed day as a text key such as 2026-11-04. Text keys avoid time-zone bugs from storing full timestamps.
  • Streak function: a plain function counts consecutive days ending today, or yesterday if today is not done yet, so a streak is not shown as broken during the day.
  • Local notifications: scheduleNotificationAsync with a daily trigger fires every day at a set time, even when the app is closed.
  • Permission: ask with requestPermissionsAsync. On Android 8 and later you also create a notification channel.
  • Notification handler: tells the app how to show notifications that arrive while it is open.
  • Limits: remote push notifications (sent from a server) need more setup and are outside this lesson. Notifications can behave differently in Expo Go. For a faithful test, use a development build.

Create the project

  1. Use a blank Expo TypeScript project.
  2. Install: npx expo install expo-notifications @react-native-async-storage/async-storage.
  3. Add the plugin entry for expo-notifications to app.json (see lesson 10's permissions file).
  4. Add habits.ts and reminders.ts, and replace App.tsx.

`habits.ts`

import AsyncStorage from "@react-native-async-storage/async-storage";

export type Habit = { id: string; name: string; doneDates: string[] };
const KEY = "habits.v1";

export const dayKey = (d: Date) =>
  `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;

// Counts consecutive days ending today (or yesterday if today is not done yet).
export function streak(doneDates: string[], today = new Date()): number {
  const set = new Set(doneDates);
  const cursor = new Date(today);
  if (!set.has(dayKey(cursor))) cursor.setDate(cursor.getDate() - 1);
  let count = 0;
  while (set.has(dayKey(cursor))) {
    count++;
    cursor.setDate(cursor.getDate() - 1);
  }
  return count;
}

export async function loadHabits(): Promise<Habit[]> {
  const raw = await AsyncStorage.getItem(KEY);
  return raw ? (JSON.parse(raw) as Habit[]) : [];
}

export const saveHabits = (h: Habit[]) => AsyncStorage.setItem(KEY, JSON.stringify(h));

`reminders.ts`

import * as Notifications from "expo-notifications";
import { Platform } from "react-native";

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: false,
    shouldSetBadge: false,
  }),
});

export async function scheduleDailyReminder(hour: number, minute: number): Promise<boolean> {
  if (Platform.OS === "android") {
    await Notifications.setNotificationChannelAsync("habits", {
      name: "Habit reminders",
      importance: Notifications.AndroidImportance.DEFAULT,
    });
  }
  const current = await Notifications.getPermissionsAsync();
  const status = current.granted ? current : await Notifications.requestPermissionsAsync();
  if (!status.granted) return false;

  await Notifications.cancelAllScheduledNotificationsAsync();
  await Notifications.scheduleNotificationAsync({
    content: { title: "Habit check-in", body: "Time to do your habits for today." },
    trigger: {
      type: Notifications.SchedulableTriggerInputTypes.DAILY,
      hour,
      minute,
      channelId: "habits",
    },
  });
  return true;
}

export const cancelReminders = () => Notifications.cancelAllScheduledNotificationsAsync();

`App.tsx`

import { useEffect, useState } from "react";
import { SafeAreaView, View, Text, TextInput, Pressable, FlatList, Switch, Alert, StyleSheet } from "react-native";
import { Habit, dayKey, loadHabits, saveHabits, streak } from "./habits";
import { cancelReminders, scheduleDailyReminder } from "./reminders";

export default function App() {
  const [habits, setHabits] = useState<Habit[]>([]);
  const [name, setName] = useState("");
  const [remind, setRemind] = useState(false);
  const [ready, setReady] = useState(false);
  const today = dayKey(new Date());

  useEffect(() => {
    loadHabits().then((h) => {
      setHabits(h);
      setReady(true);
    });
  }, []);

  useEffect(() => {
    if (ready) saveHabits(habits);
  }, [habits, ready]);

  const add = () => {
    if (!name.trim()) return;
    setHabits((h) => [...h, { id: Date.now().toString(), name: name.trim(), doneDates: [] }]);
    setName("");
  };

  const toggleToday = (id: string) =>
    setHabits((h) =>
      h.map((x) =>
        x.id !== id
          ? x
          : {
              ...x,
              doneDates: x.doneDates.includes(today)
                ? x.doneDates.filter((d) => d !== today)
                : [...x.doneDates, today],
            }
      )
    );

  const toggleReminder = async (on: boolean) => {
    if (!on) {
      await cancelReminders();
      setRemind(false);
      return;
    }
    const ok = await scheduleDailyReminder(20, 0);
    if (!ok) Alert.alert("Notifications are off", "Allow notifications in Settings to get a daily reminder at 8 pm.");
    setRemind(ok);
  };

  return (
    <SafeAreaView style={styles.screen}>
      <Text style={styles.title}>Habits</Text>
      <View style={styles.row}>
        <Text style={styles.grow}>Daily reminder at 8 pm</Text>
        <Switch value={remind} onValueChange={toggleReminder} />
      </View>

      <View style={styles.row}>
        <TextInput style={[styles.input, styles.grow]} value={name} onChangeText={setName} placeholder="New habit" />
        <Pressable style={styles.add} onPress={add}>
          <Text style={styles.addText}>Add</Text>
        </Pressable>
      </View>

      <FlatList
        data={habits}
        keyExtractor={(h) => h.id}
        ListEmptyComponent={<Text style={styles.empty}>Add a habit to start a streak.</Text>}
        renderItem={({ item }) => {
          const done = item.doneDates.includes(today);
          return (
            <View style={styles.card}>
              <View style={styles.grow}>
                <Text style={styles.habit}>{item.name}</Text>
                <Text style={styles.streak}>{streak(item.doneDates)} day streak</Text>
              </View>
              <Pressable style={[styles.done, done && styles.doneOn]} onPress={() => toggleToday(item.id)}>
                <Text style={done ? styles.doneTextOn : undefined}>{done ? "Done" : "Mark done"}</Text>
              </Pressable>
            </View>
          );
        }}
      />
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, padding: 20, backgroundColor: "#f5f6fa" },
  title: { fontSize: 28, fontWeight: "700", marginBottom: 12 },
  row: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 12 },
  grow: { flex: 1 },
  input: { backgroundColor: "#fff", borderRadius: 10, padding: 12, borderWidth: 1, borderColor: "#d0d4dc" },
  add: { backgroundColor: "#3b5bdb", paddingVertical: 12, paddingHorizontal: 16, borderRadius: 10 },
  addText: { color: "#fff", fontWeight: "600" },
  empty: { textAlign: "center", color: "#888", marginTop: 30 },
  card: { flexDirection: "row", alignItems: "center", backgroundColor: "#fff", padding: 14, borderRadius: 12, marginBottom: 8 },
  habit: { fontSize: 17, fontWeight: "600" },
  streak: { color: "#2b8a3e", marginTop: 2 },
  done: { paddingVertical: 8, paddingHorizontal: 14, borderRadius: 18, backgroundColor: "#e3e6ee" },
  doneOn: { backgroundColor: "#2b8a3e" },
  doneTextOn: { color: "#fff", fontWeight: "600" },
});

How it works

  • dayKey makes the date key. streak walks backwards from today while days are in the set.
  • Marking done toggles today's key in the habit's list. The streak is calculated while rendering, not stored.
  • The reminder switch asks for permission, cancels old reminders and schedules one daily at 8 pm. Turning it off cancels all of them.
  • If permission is refused, the switch stays off and an alert explains how to fix it.

Expected output

The screen shows Habits, a reminder switch and a box to add habits. Add Read and Walk. Press Mark done on Read: the button turns green and says Done, and the line reads "1 day streak". Press it again to undo. Turn on the reminder: a permission request appears. Allow it. At 8 pm each day you receive a notification titled Habit check-in. To test faster, change the hour and minute to a time a minute or two ahead. Close and reopen the app: habits and streaks remain.

Your task

  1. Build the app, add two habits and mark one done.
  2. Schedule the reminder for two minutes ahead and confirm that it arrives.
  3. Deny the permission once and check that the app handles it.
  4. Run the streak tests from the testing lesson.

How to check it worked

  • Streaks and habits persist.
  • The reminder arrives on a device.
  • You can explain why dates are stored as text keys.

Frequently asked questions

Local or push notifications?

Local notifications are scheduled by the app on the phone and need no server. Push notifications are sent from a server to many devices, and need a token and a backend.

Why not store timestamps?

A timestamp depends on the time zone. A date key like 2026-11-04 means the same day for the user no matter when you read it.

Why is my notification late or missing?

Check permission, battery saver settings and the channel on Android, and the Focus modes on iPhone. Test on a real device, not an emulator.