Build a Task Manager App with React Native
Build a CRUD mobile task manager app with persistent local storage.
Introduction
30 Second Summary
Every day, you pull out your phone to check what needs to get done. Task managers are one of the most common mobile apps because they solve a universal problem: keeping track of things.
In this project, you will build a task manager app using React Native that runs on your physical Android device. You'll implement full CRUD operations (Create, Read, Update, Delete) with persistent local storage.
What You'll Build
A mobile task manager app built with React Native and Expo that supports adding, viewing, editing, and deleting tasks with local persistence via AsyncStorage.
By the end of this project, you'll have:
- A working React Native task manager app running on your physical Android device.
- All four CRUD operations (Create, Read, Update, Delete) implemented with persistent local storage.
- Hands-on experience with core React Native components that differ from web React, including FlatList, Pressable, Modal, and StyleSheet.
- Secret Mission: Add task completion toggling with strikethrough styling and a filter to show all/active/completed tasks.
Are there any prerequisites?
Basic React knowledge (components, state, props, hooks) is helpful for this project. You don't need any prior mobile development experience.
Before We Start
Before We Start...
Set Up Your Expo Project
To build a mobile app with React Native, you need a way to run your code on a physical device.
Expo handles this for you. It lets you write React code on your computer and instantly preview the app on your phone. In this step, you will install the Expo Go app on your Android device, create a new project, and get the app running on your phone.
In this step, get ready to:
- Install Expo Go on your Android device and create a new Expo project.
- Start the development server and connect your phone.
- Install AsyncStorage for persistent data storage.
Install Expo Go and create your project
Expo Go is a free app that connects to your development server and displays your React Native app in real time.
- On your Android phone, open the Google Play Store.
- Search for Expo Go and install the app.
What is Expo Go?
Expo Go is a pre-built app that acts as a playground for your React Native projects. It connects to your computer's development server and renders your app live on your phone, without needing Android Studio or any native build tools.
Now let's create the project on your computer. You will scaffold a new Expo project using the blank template, which gives you a minimal starting point without navigation or extra configuration.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Expo project by running this command:
npx create-expo-app@latest TaskManager --template blank
What does this command do?
- npx create-expo-app@latest runs the Expo project scaffolding tool.
- TaskManager is the name of your project folder.
- --template blank selects a minimal template with no navigation setup, perfect for a single-screen app.
- Wait for the installation to finish. You will see packages being downloaded and a success message when it completes.
- Confirm the project folder was created by running:
ls
- You should see TaskManager listed in the output.
Command not found or install error?
- Make sure Node.js is installed and up to date. Expo SDK 54 requires Node.js 20.19.x or higher.
- If you see a permissions error, try running the command with sudo on macOS or running PowerShell as Administrator on Windows.
- Check your internet connection. The command downloads packages from npm.
Why is my Expo project not creating?
Start the development server and connect your phone
Your project is ready. Now you will start a development server on your computer and connect your Android phone to see the app live.
- Navigate into your project folder by running:
cd TaskManager
- Start the Expo development server by running:
npx expo start
- You will see a QR code appear in your terminal along with a list of keyboard shortcuts. Keep this terminal window open.
What is the Terminal UI?
When you run npx expo start, Expo shows a Terminal UI with a QR code and keyboard shortcuts. The QR code encodes the URL of your development server so your phone can connect to it.
- Make sure your computer and Android phone are connected to the same WiFi network.
- On your Android phone, open the Expo Go app.
- Tap Scan QR code inside the Expo Go app.
- Point your phone's camera at the QR code in your terminal.
The app will bundle and load on your phone. You should see the default text: Open up App.js to start working on your app! centered on a white screen.
App not loading on your phone?
- Your computer and phone must be on the same WiFi network. Firewalls (especially on Windows) can also block the connection.
- If same-WiFi is not working, stop the server with Ctrl+C and restart with npx expo start --tunnel to connect over the internet instead.
- Make sure you are scanning the QR code from inside the Expo Go app, not from your phone's default camera app.
Why can't my phone connect to the Expo dev server?
Install AsyncStorage and open the project in VS Code
Your app is running on your phone. Next, you need to install AsyncStorage. This is a library that gives your app persistent local storage on the device, similar to localStorage on the web.
- Open a new terminal window or tab. Keep the Expo dev server running in the first terminal.
- Navigate back to your project folder by running:
cd ~/Desktop/TaskManager
- Install AsyncStorage by running:
npx expo install @react-native-async-storage/async-storage
Why npx expo install instead of npm install?
The npx expo install command ensures the package version is compatible with your current Expo SDK version. Using npm install directly might grab an incompatible version and cause errors.
- You should see the package added to your package.json dependencies when the install finishes.
Install command failed?
- Make sure you are inside the TaskManager folder when running the install command.
- Check that you have an active internet connection.
Why is npx expo install failing for AsyncStorage?
Now open the project in VS Code so you are ready to edit code in the next step.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the TaskManager folder, then click Open.
You should see the project files in the Explorer sidebar, including App.js, package.json, and the node_modules folder.
Your development environment is ready. Next up, you will replace the default App.js code and build the task input form and list display.
Build the Task Input and List Display
Your app is running on your phone, but it still shows the default placeholder text. Time to turn it into a real task manager.
In this step, you'll build the Create and Read parts of CRUD. You'll set up a text input for adding tasks, a scrollable list for displaying them, and AsyncStorage for saving tasks so they survive app restarts.
In this step, get ready to:
- Replace App.js with the task manager structure including imports, state, and layout.
- Implement the addTask function to create and save new tasks.
- Implement loadTasks to restore saved tasks when the app opens.
Set up the app structure with imports and layout
You're going to replace the entire contents of App.js with the task manager code. This sets up the component shell: all the imports you need, state variables, the JSX layout, and styles.
- In VS Code, open App.js from the file explorer sidebar.
- Select all the existing code with Cmd+A (macOS) or Ctrl+A (Windows) and delete it.
- Paste the following code into the empty file:
import { useState, useEffect } from 'react';
import {
View,
Text,
TextInput,
FlatList,
Pressable,
StyleSheet,
Alert,
Modal,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEY = '@TaskManager_tasks';
export default function App() {
const [tasks, setTasks] = useState([]);
const [inputText, setInputText] = useState('');
useEffect(() => {
loadTasks();
}, []);
const loadTasks = async () => {
try {
const storedTasks = await AsyncStorage.getItem(STORAGE_KEY);
if (storedTasks !== null) {
setTasks(JSON.parse(storedTasks));
}
} catch (error) {
Alert.alert('Error', 'Failed to load tasks');
}
};
const saveTasks = async (updatedTasks) => {
try {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updatedTasks));
} catch (error) {
Alert.alert('Error', 'Failed to save tasks');
}
};
const addTask = () => {
if (inputText.trim() === '') {
Alert.alert('Oops', 'Please enter a task');
return;
}
const newTask = {
id: Date.now().toString(),
text: inputText.trim(),
};
const updatedTasks = [...tasks, newTask];
setTasks(updatedTasks);
saveTasks(updatedTasks);
setInputText('');
};
const renderTask = ({ item }) => (
<View>
<Text>{item.text}</Text>
</View>
);
return (
<View style={styles.container}>
<Text style={styles.title}>Task Manager</Text>
<View style={styles.inputRow}>
<TextInput
style={styles.input}
placeholder="Add a new task..."
value={inputText}
onChangeText={setInputText}
/>
<Pressable style={styles.addButton} onPress={addTask}>
<Text style={styles.buttonText}>Add</Text>
</Pressable>
</View>
<FlatList
data={tasks}
keyExtractor={(item) => item.id}
renderItem={renderTask}
ListEmptyComponent={
<Text style={styles.emptyText}>No tasks yet. Add one above!</Text>
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
paddingTop: 60,
paddingHorizontal: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#333',
marginBottom: 20,
textAlign: 'center',
},
inputRow: {
flexDirection: 'row',
marginBottom: 20,
gap: 10,
},
input: {
flex: 1,
backgroundColor: '#fff',
borderRadius: 8,
paddingHorizontal: 15,
paddingVertical: 12,
fontSize: 16,
borderWidth: 1,
borderColor: '#ddd',
},
addButton: {
backgroundColor: '#4a90d9',
borderRadius: 8,
paddingHorizontal: 20,
paddingVertical: 12,
justifyContent: 'center',
},
buttonText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
emptyText: {
textAlign: 'center',
color: '#999',
fontSize: 16,
marginTop: 40,
},
});
What does this code do?
This file sets up the entire task manager in one component. Here is what each section does:
- The imports bring in React hooks (useState, useEffect) and React Native components (View, Text, TextInput, FlatList, Pressable) plus AsyncStorage for persistence.
- STORAGE_KEY is the key under which all tasks are saved in local storage.
- The tasks state holds the array of task objects. inputText tracks what the user types in the text field.
- The JSX returns a title, an input row with an Add button, and a FlatList that renders each task.
- Save App.js.
- Check your phone. You should see a "Task Manager" title at the top, a text input field, a blue Add button, and the message "No tasks yet. Add one above!" below.
Seeing a red error screen?
- Make sure you deleted ALL the old code before pasting. Leftover imports or duplicate export default declarations cause conflicts.
- Check that your Expo development server is still running in the terminal. If it stopped, run npx expo start again.
- Confirm the @react-native-async-storage/async-storage package was installed in the previous step.
Why is my app showing a red error screen after pasting the new code?
Test the addTask function
The addTask function handles the Create operation. When the user taps Add, it builds a new task object, appends it to the list, and persists everything to AsyncStorage.
How does addTask work?
- inputText.trim() === '' checks that the user actually typed something before creating a task.
- Date.now().toString() generates a unique ID for each task based on the current timestamp in milliseconds.
- saveTasks(updatedTasks) calls the helper function that writes the full tasks array to AsyncStorage as a JSON string.
- setInputText('') clears the input field after a task is added so it is ready for the next entry.
- On your phone, tap the text input field and type a task name (e.g. "Buy groceries").
- Tap the Add button.
- You should see your task text appear below the input row. The "No tasks yet" message disappears.
- Add two or three more tasks to confirm they all appear in the list.
Why FlatList instead of mapping an array?
In web React, you render lists with array.map() inside JSX. React Native uses FlatList instead because it only renders items currently visible on screen.
This virtualization gives you smooth scrolling and low memory usage even with hundreds of items.
Tapping Add but nothing appears?
- Try tapping the input field first, typing a word, then tapping Add. An empty input triggers the "Please enter a task" alert instead of adding.
- If you see the alert but no task, check that renderTask is spelled exactly as shown and passed to the renderItem prop of FlatList.
Help me debug why tasks aren't appearing in my FlatList after tapping Add.
Verify task persistence with loadTasks
The loadTasks function handles the Read operation. It runs automatically when the app opens, thanks to the useEffect hook with an empty dependency array.
How does loadTasks work?
- useEffect(() => { loadTasks(); }, []) calls loadTasks once when the component first mounts.
- AsyncStorage.getItem(STORAGE_KEY) retrieves the stored JSON string. If nothing is stored, it returns null.
- JSON.parse(storedTasks) converts the string back into a JavaScript array of task objects and sets state.
AsyncStorage only stores strings. That is why saveTasks uses JSON.stringify() before writing and loadTasks uses JSON.parse() after reading.
- On your phone, swipe the app away to fully close it (swipe up from the bottom to see recent apps, then swipe the Expo Go app off screen).
- Reopen the Expo Go app. Your tasks should still be visible in the list.
If your tasks reappear after reopening, persistence is working. The data survives because AsyncStorage writes to the device's local file system, not just memory.
Tasks disappeared after reopening?
- Make sure you fully closed and reopened the app. Just switching to another app and back does not test persistence because the app stays in memory.
- Check that STORAGE_KEY is defined as '@TaskManager_tasks' at the top of your file. A typo here means loadTasks reads from a different key than saveTasks wrote to.
Why are my tasks not persisting after I close and reopen the app?
✔️ Awesome, I've got everything!
Great. Double check that you have saved App.js and your app shows tasks that persist across restarts.
ⓧ I'd like to double check the full code
Here is the complete App.js file at this point. Compare yours against it:
import { useState, useEffect } from 'react';
import {
View,
Text,
TextInput,
FlatList,
Pressable,
StyleSheet,
Alert,
Modal,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEY = '@TaskManager_tasks';
export default function App() {
const [tasks, setTasks] = useState([]);
const [inputText, setInputText] = useState('');
useEffect(() => {
loadTasks();
}, []);
const loadTasks = async () => {
try {
const storedTasks = await AsyncStorage.getItem(STORAGE_KEY);
if (storedTasks !== null) {
setTasks(JSON.parse(storedTasks));
}
} catch (error) {
Alert.alert('Error', 'Failed to load tasks');
}
};
const saveTasks = async (updatedTasks) => {
try {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updatedTasks));
} catch (error) {
Alert.alert('Error', 'Failed to save tasks');
}
};
const addTask = () => {
if (inputText.trim() === '') {
Alert.alert('Oops', 'Please enter a task');
return;
}
const newTask = {
id: Date.now().toString(),
text: inputText.trim(),
};
const updatedTasks = [...tasks, newTask];
setTasks(updatedTasks);
saveTasks(updatedTasks);
setInputText('');
};
const renderTask = ({ item }) => (
<View>
<Text>{item.text}</Text>
</View>
);
return (
<View style={styles.container}>
<Text style={styles.title}>Task Manager</Text>
<View style={styles.inputRow}>
<TextInput
style={styles.input}
placeholder="Add a new task..."
value={inputText}
onChangeText={setInputText}
/>
<Pressable style={styles.addButton} onPress={addTask}>
<Text style={styles.buttonText}>Add</Text>
</Pressable>
</View>
<FlatList
data={tasks}
keyExtractor={(item) => item.id}
renderItem={renderTask}
ListEmptyComponent={
<Text style={styles.emptyText}>No tasks yet. Add one above!</Text>
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
paddingTop: 60,
paddingHorizontal: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#333',
marginBottom: 20,
textAlign: 'center',
},
inputRow: {
flexDirection: 'row',
marginBottom: 20,
gap: 10,
},
input: {
flex: 1,
backgroundColor: '#fff',
borderRadius: 8,
paddingHorizontal: 15,
paddingVertical: 12,
fontSize: 16,
borderWidth: 1,
borderColor: '#ddd',
},
addButton: {
backgroundColor: '#4a90d9',
borderRadius: 8,
paddingHorizontal: 20,
paddingVertical: 12,
justifyContent: 'center',
},
buttonText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
emptyText: {
textAlign: 'center',
color: '#999',
fontSize: 16,
marginTop: 40,
},
});
You now have a working task manager with Create and Read functionality. Next up, you'll add the ability to edit and delete tasks to complete the full CRUD set.
Add Edit and Delete Functionality
Your task manager can create and display tasks. Now it needs the other two CRUD operations: Update and Delete.
In this step, you will add a delete function that removes tasks from the list, and an edit modal that lets you update existing task text. Both operations persist changes to AsyncStorage so your edits survive app restarts.
In this step, get ready to:
- Implement a delete function and add action buttons to each task.
- Build an edit modal with pre-filled text and Save/Cancel buttons.
- Implement the update function and test all four CRUD operations on your phone.
Implement delete and add action buttons to tasks
Deleting a task means filtering it out of the tasks array by its unique ID, then saving the updated array back to storage.
- In App.js, find your addTask function. Add the deleteTask function directly below it by copying this code:
const deleteTask = (taskId) => {
const updatedTasks = tasks.filter((task) => task.id !== taskId);
setTasks(updatedTasks);
saveTasks(updatedTasks);
};
What does this code do?
- tasks.filter() creates a new array that excludes the task matching the given taskId.
- The filtered array replaces the current state and gets saved to AsyncStorage so the deletion persists.
Now the task list needs buttons so users can actually trigger delete (and edit, which we will build next). Each task item will show an Edit button and a Delete button.
- Find your existing renderTask function in App.js. Replace it entirely with this updated version:
const renderTask = ({ item }) => (
<View style={styles.taskItem}>
<Text style={styles.taskText}>{item.text}</Text>
<View style={styles.taskButtons}>
<Pressable style={styles.editButton} onPress={() => openEditModal(item)}>
<Text style={styles.buttonText}>Edit</Text>
</Pressable>
<Pressable style={styles.deleteButton} onPress={() => deleteTask(item.id)}>
<Text style={styles.buttonText}>Delete</Text>
</Pressable>
</View>
</View>
);
What does this code do?
- Each task now renders inside a styles.taskItem container with the text on the left and two buttons on the right.
- The Delete button calls deleteTask(item.id) to remove that specific task.
- The Edit button calls openEditModal(item) which we will implement in the next substep.
Do not tap Edit yet
The Edit button references a function (openEditModal) that does not exist yet. Tapping it will cause an error. We will create it in the next substep.
The updated renderTask references new styles that do not exist yet. You need to add them before saving.
- Scroll down to your StyleSheet.create object. Add these new styles below the existing addButton style:
taskItem: {
backgroundColor: '#fff',
borderRadius: 8,
padding: 15,
marginBottom: 10,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
taskText: {
fontSize: 16,
color: '#333',
flex: 1,
marginRight: 10,
},
taskButtons: {
flexDirection: 'row',
gap: 8,
},
editButton: {
backgroundColor: '#888',
borderRadius: 6,
paddingHorizontal: 12,
paddingVertical: 8,
},
deleteButton: {
backgroundColor: '#d94a4a',
borderRadius: 6,
paddingHorizontal: 12,
paddingVertical: 8,
},
What do these styles do?
- taskItem creates a white card-style row with the task text and buttons side by side (flexDirection: 'row').
- taskText uses flex: 1 to fill available space, pushing the buttons to the right.
- The edit button is gray and the delete button is red, making destructive actions visually distinct.
- Save App.js.
- On your phone, your tasks now appear as white cards with Edit and Delete buttons on the right side.
- Tap the red Delete button on any task. The task disappears from the list immediately.
Tasks not showing buttons?
- Check that you replaced the entire renderTask function, not just parts of it. The old version had a plain <View> and <Text> without styles.
- Make sure all five new style entries (taskItem, taskText, taskButtons, editButton, deleteButton) are inside the StyleSheet.create({}) object with commas between them.
- Help me debug my renderTask function
Add the edit modal
A Modal in React Native is a popup overlay that appears on top of your current screen. You will use one to let users edit a task's text without leaving the main list.
- In App.js, find the two existing state lines (tasks and inputText). Add three new state variables directly below them:
const [editModalVisible, setEditModalVisible] = useState(false);
const [editingTask, setEditingTask] = useState(null);
const [editText, setEditText] = useState('');
- Next, add the openEditModal function below your deleteTask function:
const openEditModal = (task) => {
setEditingTask(task);
setEditText(task.text);
setEditModalVisible(true);
};
What does this code do?
- editModalVisible controls whether the modal is showing. editingTask stores which task is being edited. editText holds the text the user is typing in the modal input.
- openEditModal sets all three values at once: it remembers which task was tapped, pre-fills the input with that task's current text, and opens the modal.
Now you need the actual Modal UI. This goes inside your main return statement, after the FlatList closing tag.
- In App.js, find the closing /> of your FlatList component. Add this Modal component directly below it (but still inside the outer <View style={styles.container}>):
<Modal
visible={editModalVisible}
transparent={true}
animationType="fade"
onRequestClose={() => setEditModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}>Edit Task</Text>
<TextInput
style={styles.modalInput}
value={editText}
onChangeText={setEditText}
autoFocus={true}
/>
<View style={styles.modalButtons}>
<Pressable style={styles.saveButton} onPress={updateTask}>
<Text style={styles.buttonText}>Save</Text>
</Pressable>
<Pressable
style={styles.cancelButton}
onPress={() => setEditModalVisible(false)}
>
<Text style={styles.buttonText}>Cancel</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
What does this Modal do?
- visible={editModalVisible} shows or hides the modal based on state.
- transparent={true} makes the background see-through so the dark overlay can show the main screen behind it.
- onRequestClose handles the Android back button. Without it, pressing back while the modal is open does nothing.
- autoFocus={true} on the TextInput opens the keyboard automatically when the modal appears.
- The Save button calls updateTask (which we will create next). The Cancel button simply closes the modal.
- Add the modal styles to your StyleSheet.create object, below the deleteButton style you added earlier:
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
modalContent: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 25,
width: '85%',
},
modalTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#333',
marginBottom: 15,
textAlign: 'center',
},
modalInput: {
backgroundColor: '#f5f5f5',
borderRadius: 8,
paddingHorizontal: 15,
paddingVertical: 12,
fontSize: 16,
borderWidth: 1,
borderColor: '#ddd',
marginBottom: 20,
},
modalButtons: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 10,
},
saveButton: {
flex: 1,
backgroundColor: '#4a90d9',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
cancelButton: {
flex: 1,
backgroundColor: '#888',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
What do these modal styles do?
- modalOverlay creates a semi-transparent dark background that covers the entire screen.
- modalContent is the white card centered on screen at 85% width.
- The Save button matches the blue Add button color. The Cancel button is gray to signal it is non-destructive.
- Save App.js.
- On your phone, tap the gray Edit button on any task. A modal popup appears with a dark overlay and the task text pre-filled in the input field.
- Tap Cancel to close the modal. Do not tap Save yet (it will cause an error since updateTask does not exist yet).
Modal not appearing?
- Make sure the <Modal> block is placed after the FlatList closing tag but still inside the outer <View style={styles.container}>.
- Check that you added all three state variables (editModalVisible, editingTask, editText) at the top of your App function.
- Verify that the openEditModal function exists. The Edit button in renderTask calls it to set the state that makes the modal visible.
- Help me debug my edit modal
Implement the update function and test all CRUD operations
The final piece is the updateTask function. It maps over the tasks array, finds the task being edited, replaces its text, and saves the change.
- In App.js, add the updateTask function below your openEditModal function:
const updateTask = () => {
if (editText.trim() === '') {
Alert.alert('Oops', 'Task text cannot be empty');
return;
}
const updatedTasks = tasks.map((task) =>
task.id === editingTask.id ? { ...task, text: editText.trim() } : task
);
setTasks(updatedTasks);
saveTasks(updatedTasks);
setEditModalVisible(false);
setEditingTask(null);
setEditText('');
};
What does this code do?
- First it validates that the edited text is not empty, showing an alert if it is.
- tasks.map() creates a new array where the matching task gets its text property replaced. All other tasks stay unchanged.
- After saving, it closes the modal and resets the editing state (editingTask back to null, editText back to empty) so the next edit starts fresh.
- Save App.js.
- On your phone, test all four CRUD operations:
- Create: Type a task name and tap Add. The task appears in the list.
- Read: Your tasks display in a scrollable list. Close and reopen the app to confirm they persist.
- Update: Tap Edit on a task, change the text, and tap Save. The list shows the updated text.
- Delete: Tap Delete on a task. It disappears from the list.
Save button not updating the task?
- Make sure updateTask is defined above renderTask in your file. JavaScript functions defined with const are not hoisted, so order matters.
- Check that the Modal's Save button has onPress={updateTask} (no parentheses after updateTask). Adding () would call it immediately on render.
- Help me debug my updateTask function
✔️ Awesome, I've got everything!
Great. Double check you have saved App.js and that all four CRUD operations work on your phone.
ⓧ I'd like to double check the full code
import { useState, useEffect } from 'react';
import {
View,
Text,
TextInput,
FlatList,
Pressable,
StyleSheet,
Alert,
Modal,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEY = '@TaskManager_tasks';
export default function App() {
const [tasks, setTasks] = useState([]);
const [inputText, setInputText] = useState('');
const [editModalVisible, setEditModalVisible] = useState(false);
const [editingTask, setEditingTask] = useState(null);
const [editText, setEditText] = useState('');
useEffect(() => {
loadTasks();
}, []);
const loadTasks = async () => {
try {
const storedTasks = await AsyncStorage.getItem(STORAGE_KEY);
if (storedTasks !== null) {
setTasks(JSON.parse(storedTasks));
}
} catch (error) {
Alert.alert('Error', 'Failed to load tasks');
}
};
const saveTasks = async (updatedTasks) => {
try {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updatedTasks));
} catch (error) {
Alert.alert('Error', 'Failed to save tasks');
}
};
const addTask = () => {
if (inputText.trim() === '') {
Alert.alert('Oops', 'Please enter a task');
return;
}
const newTask = {
id: Date.now().toString(),
text: inputText.trim(),
};
const updatedTasks = [...tasks, newTask];
setTasks(updatedTasks);
saveTasks(updatedTasks);
setInputText('');
};
const deleteTask = (taskId) => {
const updatedTasks = tasks.filter((task) => task.id !== taskId);
setTasks(updatedTasks);
saveTasks(updatedTasks);
};
const openEditModal = (task) => {
setEditingTask(task);
setEditText(task.text);
setEditModalVisible(true);
};
const updateTask = () => {
if (editText.trim() === '') {
Alert.alert('Oops', 'Task text cannot be empty');
return;
}
const updatedTasks = tasks.map((task) =>
task.id === editingTask.id ? { ...task, text: editText.trim() } : task
);
setTasks(updatedTasks);
saveTasks(updatedTasks);
setEditModalVisible(false);
setEditingTask(null);
setEditText('');
};
const renderTask = ({ item }) => (
<View style={styles.taskItem}>
<Text style={styles.taskText}>{item.text}</Text>
<View style={styles.taskButtons}>
<Pressable style={styles.editButton} onPress={() => openEditModal(item)}>
<Text style={styles.buttonText}>Edit</Text>
</Pressable>
<Pressable style={styles.deleteButton} onPress={() => deleteTask(item.id)}>
<Text style={styles.buttonText}>Delete</Text>
</Pressable>
</View>
</View>
);
return (
<View style={styles.container}>
<Text style={styles.title}>Task Manager</Text>
<View style={styles.inputRow}>
<TextInput
style={styles.input}
placeholder="Add a new task..."
value={inputText}
onChangeText={setInputText}
/>
<Pressable style={styles.addButton} onPress={addTask}>
<Text style={styles.buttonText}>Add</Text>
</Pressable>
</View>
<FlatList
data={tasks}
keyExtractor={(item) => item.id}
renderItem={renderTask}
ListEmptyComponent={
<Text style={styles.emptyText}>No tasks yet. Add one above!</Text>
}
/>
<Modal
visible={editModalVisible}
transparent={true}
animationType="fade"
onRequestClose={() => setEditModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}>Edit Task</Text>
<TextInput
style={styles.modalInput}
value={editText}
onChangeText={setEditText}
autoFocus={true}
/>
<View style={styles.modalButtons}>
<Pressable style={styles.saveButton} onPress={updateTask}>
<Text style={styles.buttonText}>Save</Text>
</Pressable>
<Pressable
style={styles.cancelButton}
onPress={() => setEditModalVisible(false)}
>
<Text style={styles.buttonText}>Cancel</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
paddingTop: 60,
paddingHorizontal: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#333',
marginBottom: 20,
textAlign: 'center',
},
inputRow: {
flexDirection: 'row',
marginBottom: 20,
gap: 10,
},
input: {
flex: 1,
backgroundColor: '#fff',
borderRadius: 8,
paddingHorizontal: 15,
paddingVertical: 12,
fontSize: 16,
borderWidth: 1,
borderColor: '#ddd',
},
addButton: {
backgroundColor: '#4a90d9',
borderRadius: 8,
paddingHorizontal: 20,
paddingVertical: 12,
justifyContent: 'center',
},
taskItem: {
backgroundColor: '#fff',
borderRadius: 8,
padding: 15,
marginBottom: 10,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
taskText: {
fontSize: 16,
color: '#333',
flex: 1,
marginRight: 10,
},
taskButtons: {
flexDirection: 'row',
gap: 8,
},
editButton: {
backgroundColor: '#888',
borderRadius: 6,
paddingHorizontal: 12,
paddingVertical: 8,
},
deleteButton: {
backgroundColor: '#d94a4a',
borderRadius: 6,
paddingHorizontal: 12,
paddingVertical: 8,
},
buttonText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
emptyText: {
textAlign: 'center',
color: '#999',
fontSize: 16,
marginTop: 40,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
modalContent: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 25,
width: '85%',
},
modalTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#333',
marginBottom: 15,
textAlign: 'center',
},
modalInput: {
backgroundColor: '#f5f5f5',
borderRadius: 8,
paddingHorizontal: 15,
paddingVertical: 12,
fontSize: 16,
borderWidth: 1,
borderColor: '#ddd',
marginBottom: 20,
},
modalButtons: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 10,
},
saveButton: {
flex: 1,
backgroundColor: '#4a90d9',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
cancelButton: {
flex: 1,
backgroundColor: '#888',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
});
Your app now supports full CRUD functionality. Next up, you will style everything so it looks polished on your phone's screen.
Style the App
Your task manager handles all four CRUD operations and persists data across restarts. The app works, but right now the task items and modal feel flat on screen.
In this step, you'll add shadow effects and elevation to give the UI a sense of depth. These properties make elements feel like they're lifting off the background, which is a key part of mobile design language.
In this step, get ready to:
- Add shadow and elevation to task items for visual depth.
- Polish the edit modal with shadow and elevation.
- Verify all CRUD operations still work with the final styling on your phone.
Add shadow to task items
On mobile, shadow and elevation create the visual cue that a card is floating above the background. React Native uses separate properties for iOS shadows (shadowColor, shadowOffset, shadowOpacity, shadowRadius) and Android elevation (elevation).
- In App.js, scroll down to the styles section at the bottom of the file.
- Find the taskItem style. It currently ends with justifyContent: 'space-between'.
- Add the following shadow properties after the justifyContent line:
// Shadow for iOS
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
// Elevation for Android
elevation: 2,
What does this code do?
The shadowColor, shadowOffset, shadowOpacity, and shadowRadius properties create a soft shadow on iOS devices.
Android ignores these shadow properties entirely. Instead, it uses elevation to create a Material Design shadow. You need both sets to support all devices.
- Save App.js and check your phone.
- Each task item in your list should now have a subtle shadow beneath it, making it look like a card floating above the gray background.
Don't see shadows on your task items?
- Make sure you added the properties inside the taskItem object, not outside it or in a different style.
- On Android, only elevation creates a visible shadow. The other shadow properties are for iOS only. If you're testing on Android, confirm elevation: 2 is present.
- You need at least one task in the list to see the effect. Add a task if your list is empty.
Why aren't my shadows showing on my React Native task items?
Add shadow to the edit modal
The modal overlay already dims the background, but the modal card itself looks flat. Adding a stronger shadow makes it feel like it's popping forward toward the user.
- Find the modalContent style. It currently ends with width: '85%'.
- Add the following shadow properties after the width line:
// Stronger shadow for the modal card
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
Why is the modal shadow stronger?
The modal uses a higher shadowOpacity (0.25 vs 0.1) and elevation (5 vs 2) compared to the task items. In mobile design, elements that appear closer to the user (like modals and popups) get deeper, more visible shadows to reinforce the layering effect.
- Save App.js.
- On your phone, tap the Edit button on any task to open the modal. The white card should now have a visible shadow separating it from the dimmed background.
Modal doesn't look different?
- The shadow is subtle against the dark overlay. Look at the edges of the white modal card for the effect.
- Confirm the properties are inside the modalContent object (not modalOverlay).
Help me debug my React Native modal shadow styling.
Test the final styled app
With all styles in place, your app should feel polished and responsive. Time to run through every CRUD operation one more time to confirm nothing broke.
- On your phone, add a new task by typing in the text field and tapping Add. Confirm the task appears in the list with a shadow card.
- Tap Edit on a task. Confirm the modal appears with the shadow card and the text is pre-filled.
- Change the task text and tap Save. Confirm the updated text appears in the list.
- Tap Delete on a task. Confirm it disappears from the list.
- Close and reopen the Expo Go app. Confirm your remaining tasks are still there (persistence working).
What should the final app look like?
You should see a light gray background with a centered title, a text input with a blue Add button, and white task cards with subtle shadows. Each card has gray Edit and red Delete buttons on the right side.
The edit modal should appear with a fade animation over a dimmed background, showing a white card with rounded corners and a visible shadow.
✔️ Awesome, I've got everything!
Great. Double check you've saved App.js and all four CRUD operations work on your device.
ⓧ I'd like to double check the full code
Here is the complete App.js file with all styles applied. Compare it against yours:
import { useState, useEffect } from 'react';
import {
View,
Text,
TextInput,
FlatList,
Pressable,
StyleSheet,
Alert,
Modal,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEY = '@TaskManager_tasks';
export default function App() {
const [tasks, setTasks] = useState([]);
const [inputText, setInputText] = useState('');
const [editModalVisible, setEditModalVisible] = useState(false);
const [editingTask, setEditingTask] = useState(null);
const [editText, setEditText] = useState('');
useEffect(() => {
loadTasks();
}, []);
const loadTasks = async () => {
try {
const storedTasks = await AsyncStorage.getItem(STORAGE_KEY);
if (storedTasks !== null) {
setTasks(JSON.parse(storedTasks));
}
} catch (error) {
Alert.alert('Error', 'Failed to load tasks');
}
};
const saveTasks = async (updatedTasks) => {
try {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updatedTasks));
} catch (error) {
Alert.alert('Error', 'Failed to save tasks');
}
};
const addTask = () => {
if (inputText.trim() === '') {
Alert.alert('Oops', 'Please enter a task');
return;
}
const newTask = {
id: Date.now().toString(),
text: inputText.trim(),
};
const updatedTasks = [...tasks, newTask];
setTasks(updatedTasks);
saveTasks(updatedTasks);
setInputText('');
};
const deleteTask = (taskId) => {
const updatedTasks = tasks.filter((task) => task.id !== taskId);
setTasks(updatedTasks);
saveTasks(updatedTasks);
};
const openEditModal = (task) => {
setEditingTask(task);
setEditText(task.text);
setEditModalVisible(true);
};
const updateTask = () => {
if (editText.trim() === '') {
Alert.alert('Oops', 'Task text cannot be empty');
return;
}
const updatedTasks = tasks.map((task) =>
task.id === editingTask.id ? { ...task, text: editText.trim() } : task
);
setTasks(updatedTasks);
saveTasks(updatedTasks);
setEditModalVisible(false);
setEditingTask(null);
setEditText('');
};
const renderTask = ({ item }) => (
<View style={styles.taskItem}>
<Text style={styles.taskText}>{item.text}</Text>
<View style={styles.taskButtons}>
<Pressable style={styles.editButton} onPress={() => openEditModal(item)}>
<Text style={styles.buttonText}>Edit</Text>
</Pressable>
<Pressable style={styles.deleteButton} onPress={() => deleteTask(item.id)}>
<Text style={styles.buttonText}>Delete</Text>
</Pressable>
</View>
</View>
);
return (
<View style={styles.container}>
<Text style={styles.title}>Task Manager</Text>
<View style={styles.inputRow}>
<TextInput
style={styles.input}
placeholder="Add a new task..."
value={inputText}
onChangeText={setInputText}
/>
<Pressable style={styles.addButton} onPress={addTask}>
<Text style={styles.buttonText}>Add</Text>
</Pressable>
</View>
<FlatList
data={tasks}
keyExtractor={(item) => item.id}
renderItem={renderTask}
ListEmptyComponent={
<Text style={styles.emptyText}>No tasks yet. Add one above!</Text>
}
/>
<Modal
visible={editModalVisible}
transparent={true}
animationType="fade"
onRequestClose={() => setEditModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}>Edit Task</Text>
<TextInput
style={styles.modalInput}
value={editText}
onChangeText={setEditText}
autoFocus={true}
/>
<View style={styles.modalButtons}>
<Pressable style={styles.saveButton} onPress={updateTask}>
<Text style={styles.buttonText}>Save</Text>
</Pressable>
<Pressable
style={styles.cancelButton}
onPress={() => setEditModalVisible(false)}
>
<Text style={styles.buttonText}>Cancel</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
paddingTop: 60,
paddingHorizontal: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#333',
marginBottom: 20,
textAlign: 'center',
},
inputRow: {
flexDirection: 'row',
marginBottom: 20,
gap: 10,
},
input: {
flex: 1,
backgroundColor: '#fff',
borderRadius: 8,
paddingHorizontal: 15,
paddingVertical: 12,
fontSize: 16,
borderWidth: 1,
borderColor: '#ddd',
},
addButton: {
backgroundColor: '#4a90d9',
borderRadius: 8,
paddingHorizontal: 20,
paddingVertical: 12,
justifyContent: 'center',
},
taskItem: {
backgroundColor: '#fff',
borderRadius: 8,
padding: 15,
marginBottom: 10,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
taskText: {
fontSize: 16,
color: '#333',
flex: 1,
marginRight: 10,
},
taskButtons: {
flexDirection: 'row',
gap: 8,
},
editButton: {
backgroundColor: '#888',
borderRadius: 6,
paddingHorizontal: 12,
paddingVertical: 8,
},
deleteButton: {
backgroundColor: '#d94a4a',
borderRadius: 6,
paddingHorizontal: 12,
paddingVertical: 8,
},
buttonText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
emptyText: {
textAlign: 'center',
color: '#999',
fontSize: 16,
marginTop: 40,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
modalContent: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 25,
width: '85%',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
modalTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#333',
marginBottom: 15,
textAlign: 'center',
},
modalInput: {
backgroundColor: '#f5f5f5',
borderRadius: 8,
paddingHorizontal: 15,
paddingVertical: 12,
fontSize: 16,
borderWidth: 1,
borderColor: '#ddd',
marginBottom: 20,
},
modalButtons: {
flexDirection: 'row',
justifyContent: 'space-between',
gap: 10,
},
saveButton: {
flex: 1,
backgroundColor: '#4a90d9',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
cancelButton: {
flex: 1,
backgroundColor: '#888',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
});
Your task manager is fully styled and functional. Next up, you'll take on the Secret Mission and add task completion toggling with filtering.
Secret mission
Add Task Completion Toggling and Filtering
Your task manager handles the basics, but real productivity apps let you check off tasks and filter your view. Can you add completion toggling with strikethrough styling and a filter to show all, active, or completed tasks?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally on your computer and Android device, so there are no ongoing costs.
Resources you used:
- TaskManager project folder on your computer.
- Expo Go app on your Android device.
Keep everything running
No action needed. Choose this if you're still actively building or want to revisit the project later.
- Your TaskManager folder stays on your Desktop.
- Expo Go stays installed on your Android device for future Expo projects.
- To start the app again, open a terminal in the TaskManager folder and run npx expo start.
Pause - I'll come back to this later
Stop the Expo development server to free up your terminal and system resources.
- In the terminal where your Expo dev server is running, press Ctrl+C to stop the server.
- Your project files remain on disk. When you're ready to continue, open a terminal in the TaskManager folder and run npx expo start again.
Delete - I don't want to use this again
Remove all project files and the Expo Go app from your device.
- Stop the Expo dev server by pressing Ctrl+C in the terminal.
- Delete the project folder by running the command for your operating system below.
macOS
cd ~/Desktop
rm -rf TaskManager
Windows
cd ~\Desktop
Remove-Item -Recurse -Force TaskManager
- Uninstall Expo Go from your Android device by long-pressing the Expo Go app icon, then selecting Uninstall.
Nice Work!
Nice Work!
Congratulations! You just built your first React Native app with full CRUD functionality running on your physical Android device.
Here's what you learned:
- Built a mobile task manager app with full Create, Read, Update, and Delete operations using core React Native components.
- Used AsyncStorage for persistent local data storage so tasks survive app restarts.
- Learned key React Native components that differ from web React: FlatList for efficient scrollable lists, Pressable for touch interactions, Modal for overlay popups, and StyleSheet for mobile-first styling.
- Secret Mission: Added task completion toggling with strikethrough styling and a filter to show all, active, or completed tasks.
Ready to quiz yourself?