Bryn Flow

⚛️ Build a Todo App with FlatList and AsyncStorage

A complete todo list app: add, complete, and delete tasks, rendered efficiently with FlatList and persisted locally with AsyncStorage.

Beginner ⏱️ ~35 minutes 🧰 React Native, useState, FlatList, AsyncStorage
← React Native Track

What You'll Build

A text input plus "Add" button, a scrollable list of todos with a tap-to-toggle-complete row and a delete button, and persistence so the list survives an app restart.

Prerequisites

  • A React Native project (Expo or bare) already running
  • npm install @react-native-async-storage/async-storage (or npx expo install if using Expo)
  • Helpful but not required: read State with useState and Lists with FlatList first

Step by Step

1 Set up state for the todo list

// App.js
import React, { useState, useEffect } from 'react';
import { View, Text, TextInput, Pressable, FlatList, StyleSheet } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';

const STORAGE_KEY = '@todos';

export default function App() {
  const [todos, setTodos] = useState([]);
  const [text, setText] = useState('');

  // steps 2-4 go here
}

2 Load saved todos on mount

AsyncStorage only stores strings, so arrays and objects are serialized with JSON.stringify/parsed with JSON.parse.

  useEffect(() => {
    async function loadTodos() {
      const saved = await AsyncStorage.getItem(STORAGE_KEY);
      if (saved) setTodos(JSON.parse(saved));
    }
    loadTodos();
  }, []);

  useEffect(() => {
    AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
  }, [todos]);

The second effect saves to disk automatically any time todos changes — no manual "save" button needed.

3 Add, toggle, and delete functions

  function addTodo() {
    if (!text.trim()) return;
    setTodos((prev) => [
      { id: Date.now().toString(), title: text.trim(), done: false },
      ...prev,
    ]);
    setText('');
  }

  function toggleTodo(id) {
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
    );
  }

  function deleteTodo(id) {
    setTodos((prev) => prev.filter((t) => t.id !== id));
  }

Using the functional updater form setTodos((prev) => ...) avoids stale-state bugs when multiple updates happen in quick succession.

4 Build the UI with FlatList

  return (
    <View style={styles.container}>
      <View style={styles.inputRow}>
        <TextInput
          style={styles.input}
          placeholder="Add a task..."
          value={text}
          onChangeText={setText}
          onSubmitEditing={addTodo}
        />
        <Pressable style={styles.addBtn} onPress={addTodo}>
          <Text style={styles.addBtnText}>Add</Text>
        </Pressable>
      </View>

      <FlatList
        data={todos}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => (
          <Pressable style={styles.row} onPress={() => toggleTodo(item.id)}>
            <Text style={[styles.rowText, item.done && styles.rowTextDone]}>
              {item.done ? '✓ ' : '○ '}{item.title}
            </Text>
            <Pressable onPress={() => deleteTodo(item.id)}>
              <Text style={styles.delete}>✕</Text>
            </Pressable>
          </Pressable>
        )}
        ListEmptyComponent={<Text style={styles.empty}>No tasks yet — add one above</Text>}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 16, paddingTop: 60 },
  inputRow: { flexDirection: 'row', marginBottom: 16 },
  input: { flex: 1, borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 10, marginRight: 8 },
  addBtn: { backgroundColor: '#1c8fb0', borderRadius: 8, paddingHorizontal: 16, justifyContent: 'center' },
  addBtnText: { color: '#fff', fontWeight: 'bold' },
  row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 12, borderBottomWidth: 1, borderColor: '#eee' },
  rowText: { fontSize: 16 },
  rowTextDone: { textDecorationLine: 'line-through', color: '#999' },
  delete: { color: '#d64545', fontSize: 16, paddingHorizontal: 8 },
  empty: { textAlign: 'center', color: '#999', marginTop: 40 },
});

5 Run it

Start the app, add a few tasks, tap one to mark it done, delete another, then reload the app (or close and reopen it) — your remaining todos and their done state should still be there.

Final Working Code

Steps 1–4 above are all one file, App.js, in the order shown — imports, state, effects, handler functions, then the returned JSX and styles. Nothing was abbreviated; paste them in order and the app runs as-is.

What to Try Next

Want more React Native walkthroughs?

Head back to the React Native track for the full topic list, roadmap, and practice quiz.

Back to React Native Track