The tip calculator forgot everything when you closed it. A to-do app must remember. This project adds three ideas: showing a list with FlatList, running code at the right moment with useEffect, and saving small data on the phone with AsyncStorage.
Concepts in this project
- FlatList: draws only visible rows. You pass
data, akeyExtractorthat returns a unique string, andrenderItem.ListEmptyComponentshows a message when the list is empty. - useEffect: runs code after the screen draws. With
[]it runs once at start (load saved tasks). With[todos]it runs whenever tasks change (save them). - AsyncStorage: a simple key-value store that keeps text on the phone. Store objects with
JSON.stringifyand read them withJSON.parse. - Immutable updates:
mapto change one item,filterto remove, spread to add. Never push into the existing array.
Create the project
- Create a new Expo project, or reuse your first one.
- Install the storage library with
npx expo install @react-native-async-storage/async-storage. - Add
storage.tsand replaceApp.tsx.
my-app/
App.tsx list, input, buttons
storage.ts load and save functions
The code
`storage.ts`
import AsyncStorage from "@react-native-async-storage/async-storage";
export type Todo = { id: string; title: string; done: boolean };
const KEY = "todos.v1";
export async function loadTodos(): Promise<Todo[]> {
try {
const raw = await AsyncStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as Todo[]) : [];
} catch {
return [];
}
}
export async function saveTodos(todos: Todo[]): Promise<void> {
await AsyncStorage.setItem(KEY, JSON.stringify(todos));
}
loadTodos returns an empty list if nothing was saved or if reading fails, so the app never crashes on a damaged value.
`App.tsx`
import { useEffect, useState } from "react";
import { SafeAreaView, View, Text, TextInput, FlatList, Pressable, StyleSheet } from "react-native";
import { Todo, loadTodos, saveTodos } from "./storage";
export default function App() {
const [todos, setTodos] = useState<Todo[]>([]);
const [text, setText] = useState("");
const [ready, setReady] = useState(false);
useEffect(() => {
loadTodos().then((t) => {
setTodos(t);
setReady(true);
});
}, []);
useEffect(() => {
if (ready) saveTodos(todos);
}, [todos, ready]);
const add = () => {
const title = text.trim();
if (!title) return;
setTodos((prev) => [{ id: Date.now().toString(), title, done: false }, ...prev]);
setText("");
};
const toggle = (id: string) =>
setTodos((prev) => prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
const remove = (id: string) => setTodos((prev) => prev.filter((t) => t.id !== id));
const clearDone = () => setTodos((prev) => prev.filter((t) => !t.done));
const left = todos.filter((t) => !t.done).length;
return (
<SafeAreaView style={styles.screen}>
<Text style={styles.title}>To-Do</Text>
<View style={styles.row}>
<TextInput
style={[styles.input, styles.grow]}
value={text}
onChangeText={setText}
placeholder="New task"
onSubmitEditing={add}
returnKeyType="done"
/>
<Pressable style={styles.add} onPress={add}>
<Text style={styles.addText}>Add</Text>
</Pressable>
</View>
<Text style={styles.count}>
{left} left, {todos.length - left} done
</Text>
<FlatList
data={todos}
keyExtractor={(t) => t.id}
ListEmptyComponent={<Text style={styles.empty}>Nothing to do. Add your first task above.</Text>}
renderItem={({ item }) => (
<View style={styles.item}>
<Pressable style={styles.grow} onPress={() => toggle(item.id)}>
<Text style={[styles.itemText, item.done && styles.done]}>
{item.done ? "☑ " : "☐ "}
{item.title}
</Text>
</Pressable>
<Pressable onPress={() => remove(item.id)}>
<Text style={styles.del}>Delete</Text>
</Pressable>
</View>
)}
/>
{todos.some((t) => t.done) && (
<Pressable onPress={clearDone}>
<Text style={styles.clear}>Clear finished tasks</Text>
</Pressable>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 20, backgroundColor: "#f5f6fa" },
title: { fontSize: 28, fontWeight: "700", marginBottom: 12 },
row: { flexDirection: "row", gap: 8, alignItems: "center" },
grow: { flex: 1 },
input: { backgroundColor: "#fff", borderRadius: 10, padding: 12, fontSize: 16, borderWidth: 1, borderColor: "#d0d4dc" },
add: { backgroundColor: "#3b5bdb", paddingVertical: 12, paddingHorizontal: 16, borderRadius: 10 },
addText: { color: "#fff", fontWeight: "600" },
count: { marginVertical: 12, color: "#555" },
empty: { textAlign: "center", color: "#888", marginTop: 40 },
item: { flexDirection: "row", alignItems: "center", backgroundColor: "#fff", padding: 14, borderRadius: 10, marginBottom: 8 },
itemText: { fontSize: 16 },
done: { textDecorationLine: "line-through", color: "#999" },
del: { color: "#d6336c" },
clear: { textAlign: "center", color: "#3b5bdb", padding: 12 },
});
How it works
- The first
useEffectloads saved tasks once and then setsready. - The second saves tasks every time they change, but only after
ready, so the empty starting list does not overwrite saved data. add,toggle,removeandclearDoneeach build a new list.leftis calculated while rendering fromtodos. It is not stored separately, so it cannot be wrong.
Expected output
You see the title, a text box with an Add button, the line "0 left, 0 done" and the message "Nothing to do. Add your first task above." Add Buy milk and Call Dad: both appear, and the line reads "2 left, 0 done". Tap Buy milk: it gets a line through it and the line reads "1 left, 1 done". A Clear finished tasks link appears. Close the app fully and open it again: the tasks are still there.
Your task
- Build the app and check the example. Then force-close the app and reopen it.
- Add an edit feature: tapping the task title opens it in the text box.
- Show "All done, well done!" when every task is ticked.
- Sort tasks so unfinished ones come first.
How to check it worked
- Tasks survive closing the app.
- Ticking, deleting and clearing all work and the counter updates.
- You can explain why the save effect waits for ready.