What you'll build
By the end of this tutorial you'll have a habit tracker running on your actual phone. Not in a browser tab pretending to be a phone: a real app, installed through Expo Go, that you can close and reopen on the bus.
It does four things. You type a habit into a text field and tap Add. The habit shows up in a list. Once a day you tap Mark done, and a streak counter goes up by one. Miss a day and the streak resets to 1 the next time you check in. All of that is saved to the phone's own storage, so the list and the streaks are still there after you force-quit the app or restart the device.
Every file you need is on this page. There are four of them, each complete: paste them into a fresh create-expo-app project, run one command, and scan a QR code. No backend, no database server, no API key, no account anywhere.
The interesting part is not the CRUD. It's the streak rule, which is the one piece of real logic here and the one place a date bug will bite you. We write it as a pure function with its own file so you can read it in isolation, and the tutorial explains the timezone trap that catches most first attempts.
Why Expo, React Native, and TypeScript
React Native is how you write a phone app in the language you already know. You write React components, and instead of rendering to the DOM, they render to the platform's own native views: a <Text> becomes a UITextView on iOS and a TextView on Android. One codebase, both app stores, no Swift and no Kotlin.
Expo is the part that makes it free and painless to start. Without it, a React Native project means Xcode on a Mac for iOS and Android Studio for Android, both multi-gigabyte installs, both with their own SDK setup. Expo's managed workflow replaces all of that with one command and a free app called Expo Go that you install on your phone from the App Store or Play Store. Your phone becomes the device you test on, over your home wifi, from whatever computer you own. That is the whole reason this tutorial is reachable on a Windows or Linux machine.
TypeScript earns its place here more than it does on the web, because a habit is a shape you pass between three files: the storage layer reads it off the disk, the streak function transforms it, and the list renders it. One typed Habit interface means a renamed field breaks at compile time in all three places instead of silently rendering undefined on a phone screen, which is a far worse place to debug than a browser console.
What Expo costs you is native modules it hasn't wrapped. If you need a library that ships custom native code and Expo has no config plugin for it, you have to leave the managed workflow. For everything in this tutorial, and for most apps a solo learner builds, you will not hit that wall. Storage, navigation, notifications, camera, and location are all covered.
Setup
You need Node installed, and the Expo Go app on your phone. Nothing else. Scaffold the project with Expo's own tool, using the TypeScript template:
npx create-expo-app@latest habit-tracker -t expo-template-blank-typescriptThat gives you a project with a single App.tsx at the root, a tsconfig.json, and nothing else to untangle. The blank template is the right starting point here: the other templates wire up file-based routing with Expo Router, which is excellent and which you'll want for a second screen, but it is one more concept between you and a working app on your phone.
Now add the storage library:
cd habit-tracker
npx expo install @react-native-async-storage/async-storageUse npx expo install, not npm install, for anything with a native side. It is the same install, except Expo picks the package version that matches your SDK release. npm install grabs the newest version on npm, which may expect a different native runtime than the Expo Go on your phone ships, and you find out as a red error screen rather than a build failure.
AsyncStorage is React Native's equivalent of localStorage: an unencrypted, asynchronous, key-value store scoped to your app. Two differences from the browser version matter. It is async, so every read and write returns a Promise. And it only stores strings, so objects go through JSON.stringify on the way in and JSON.parse on the way out. We'll wrap both facts in one small typed module so the rest of the app never thinks about them.
The Habit type and the streak rule
Start with the data and the one real algorithm, in a file with no React in it at all. Create lib/habit.ts:
// One habit, exactly as it is stored on the device.
//
// lastCompletedDate is a plain "YYYY-MM-DD" string rather than a Date, for two
// reasons: JSON has no Date type so it would come back as a string anyway, and
// a day is what we actually compare on, not an instant in time.
export interface Habit {
id: string;
name: string;
streak: number;
lastCompletedDate: string | null;
}
// Today as "YYYY-MM-DD", in the phone's own timezone.
export function dayKey(date: Date = new 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;
}
// The calendar day before a given key. Building the Date from its parts keeps
// this in local time, and setDate handles month and year rollover for us.
export function previousDay(key: string): string {
const [year, month, day] = key.split("-").map(Number);
const date = new Date(year, month - 1, day);
date.setDate(date.getDate() - 1);
return dayKey(date);
}
export function newHabit(name: string): Habit {
return {
id: Date.now().toString(36),
name,
streak: 0,
lastCompletedDate: null,
};
}
// Mark a habit done for the given day. Three cases:
// already done today -> nothing changes
// last done yesterday -> the streak continues
// anything else -> a fresh streak starting at 1
export function completeHabit(habit: Habit, today: string = dayKey()): Habit {
if (habit.lastCompletedDate === today) return habit;
const continued = habit.lastCompletedDate === previousDay(today);
return {
...habit,
streak: continued ? habit.streak + 1 : 1,
lastCompletedDate: today,
};
}completeHabit takes a habit and returns a new one. It never mutates its input and it never touches storage or state, which means you can reason about every case by reading nine lines, and you could test it with no phone and no React in the room.
The timezone trap, because this is where streak code goes wrong. The tempting way to get yesterday is new Date(key) and subtract a day. Don't. A bare "2026-10-06" string is parsed by JavaScript as midnight UTC, not midnight where your user is standing. Someone in Los Angeles who taps Mark done at 6pm gets a date object that already belongs to the next day, so their streak either double-counts or breaks for no visible reason. Building the date from its three parts with new Date(year, month - 1, day) uses local time, which is the timezone the user's own idea of "today" lives in. That one difference is the whole reason dayKey and previousDay exist as separate functions instead of inline arithmetic.
Why the streak resets to 1 and not 0. If you missed two days and then check in, you have in fact done the habit once, today. Resetting to 0 would mean tapping Mark done and watching the counter say you have done nothing, which is both wrong and discouraging. The honest limitation of the rule as written: it has no concept of a habit you only intend to do on weekdays, or three times a week. A real habit app needs a schedule per habit, and that changes this function from "was yesterday the last one" to "was the previous *scheduled* day the last one." Worth knowing before you ship this to anyone.
The storage helper
Next, the only file that knows AsyncStorage exists. It hands the rest of the app two functions that deal in Habit[], so nothing else has to think about string serialization or Promises. Create lib/storage.ts:
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { Habit } from "./habit";
// Versioning the key means a future shape change can ship without reading old
// data it no longer understands.
const STORAGE_KEY = "habit-tracker/habits/v1";
// Anything that comes back off the disk is unknown until we check it. A user
// who ran an older build of your app may have written a different shape.
function isHabit(value: unknown): value is Habit {
if (typeof value !== "object" || value === null) return false;
const habit = value as Record<string, unknown>;
return (
typeof habit.id === "string" &&
typeof habit.name === "string" &&
typeof habit.streak === "number" &&
(habit.lastCompletedDate === null ||
typeof habit.lastCompletedDate === "string")
);
}
export async function loadHabits(): Promise<Habit[]> {
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw === null) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isHabit);
} catch (error) {
// A corrupt or half-written value should not keep the app from opening.
console.warn("Could not read saved habits, starting empty", error);
return [];
}
}
export async function saveHabits(habits: Habit[]): Promise<void> {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(habits));
}Three things in here are worth the extra lines.
`loadHabits` returns `Habit[]`, not `Habit[] | null`. A first run with nothing stored and a run with a corrupt value both come back as an empty array. The app has one code path, not three, and there is no null to forget to handle on a screen you can't easily inspect.
The `isHabit` guard is not paranoia. TypeScript checks your source, and JSON.parse returns any regardless of what you promise it. On the web you'd shrug and move on. On a phone, the data has been sitting on a real device across however many versions of your app the owner has installed, so the one thing you can be sure of is that it was written by code you no longer have. Filtering rather than throwing means one bad row loses one habit instead of the whole list.
The `try/catch` wraps the read, not the write. A failed read has an obvious safe answer: start empty. A failed write does not, so we let it reject and surface rather than swallowing it. If you ever see habits not persisting, you want that error, not silence.
Learn React properly first, free
React Native assumes you're comfortable with React: components, props, useState, and useEffect. If any of that is shaky, our learn path for mobile development sequences the free courses that get you there, and Full Stack Open from the University of Helsinki is the standout free option, with a React Native module of its own after it teaches you React on the web.
The app: load, list, add, and mark done
Now the screen. Replace the generated App.tsx with this. It is the whole app: state, the one-time load, the add field, and the list with its Mark done button.
import { useEffect, useState } from "react";
import { StatusBar } from "expo-status-bar";
import {
FlatList,
Pressable,
SafeAreaView,
Text,
TextInput,
View,
} from "react-native";
import { completeHabit, dayKey, newHabit, type Habit } from "./lib/habit";
import { loadHabits, saveHabits } from "./lib/storage";
import { styles } from "./styles";
export default function App() {
const [habits, setHabits] = useState<Habit[]>([]);
const [draft, setDraft] = useState("");
const [loaded, setLoaded] = useState(false);
// Read the saved habits once, when the app starts.
useEffect(() => {
loadHabits().then((saved) => {
setHabits(saved);
setLoaded(true);
});
}, []);
// Every change goes through here: update the screen, then write to disk.
function commit(next: Habit[]) {
setHabits(next);
void saveHabits(next);
}
function handleAdd() {
const name = draft.trim();
if (!name) return;
commit([...habits, newHabit(name)]);
setDraft("");
}
function handleDone(id: string) {
commit(
habits.map((habit) => (habit.id === id ? completeHabit(habit) : habit)),
);
}
const today = dayKey();
return (
<SafeAreaView style={styles.screen}>
<StatusBar style="dark" />
<View style={styles.container}>
<Text style={styles.title}>Habits</Text>
<View style={styles.addRow}>
<TextInput
style={styles.input}
value={draft}
onChangeText={setDraft}
placeholder="Read 20 pages"
placeholderTextColor="#999"
returnKeyType="done"
onSubmitEditing={handleAdd}
/>
<Pressable style={styles.addButton} onPress={handleAdd}>
<Text style={styles.addButtonText}>Add</Text>
</Pressable>
</View>
<FlatList
data={habits}
keyExtractor={(habit) => habit.id}
contentContainerStyle={styles.list}
renderItem={({ item }) => {
const doneToday = item.lastCompletedDate === today;
return (
<View style={styles.card}>
<View style={styles.cardText}>
<Text style={styles.habitName}>{item.name}</Text>
<Text style={styles.streak}>
{item.streak === 0
? "No streak yet"
: item.streak === 1
? "1 day in a row"
: item.streak + " days in a row"}
</Text>
</View>
<Pressable
style={[styles.doneButton, doneToday && styles.doneButtonOff]}
onPress={() => handleDone(item.id)}
disabled={doneToday}
>
<Text style={styles.doneButtonText}>
{doneToday ? "Done today" : "Mark done"}
</Text>
</Pressable>
</View>
);
}}
ListEmptyComponent={
<Text style={styles.empty}>
{loaded ? "No habits yet. Add one above." : "Loading..."}
</Text>
}
/>
</View>
</SafeAreaView>
);
}Read what changed from the web version of this app you've probably written before.
There is no HTML and no CSS. View is the box, Text is the only thing that can hold a string, TextInput is the field, Pressable is the tappable thing, and FlatList is the list. That last one is not just a styled map: FlatList only mounts the rows that are near the screen and recycles them as you scroll, which is what keeps a long list smooth on a phone's memory budget. Also note that bare text has to be inside a Text. Putting a string directly in a View is a crash, not a styling bug, and it's the first error most people writing React Native hit.
Saving happens in `commit`, not in an effect. The tempting version is useEffect(() => { saveHabits(habits); }, [habits]). That has a real bug: the effect also runs on the very first render, when habits is still the empty initial state and the load from disk hasn't finished, so your app's first act is to write [] over everything the user saved. Routing both mutations through one commit function means a write only ever happens because something actually changed. If you prefer the effect style, you have to guard it on the loaded flag.
`void saveHabits(next)` is deliberate. The screen updates from setHabits immediately and the write to disk finishes a few milliseconds later. There is nothing to wait for and no spinner to show, so we start the Promise and say so with void rather than leaving a floating call that reads like an oversight.
`doneToday` disables the button. completeHabit already refuses to count the same day twice, so this is belt and braces, but it also tells the user why nothing happens when they tap. Deriving it from item.lastCompletedDate === today rather than storing a separate boolean means the state can't get out of sync with the streak.
`today` is computed once per render. Honest limitation: an app left open across midnight keeps the old day until something re-renders. For a habit tracker you open, tap, and close, that is fine. If it bothers you, the fix is an app-state listener that recomputes when the app comes back to the foreground.
Styling
React Native has no stylesheet language. You write plain objects, and StyleSheet.create validates the keys at build time so a typo like paddingHorizantal is an error instead of a line that silently does nothing. Create styles.ts. The app won't run until this file exists, because App.tsx imports it.
import { StyleSheet } from "react-native";
export const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: "#fff",
},
container: {
flex: 1,
padding: 20,
paddingTop: 32,
},
title: {
fontSize: 32,
fontWeight: "700",
marginBottom: 16,
},
addRow: {
flexDirection: "row",
gap: 8,
marginBottom: 20,
},
input: {
flex: 1,
borderWidth: 1,
borderColor: "#ccc",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
},
addButton: {
backgroundColor: "#111",
borderRadius: 8,
paddingHorizontal: 16,
justifyContent: "center",
},
addButtonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
list: {
gap: 10,
paddingBottom: 40,
},
card: {
flexDirection: "row",
alignItems: "center",
borderWidth: 1,
borderColor: "#e5e5e5",
borderRadius: 10,
padding: 14,
},
cardText: {
flex: 1,
},
habitName: {
fontSize: 17,
fontWeight: "600",
},
streak: {
color: "#666",
marginTop: 2,
},
doneButton: {
backgroundColor: "#111",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 8,
},
doneButtonOff: {
backgroundColor: "#bbb",
},
doneButtonText: {
color: "#fff",
fontWeight: "600",
},
empty: {
color: "#666",
fontSize: 16,
},
});Two things that trip up people coming from the web. Everything is flexbox, and the default direction is `column`, not row, which is why the add field and its button need an explicit flexDirection: "row". And there is no inheritance: setting a color on a View does nothing to the Text inside it, so text styles go on the Text itself every time.
The paddingTop: 32 on the container is a blunt instrument. SafeAreaView keeps content clear of the notch on iOS but is a plain View on Android, so the padding covers both without another dependency. The proper fix, once you add a second screen, is react-native-safe-area-context, which gives you the real inset per device.
Run it on your phone
Install Expo Go on your phone from the App Store or Play Store, put the phone on the same wifi as your computer, then start the dev server:
npx expo startA QR code appears in your terminal. On Android, scan it from inside Expo Go. On iOS, scan it with the regular Camera app and tap the banner. Your app opens on the phone in a few seconds, and it hot-reloads every time you save a file. No Xcode, no Android Studio, no emulator, no Mac. Then try the whole thing:
- Type a habit and tap Add. It appears in the list with "No streak yet".
- Tap Mark done. The streak reads "1 day in a row" and the button greys out to "Done today".
- Tap it again. Nothing happens, which is the point:
completeHabitreturns the habit untouched whenlastCompletedDateis already today. - Force-quit the app (swipe it away) and reopen it from Expo Go. Your habits and streaks are still there. That is AsyncStorage doing its job.
- To watch a streak grow without waiting a day, temporarily call
completeHabit(habit, "2026-10-07")fromhandleDonewith tomorrow's date hardcoded. The streak goes to 2 because yesterday matches. Change it to a date three days out and it drops back to 1. - Add a dozen habits and scroll.
FlatListkeeps it smooth because it is only rendering the rows you can see.
If the QR code scan hangs, it is almost always the network: a guest wifi or a VPN can stop your phone from reaching your computer. npx expo start --tunnel routes the connection through Expo's servers instead, which is slower but works from anywhere.
Where to go next
Be clear about what you just learned and what you didn't. You now know the React Native rendering model (native primitives, no DOM, flexbox styling in objects), the Expo workflow that gets a real app onto a real phone for free, and on-device persistence with AsyncStorage behind a typed module. That is the core of a mobile app, and it is the part that transfers to whatever you build next.
Three natural extensions, in the order they get interesting. A second screen, with Expo Router, so tapping a habit opens its history. File-based routing, the same idea as the Next.js App Router. Push notifications with expo-notifications, which is what turns a habit tracker from a thing you remember to open into a thing that reminds you. A real database when the key-value store runs out of road: the moment you want "show me every habit I completed in September", you are asking for a query, and expo-sqlite gives you SQL on the device without changing anything outside lib/storage.ts, because that is the only file that knows where data lives.
One thing worth saying plainly about publishing, since it is the question everyone asks. Getting this onto the Play Store is doable from any computer: Expo's build service compiles it in the cloud, and Google charges a one-off 25 dollars for a developer account. The App Store is the harder one. Apple charges 99 dollars a year, and while Expo's cloud builds mean you don't need a Mac to compile, you will want one eventually for the fiddlier parts of submission and for the iOS simulator. Testing on your own iPhone through Expo Go, which is everything this tutorial does, needs none of that.
For the free courses that get you from here to a mobile developer who can ship, see our mobile development learn path and the mobile developer roadmap. If you want the browser-side version of this same CRUD-plus-persistence shape for comparison, the Markdown notes app tutorial builds it with React and localStorage, and reading the two side by side is the fastest way to see exactly which parts of React carry over to mobile and which parts don't.
Frequently asked questions
Do I need a Mac or Xcode to build this?
No. Expo Go runs this app on your own phone from any computer, Windows, Linux, or Mac, by scanning a QR code over your local wifi. You never install Xcode or Android Studio to finish this tutorial. A Mac only becomes useful at the very end if you want to publish to Apple's App Store, and even then Expo's cloud build service compiles the binary for you; the Mac is for the iOS simulator and the fiddlier parts of submission. Publishing to Google Play needs no Mac at all.
Is AsyncStorage good enough for a real app?
For small, single-device data like this habit list, yes. AsyncStorage is a simple key-value store, so it is a good fit whenever your whole dataset is small enough to read and write in one go: settings, a short list, a cached token. It stops being the right tool when you need to query (show me everything I completed last month), when you have relations between records, when the data grows into the megabytes, or when it has to sync across devices. That is the point to move to expo-sqlite for real SQL on the device, or a backend if syncing is the requirement. Also note it is not encrypted, so secrets belong in expo-secure-store instead.
How is this different from building the same app as a website?
The component model and TypeScript are identical; the rendering target is not. React Native renders to the platform's native views, so you build the UI out of View, Text, TextInput, FlatList, and Pressable instead of div, span, input, ul, and button. There is no DOM, no CSS file, and no browser: styles are plain objects passed through StyleSheet.create, everything is flexbox with column as the default direction, and style properties do not inherit from a parent. Strings must sit inside a Text component or the app crashes. Storage changes too, from the synchronous localStorage to the async AsyncStorage. What carries over unchanged is React itself: components, props, useState, useEffect, and the way you think about state.
What should I learn after this?
Two things, in this order. Navigation with Expo Router, because almost every real app has more than one screen and file-based routing is how Expo does it; adding a per-habit detail screen to this app is a good first exercise. Then push notifications with expo-notifications, which is the feature that makes a habit tracker specifically useful, since the app has to reach the user rather than wait to be opened. After that, swap AsyncStorage for expo-sqlite when you want to query your history rather than just load it.