Project 2: To-Do List with FlatList and AsyncStorage

React Native with Expo: 10 Real Projects · Building Real Apps · 7 min read · Updated

Saved on the phone

  1. The user adds or ticks a task. The handler creates a new array.
  2. The state changed, so the save effect runs.
  3. The effect writes the list as JSON text.
  4. Next time the app opens, the load effect reads it back into state.

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, a keyExtractor that returns a unique string, and renderItem. ListEmptyComponent shows 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.stringify and read them with JSON.parse.
  • Immutable updates: map to change one item, filter to remove, spread to add. Never push into the existing array.

Create the project

  1. Create a new Expo project, or reuse your first one.
  2. Install the storage library with npx expo install @react-native-async-storage/async-storage.
  3. Add storage.ts and replace App.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 useEffect loads saved tasks once and then sets ready.
  • The second saves tasks every time they change, but only after ready, so the empty starting list does not overwrite saved data.
  • add, toggle, remove and clearDone each build a new list.
  • left is calculated while rendering from todos. 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

  1. Build the app and check the example. Then force-close the app and reopen it.
  2. Add an edit feature: tapping the task title opens it in the text box.
  3. Show "All done, well done!" when every task is ticked.
  4. 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.

Frequently asked questions

What does the empty array in useEffect mean?

It means the effect has no dependencies, so it runs once after the first render. It is the usual place to load saved data.

Why not push into the array?

React decides to redraw by comparing old and new values. If you change the same array, the reference is unchanged and React may not notice. A new array always triggers an update.

Can AsyncStorage lose data?

It can if the user clears app data or the phone is reset. Treat it as a local cache and use a server or backup for data that must never be lost.