Building a Modern Todo App with React Native, Expo, and Convex
In this tutorial, I'll walk you through building a feature-rich todo application from scratch using React Native, Expo, and Convex. This app includes real-time synchronization, drag-and-drop reordering, theme switching, and a clean, modern UI that works across iOS, Android, and web platforms.
The final app will have:
✅ Complete CRUD operations for todos
🔄 Real-time data synchronization
🎯 Drag-and-drop reordering
🌙 Light/dark theme switching
📱 Cross-platform compatibility
🔍 Todo filtering (all, active, completed)
💾 Persistent storage
Prerequisites
Before we start, ensure you have:
Node.js (v18 or higher)
npm or yarn
Expo CLI (
npm install -g @expo/cli)A Convex account (sign up here)
Step 1: Setting Up the Project
Let's start by creating a new Expo project with the necessary dependencies.
npx create-expo-app todoap --template blank-typescript
cd todoap
Install the required dependencies:
npm install @expo/metro-config @expo/vector-icons @react-navigation/bottom-tabs @react-navigation/elements @react-navigation/native @react-navigation/native-stack convex expo-constants expo-dev-client expo-font expo-haptics expo-image expo-linking expo-router expo-splash-screen expo-status-bar expo-symbols expo-system-ui expo-web-browser react react-dom react-native react-native-css-interop react-native-draggable-flatlist react-native-gesture-handler react-native-reanimated react-native-safe-area-context react-native-screens react-native-web react-native-worklets tailwindcss
Install development dependencies:
npm install -D @types/react eslint eslint-config-expo typescript
Step 2: Configuring Expo and Project Structure
Update app.json with our app configuration:
{
"expo": {
"name": "todoap",
"slug": "todoap",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "todoap",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"package": "com.sugar_dev.todoap"
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"projectId": "your-project-id"
}
},
"owner": "your-owner"
}
}
Create the basic project structure:
todoap/
├── app/
│ ├── _layout.tsx
│ └── index.tsx
├── components/
├── contexts/
├── convex/
├── hooks/
├── lib/
└── assets/
└── images/
Step 3: Setting Up Convex Backend
Initialize Convex in your project:
npx convex dev --once --configure new --project todoapp
This will create a convex/ directory and generate environment variables. Create lib/convex.ts:
export const CONVEX_URL = process.env.EXPO_PUBLIC_CONVEX_URL!;
Define the database schema in convex/schema.ts:
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
todos: defineTable({
text: v.string(),
completed: v.boolean(),
createdAt: v.number(),
order: v.number(),
}),
});
Create the todo functions in convex/todos.ts:
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
export const getTodos = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("todos").order("asc").collect();
},
});
export const addTodo = mutation({
args: { text: v.string() },
handler: async (ctx, args) => {
const existingTodos = await ctx.db.query("todos").collect();
const maxOrder = existingTodos.length > 0 ? Math.max(...existingTodos.map(t => t.order)) : 0;
const todoId = await ctx.db.insert("todos", {
text: args.text,
completed: false,
createdAt: Date.now(),
order: maxOrder + 1,
});
return todoId;
},
});
export const toggleTodo = mutation({
args: { id: v.id("todos") },
handler: async (ctx, args) => {
const todo = await ctx.db.get(args.id);
if (!todo) return;
await ctx.db.patch(args.id, { completed: !todo.completed });
},
});
export const editTodo = mutation({
args: { id: v.id("todos"), text: v.string() },
handler: async (ctx, args) => {
await ctx.db.patch(args.id, { text: args.text });
},
});
export const deleteTodo = mutation({
args: { id: v.id("todos") },
handler: async (ctx, args) => {
await ctx.db.delete(args.id);
},
});
export const clearCompleted = mutation({
args: {},
handler: async (ctx) => {
const todos = await ctx.db.query("todos").collect();
const completedTodos = todos.filter((todo) => todo.completed);
await Promise.all(completedTodos.map((todo) => ctx.db.delete(todo._id)));
},
});
export const reorderTodos = mutation({
args: { fromIndex: v.number(), toIndex: v.number() },
handler: async (ctx, args) => {
const todos = await ctx.db.query("todos").order("asc").collect();
if (args.fromIndex < 0 || args.fromIndex >= todos.length ||
args.toIndex < 0 || args.toIndex >= todos.length) {
return;
}
const reorderedTodos = [...todos];
const [movedTodo] = reorderedTodos.splice(args.fromIndex, 1);
reorderedTodos.splice(args.toIndex, 0, movedTodo);
await Promise.all(
reorderedTodos.map((todo, index) =>
ctx.db.patch(todo._id, { order: index + 1 })
)
);
},
});
export const seedTodos = mutation({
args: {},
handler: async (ctx) => {
const existingTodos = await ctx.db.query("todos").collect();
if (existingTodos.length > 0) {
return;
}
const sampleTodos = [
"Complete online JavaScript course",
"Jog around the park 3x",
"10 minutes meditation",
"Read for 1 hour",
"Pick up groceries",
"Complete Todo App on Frontend Mentor",
"Buy coffee beans",
"Call mom",
"Finish project proposal",
"Clean the house",
];
await Promise.all(
sampleTodos.map((text, index) =>
ctx.db.insert("todos", {
text,
completed: index === 0,
createdAt: Date.now() - (sampleTodos.length - index) * 3600000,
order: index + 1,
})
)
);
},
});
Step 4: Setting Up Theme Management
Create the theme context in contexts/ThemeContext.tsx:
import React, { createContext, useContext, useEffect, useState } from 'react';
import { Appearance } from 'react-native';
export type ThemeType = 'light' | 'dark';
export interface ThemeColors {
background: string;
surface: string;
primary: string;
secondary: string;
text: string;
textSecondary: string;
border: string;
completed: string;
placeholder: string;
}
const lightTheme: ThemeColors = {
background: '#fafafa',
surface: '#ffffff',
primary: '#3a7bfd',
secondary: '#e4e5f1',
text: '#494c6b',
textSecondary: '#9495a5',
border: '#e3e4f1',
completed: '#d1d2da',
placeholder: '#9495a5',
};
const darkTheme: ThemeColors = {
background: '#171823',
surface: '#25273d',
primary: '#3a7bfd',
secondary: '#393a4b',
text: '#c8cbe7',
textSecondary: '#5b5e7e',
border: '#393a4b',
completed: '#777a92',
placeholder: '#5b5e7e',
};
interface ThemeContextType {
theme: ThemeType;
colors: ThemeColors;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [theme, setTheme] = useState<ThemeType>('light');
useEffect(() => {
const systemTheme = Appearance.getColorScheme();
setTheme(systemTheme || 'light');
}, []);
const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
};
const colors = theme === 'light' ? lightTheme : darkTheme;
return (
<ThemeContext.Provider value={{ theme, colors, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
Step 5: Creating the Todo Hook
Create hooks/useTodos.ts for state management:
import { useState, useEffect } from 'react';
import { useQuery, useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';
export interface Todo {
_id: string;
text: string;
completed: boolean;
createdAt: number;
order: number;
}
export type FilterType = 'all' | 'active' | 'completed';
export const useTodos = () => {
const [filter, setFilter] = useState<FilterType>('all');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const todosQuery = useQuery(api.todos.getTodos);
const todos: Todo[] = todosQuery || [];
const addTodoMutation = useMutation(api.todos.addTodo);
const toggleTodoMutation = useMutation(api.todos.toggleTodo);
const editTodoMutation = useMutation(api.todos.editTodo);
const deleteTodoMutation = useMutation(api.todos.deleteTodo);
const clearCompletedMutation = useMutation(api.todos.clearCompleted);
const reorderTodosMutation = useMutation(api.todos.reorderTodos);
const validateTodoText = (text: string): string | null => {
const trimmed = text.trim();
if (!trimmed) return 'Todo text cannot be empty';
if (trimmed.length > 200) return 'Todo text must be less than 200 characters';
return null;
};
const addTodo = async (text: string) => {
const validationError = validateTodoText(text);
if (validationError) {
setError(validationError);
return;
}
setIsLoading(true);
setError(null);
try {
await addTodoMutation({ text: text.trim() });
} catch (err) {
setError('Failed to add todo. Please try again.');
console.error('Add todo error:', err);
} finally {
setIsLoading(false);
}
};
const toggleTodo = async (id: string) => {
if (!id) {
setError('Invalid todo ID');
return;
}
setIsLoading(true);
setError(null);
try {
await toggleTodoMutation({ id: id as any });
} catch (err) {
setError('Failed to update todo. Please try again.');
console.error('Toggle todo error:', err);
} finally {
setIsLoading(false);
}
};
const editTodo = async (id: string, newText: string) => {
const validationError = validateTodoText(newText);
if (validationError) {
setError(validationError);
return;
}
if (!id) {
setError('Invalid todo ID');
return;
}
setIsLoading(true);
setError(null);
try {
await editTodoMutation({ id: id as any, text: newText.trim() });
} catch (err) {
setError('Failed to edit todo. Please try again.');
console.error('Edit todo error:', err);
} finally {
setIsLoading(false);
}
};
const deleteTodo = async (id: string) => {
if (!id) {
setError('Invalid todo ID');
return;
}
setIsLoading(true);
setError(null);
try {
await deleteTodoMutation({ id: id as any });
} catch (err) {
setError('Failed to delete todo. Please try again.');
console.error('Delete todo error:', err);
} finally {
setIsLoading(false);
}
};
const clearCompleted = async () => {
setIsLoading(true);
setError(null);
try {
await clearCompletedMutation({});
} catch (err) {
setError('Failed to clear completed todos. Please try again.');
console.error('Clear completed error:', err);
} finally {
setIsLoading(false);
}
};
const reorderTodos = async (fromIndex: number, toIndex: number) => {
if (fromIndex < 0 || toIndex < 0 || fromIndex >= todos.length || toIndex >= todos.length) {
setError('Invalid reorder indices');
return;
}
setIsLoading(true);
setError(null);
try {
await reorderTodosMutation({ fromIndex, toIndex });
} catch (err) {
setError('Failed to reorder todos. Please try again.');
console.error('Reorder todos error:', err);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (error) {
const timer = setTimeout(() => setError(null), 5000);
return () => clearTimeout(timer);
}
}, [error]);
const filteredTodos = todos.filter((todo: Todo) => {
switch (filter) {
case 'active':
return !todo.completed;
case 'completed':
return todo.completed;
default:
return true;
}
});
const activeCount = todos.filter((todo: Todo) => !todo.completed).length;
return {
todos: filteredTodos,
allTodos: todos,
filter,
setFilter,
addTodo,
toggleTodo,
editTodo,
deleteTodo,
clearCompleted,
reorderTodos,
activeCount,
error,
isLoading,
};
};
Step 6: Setting Up the Root Layout
Create app/_layout.tsx:
import { Stack } from "expo-router";
import { ThemeProvider } from "../contexts/ThemeContext";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { CONVEX_URL } from "../lib/convex";
const convex = new ConvexReactClient(CONVEX_URL, { unsavedChangesWarning: false });
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<ConvexProvider client={convex}>
<ThemeProvider>
<Stack />
</ThemeProvider>
</ConvexProvider>
</GestureHandlerRootView>
);
}
Step 7: Building UI Components
Create the theme switcher component in components/ThemeSwitcher.tsx:
import React from 'react';
import { TouchableOpacity, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
import { useTheme } from '../contexts/ThemeContext';
export const ThemeSwitcher: React.FC = () => {
const { theme, toggleTheme } = useTheme();
return (
<TouchableOpacity
style={styles.container}
onPress={toggleTheme}
accessibilityLabel={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme`}
accessibilityRole="button"
>
<Image
source={theme === 'light' ? require('../assets/images/crescent_moon.svg') : require('../assets/images/sun.svg')}
style={styles.icon}
contentFit="contain"
/>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
padding: 8,
},
icon: {
width: 24,
height: 24,
},
});
Create the todo input component in components/TodoInput.tsx:
import React, { useState } from 'react';
import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
import { useTheme } from '../contexts/ThemeContext';
interface TodoInputProps {
onAddTodo: (text: string) => void;
isLoading?: boolean;
}
export const TodoInput: React.FC<TodoInputProps> = ({ onAddTodo, isLoading = false }) => {
const { colors } = useTheme();
const [text, setText] = useState('');
const handleSubmit = () => {
if (text.trim()) {
onAddTodo(text);
setText('');
}
};
return (
<View style={[styles.container, { backgroundColor: colors.surface, borderColor: colors.border }]}>
<TouchableOpacity
style={[styles.checkbox, { borderColor: colors.border }]}
onPress={handleSubmit}
disabled={isLoading}
accessibilityLabel="Add todo"
accessibilityRole="button"
accessibilityState={{ disabled: isLoading }}
/>
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="Create a new todo..."
placeholderTextColor={colors.placeholder}
value={text}
onChangeText={setText}
onSubmitEditing={handleSubmit}
returnKeyType="done"
accessibilityLabel="Todo input field"
/>
{text.trim() && (
<TouchableOpacity
style={styles.cancelButton}
onPress={() => setText('')}
accessibilityLabel="Clear input"
accessibilityRole="button"
>
<Image
source={require('../assets/images/cancel.svg')}
style={styles.cancelIcon}
contentFit="contain"
/>
</TouchableOpacity>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 5,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
height: 48,
},
checkbox: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 1,
marginRight: 12,
},
input: {
flex: 1,
fontSize: 18,
fontWeight: '400',
},
cancelButton: {
padding: 4,
marginLeft: 8,
},
cancelIcon: {
width: 16,
height: 16,
},
});
Create the todo item component in components/TodoItem.tsx:
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, TextInput, StyleSheet } from 'react-native';
import { useTheme } from '../contexts/ThemeContext';
import { Todo } from '../hooks/useTodos';
interface TodoItemProps {
todo: Todo;
onToggle: (id: string) => void;
onEdit: (id: string, text: string) => void;
onDelete: (id: string) => void;
drag?: () => void;
isActive?: boolean;
}
export const TodoItem: React.FC<TodoItemProps> = ({ todo, onToggle, onEdit, onDelete, drag, isActive }) => {
const { colors } = useTheme();
const containerStyle = {
flexDirection: 'row' as const,
alignItems: 'center' as const,
paddingHorizontal: 20,
paddingVertical: 14,
borderRadius: 5,
marginBottom: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
height: 52,
borderBottomWidth: 1,
borderBottomColor: colors.border,
backgroundColor: colors.surface,
opacity: isActive ? 0.8 : 1,
transform: [{ scale: isActive ? 1.02 : 1 }],
};
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(todo.text);
const handleEditSubmit = () => {
if (editText.trim() && editText !== todo.text) {
onEdit(todo._id, editText);
}
setIsEditing(false);
};
const handleEditCancel = () => {
setEditText(todo.text);
setIsEditing(false);
};
return (
<TouchableOpacity
style={containerStyle}
onLongPress={drag}
delayLongPress={500}
activeOpacity={0.7}
>
<TouchableOpacity
style={[
styles.checkbox,
{ borderColor: colors.primary },
todo.completed && { backgroundColor: colors.primary },
]}
onPress={() => onToggle(todo._id)}
accessibilityLabel={todo.completed ? "Mark as incomplete" : "Mark as complete"}
accessibilityRole="button"
>
{todo.completed && (
<Text style={styles.checkmark}>✓</Text>
)}
</TouchableOpacity>
{isEditing ? (
<TextInput
style={[styles.input, { color: colors.text }]}
value={editText}
onChangeText={setEditText}
onSubmitEditing={handleEditSubmit}
onBlur={handleEditCancel}
autoFocus
returnKeyType="done"
accessibilityLabel="Edit todo text"
/>
) : (
<TouchableOpacity
style={styles.textContainer}
onPress={() => setIsEditing(true)}
accessibilityLabel="Edit todo"
accessibilityRole="button"
>
<Text
style={[
styles.text,
{ color: todo.completed ? colors.completed : colors.text },
todo.completed && styles.completedText,
]}
>
{todo.text}
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.deleteButton}
onPress={() => onDelete(todo._id)}
accessibilityLabel="Delete todo"
accessibilityRole="button"
>
<Text style={[styles.deleteIcon, { color: colors.textSecondary }]}>✕</Text>
</TouchableOpacity>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 14,
borderRadius: 5,
marginBottom: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
height: 52,
},
checkbox: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 1,
marginRight: 12,
alignItems: 'center',
justifyContent: 'center',
},
checkmark: {
color: 'white',
fontSize: 12,
fontWeight: 'bold',
},
textContainer: {
flex: 1,
},
text: {
fontSize: 18,
fontWeight: '400',
},
completedText: {
textDecorationLine: 'line-through',
},
input: {
flex: 1,
fontSize: 18,
fontWeight: '400',
borderBottomWidth: 1,
borderBottomColor: '#3a7bfd',
paddingVertical: 4,
},
deleteButton: {
padding: 4,
},
deleteIcon: {
fontSize: 18,
fontWeight: 'bold',
},
});
Create the filter tabs component in components/FilterTabs.tsx:
import React from 'react';
import { View, TouchableOpacity, Text, StyleSheet } from 'react-native';
import { useTheme } from '../contexts/ThemeContext';
import { FilterType } from '../hooks/useTodos';
interface FilterTabsProps {
filter: FilterType;
onFilterChange: (filter: FilterType) => void;
}
export const FilterTabs: React.FC<FilterTabsProps> = ({ filter, onFilterChange }) => {
const { colors } = useTheme();
const filters: FilterType[] = ['all', 'active', 'completed'];
return (
<View style={[styles.container, { backgroundColor: colors.surface }]}>
{filters.map((filterType) => {
const isActive = filter === filterType;
return (
<TouchableOpacity
key={filterType}
style={styles.tab}
onPress={() => onFilterChange(filterType)}
accessibilityLabel={`Filter ${filterType} todos`}
accessibilityRole="button"
accessibilityState={{ selected: isActive }}
>
<Text
style={[
styles.tabText,
{
color: isActive ? '#3A7CFD' : colors.textSecondary,
},
]}
>
{filterType.charAt(0).toUpperCase() + filterType.slice(1)}
</Text>
</TouchableOpacity>
);
})}
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
borderRadius: 8,
},
tab: {
paddingHorizontal: 16,
marginHorizontal: 4,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
tabText: {
fontSize: 14,
fontWeight: '600',
},
});
Step 8: Creating the Main Screen
Finally, create the main screen in app/index.tsx:
import React from 'react';
import { View, Text, StyleSheet, SafeAreaView, StatusBar, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image';
import DraggableFlatList from 'react-native-draggable-flatlist';
import { useTheme } from '../contexts/ThemeContext';
import { useTodos } from '../hooks/useTodos';
import { TodoInput } from '../components/TodoInput';
import { TodoItem } from '../components/TodoItem';
import { FilterTabs } from '../components/FilterTabs';
import { ThemeSwitcher } from '../components/ThemeSwitcher';
export default function Index() {
const { colors, theme } = useTheme();
const {
todos,
allTodos,
filter,
setFilter,
addTodo,
toggleTodo,
editTodo,
deleteTodo,
clearCompleted,
reorderTodos,
activeCount,
error,
isLoading,
} = useTodos();
const renderTodo = ({ item, drag, isActive }: any) => (
<TodoItem
todo={item}
onToggle={toggleTodo}
onEdit={editTodo}
onDelete={deleteTodo}
drag={drag}
isActive={isActive}
/>
);
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
<StatusBar barStyle={colors.background === '#fafafa' ? 'dark-content' : 'light-content'} />
<View style={styles.header}>
<Image
source={theme === 'light' ? require('../assets/images/Light header.jpg') : require('../assets/images/dark header.png')}
style={styles.headerBackground}
contentFit="cover"
/>
<View style={styles.headerContent}>
<Text style={[styles.title, { color: 'white' }]}>TODO</Text>
<ThemeSwitcher />
</View>
</View>
<View style={styles.content}>
{error && (
<View style={[styles.errorContainer, { backgroundColor: colors.surface }]}>
<Text style={[styles.errorText, { color: '#ff6b6b' }]}>{error}</Text>
</View>
)}
<TodoInput onAddTodo={addTodo} isLoading={isLoading} />
<View style={[styles.listContainer, { backgroundColor: colors.surface }]}>
<DraggableFlatList
data={todos}
renderItem={renderTodo}
keyExtractor={(item) => item._id}
onDragEnd={({ data, from, to }) => {
reorderTodos(from, to);
}}
showsVerticalScrollIndicator={false}
contentContainerStyle={todos.length === 0 && styles.emptyList}
ListFooterComponent={
todos.length > 0 ? (
<View style={[styles.listFooter, { backgroundColor: colors.surface }]}>
<Text style={[styles.itemCount, { color: colors.textSecondary }]}>
{activeCount} item{activeCount !== 1 ? 's' : ''} left
</Text>
<TouchableOpacity
onPress={clearCompleted}
accessibilityLabel="Clear completed todos"
accessibilityRole="button"
>
<Text style={[styles.clearButton, { color: colors.textSecondary }]}>
Clear Completed
</Text>
</TouchableOpacity>
</View>
) : null
}
/>
{todos.length === 0 && (
<Text style={[styles.emptyText, { color: colors.textSecondary, paddingBottom: 20 }]}>
No todos yet. Add one above!
</Text>
)}
</View>
<View style={[styles.filterContainer, { backgroundColor: colors.surface, borderColor: colors.border, justifyContent: 'center' }]}>
<FilterTabs filter={filter} onFilterChange={setFilter} />
</View>
<Text style={[styles.instruction, { color: colors.textSecondary }]}>
Long press to drag and reorder list
</Text>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: 200,
alignItems: 'center',
paddingTop: 48,
},
headerBackground: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
headerContent: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
paddingHorizontal: 24,
},
title: {
fontSize: 36,
fontWeight: 'bold',
letterSpacing: 12,
},
content: {
flex: 1,
paddingHorizontal: 24,
marginTop: -95,
minHeight: '100%',
},
filterContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 5,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
minHeight: 48,
},
listContainer: {
borderRadius: 5,
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
maxHeight: 400,
},
list: {
maxHeight: 350,
},
emptyList: {
paddingVertical: 40,
},
emptyText: {
textAlign: 'center',
fontSize: 16,
},
footer: {
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 16,
borderRadius: 5,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
listFooter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 16,
},
itemCount: {
fontSize: 14,
fontWeight: '600',
},
footerCenter: {
flex: 1,
alignItems: 'center',
},
clearButton: {
fontSize: 14,
fontWeight: '600',
},
instruction: {
textAlign: 'center',
fontSize: 14,
marginTop: 40,
marginBottom: 20,
},
errorContainer: {
paddingHorizontal: 20,
paddingVertical: 12,
marginBottom: 16,
borderRadius: 5,
borderLeftWidth: 4,
borderLeftColor: '#ff6b6b',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
errorText: {
fontSize: 14,
fontWeight: '500',
},
});
Step 9: Adding Assets and Final Configuration
Create a global.css file for Tailwind:
@tailwind base;
@tailwind components;
@tailwind utilities;
Update babel.config.js:
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};
Update metro.config.js:
const { getDefaultConfig } = require('@expo/metro-config');
const config = getDefaultConfig(__dirname);
module.exports = config;
Step 10: Running the App
Seed the database with sample data:
npx convex run todos:seedTodos
Start the development server:
npx expo start
Conclusion
You've successfully built a modern, feature-rich todo app with React Native, Expo, and Convex! The app includes:
Real-time data synchronization with Convex
Drag-and-drop reordering with react-native-draggable-flatlist
Light/dark theme switching
Cross-platform compatibility
Clean, accessible UI with proper error handling
Key technologies used:
React Native & Expo: Cross-platform mobile development
Expo Router: File-based routing
Convex: Real-time backend and database
React Native Draggable FlatList: Drag-and-drop functionality
Context API: Theme management
TypeScript: Type safety
The app demonstrates modern React Native development practices including proper state management, error handling, accessibility, and responsive design. You can extend this further by adding features like user authentication, categories, due dates, or push notifications.
For the complete source code, check out the repository at: [Your Repository Link]
Happy coding! 🚀


