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(ornpx expo installif 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.