Skip to main content

Command Palette

Search for a command to run...

Building a Modern Todo App with React Native, Expo, and Convex

Updated
15 min read

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! 🚀

More from this blog

J

JavaScript Insight

17 posts

I enjoy keeping up with the latest trends and techniques in front-end development, I am dedicated to making technical concepts accessible and understandable to all.