How to Build a Full-Stack TODO App: A Step-by-Step Django & React Tutorial

By admin December 22, 2025 10 min read

How to Build a Full-Stack TODO App with Django & React

Building a full-stack application can seem daunting, but it’s an incredibly rewarding skill. By combining Django, a powerful Python web framework, with React, a modern JavaScript library for user interfaces, you can create dynamic, data-driven applications. In this comprehensive tutorial, we’ll walk through building a complete TODO application step-by-step. You’ll learn how to construct a RESTful API with Django REST Framework and connect it to an interactive React frontend. Let’s dive in and turn your ideas into a working application!

Project Overview and Setup

Our project is split into two main parts: the backend (API) built with Django and the frontend built with React. They will communicate via HTTP requests (GET, POST, PUT, DELETE). We’ll start by setting up our development environment.

To watch the full tutorial on YouTube, click here.

Prerequisites

Before starting, ensure you have the following installed:

  • Python (3.8 or higher)
  • Node.js and npm (Node Package Manager)
  • Git (for version control and cloning our repository)
  • A code editor (like VS Code)

Cloning the Starter Repository

We have prepared a complete codebase for you. Open your terminal and clone the repository to get all the necessary files.

git clone https://github.com/chrisHalogen/todo-app.git
cd todo-app

Inside the repository, you will find two main directories: backend/ for Django and frontend/ for React. Let’s explore and set up each one.

Pro Tip: It’s highly recommended to use a virtual environment for Python projects to manage dependencies. We’ll set one up in the next step.

Part 1: Building the Django REST API Backend

Our backend will handle data storage, business logic, and serve as a secure API endpoint for our React app.

Setting Up the Django Project & Virtual Environment

Navigate to the backend directory and create a Python virtual environment. This keeps our project’s dependencies isolated.

cd backend
python -m venv venv

Activate the virtual environment:

  • On macOS/Linux: source venv/bin/activate
  • On Windows: venv\Scripts\activate

Once activated, install the required Python packages from the requirements.txt file.

pip install -r requirements.txt

Understanding the Django Application Structure

Let’s look at the key files in our backend directory that make our API work.

  • manage.py: The Django command-line utility.
  • backend/settings.py: Main configuration file for the Django project.
  • backend/urls.py: The root URL router for the project.
  • todos/: This is our Django “app” dedicated to the TODO functionality.

Creating the Todo Model

The model defines the structure of our data in the database. Open todos/models.py.

from django.db import models

class Todo(models.Model):
    title = models.CharField(max_length=200)
    description = models.TextField(blank=True, null=True)
    completed = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.title

This simple model has four fields: a title, an optional description, a completion status, and an automatic timestamp. The __str__ method defines how a Todo object is displayed in the Django admin.

Creating Serializers with Django REST Framework

Serializers convert complex data types (like Django model instances) into native Python datatypes that can be easily rendered into JSON. Create or open todos/serializers.py.

from rest_framework import serializers
from .models import Todo

class TodoSerializer(serializers.ModelSerializer):
    class Meta:
        model = Todo
        fields = '__all__'  # Includes all model fields: id, title, description, completed, created_at
        read_only_fields = ('id', 'created_at')  # These fields are set automatically

Building API Views (Function-Based Views)

Views handle the logic for processing API requests. We are using function-based views decorated with @api_view. Open todos/views.py.

from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status
from .models import Todo
from .serializers import TodoSerializer

@api_view(['GET', 'POST'])
def todo_list(request):
    """
    List all todos, or create a new todo.
    """
    if request.method == 'GET':
        todos = Todo.objects.all().order_by('-created_at')
        serializer = TodoSerializer(todos, many=True)
        return Response(serializer.data)

    elif request.method == 'POST':
        serializer = TodoSerializer(data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data, status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

@api_view(['GET', 'PUT', 'DELETE'])
def todo_detail(request, pk):
    """
    Retrieve, update or delete a single todo instance.
    """
    try:
        todo = Todo.objects.get(pk=pk)
    except Todo.DoesNotExist:
        return Response(status=status.HTTP_404_NOT_FOUND)

    if request.method == 'GET':
        serializer = TodoSerializer(todo)
        return Response(serializer.data)

    elif request.method == 'PUT':
        serializer = TodoSerializer(todo, data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

    elif request.method == 'DELETE':
        todo.delete()
        return Response(status=status.HTTP_204_NO_CONTENT)

Configuring URLs

We need to map our views to specific URL paths. First, in the app’s todos/urls.py:

from django.urls import path
from . import views

urlpatterns = [
    path('todos/', views.todo_list, name='todo_list'),
    path('todos/<int:pk>/', views.todo_detail, name='todo_detail'),
]

Then, include these app URLs in the project’s main backend/urls.py:

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('todos.urls')),  # All our API routes will be under /api/
]

Running Migrations and Starting the Server

Migrations translate our model into database tables. Run the following commands:

python manage.py makemigrations
python manage.py migrate

Now, let’s create a superuser to access the Django admin interface.

python manage.py createsuperuser

Finally, start the Django development server:

python manage.py runserver

Your API is now live at http://127.0.0.1:8000/api/todos/. You can also visit /admin to log in and manage todos directly.

Key Takeaway: At this point, you have a fully functional REST API capable of CRUD (Create, Read, Update, Delete) operations. Test it using a tool like Postman or your browser before moving to the frontend.

Part 2: Building the React Frontend

Now, let’s build the user interface that will consume our Django API. Open a new terminal window, navigate to the project root, and then into the frontend directory.

Installing Dependencies and Project Structure

cd frontend
npm install

This installs all required packages (like React, Axios for HTTP requests, and React Icons). Let’s examine the main files:

  • src/App.js: The main React component.
  • src/components/: Contains reusable components like TodoList.js, TodoForm.js.
  • src/services/: Contains the api.js file which configures Axios to talk to our Django backend.

API Service Configuration

The src/services/api.js file sets up a base Axios instance, defining the base URL of our Django API. This is crucial for connecting the frontend to the backend.

import axios from 'axios';

const api = axios.create({
    baseURL: 'http://127.0.0.1:8000/api/', // Your Django server address
});

export default api;

Main App Component and State Management

Open src/App.js. This component is the brain of our frontend. It manages the state (the list of todos) and coordinates data flow between the form and the list.

import React, { useState, useEffect } from 'react';
import './App.css';
import TodoList from './components/TodoList';
import TodoForm from './components/TodoForm';
import api from './services/api';

function App() {
    const [todos, setTodos] = useState([]);
    const [editingTodo, setEditingTodo] = useState(null);

    // Fetch todos from API on component mount
    useEffect(() => {
        fetchTodos();
    }, []);

    const fetchTodos = async () => {
        try {
            const response = await api.get('/todos/');
            setTodos(response.data);
        } catch (error) {
            console.error('Error fetching todos:', error);
        }
    };

    const addTodo = async (todo) => {
        try {
            const response = await api.post('/todos/', todo);
            setTodos([response.data, ...todos]); // Add new todo to the start of the list
        } catch (error) {
            console.error('Error adding todo:', error);
        }
    };

    const updateTodo = async (updatedTodo) => {
        try {
            const response = await api.put(`/todos/${updatedTodo.id}/`, updatedTodo);
            setTodos(todos.map(todo => (todo.id === updatedTodo.id ? response.data : todo)));
            setEditingTodo(null); // Exit editing mode
        } catch (error) {
            console.error('Error updating todo:', error);
        }
    };

    const deleteTodo = async (id) => {
        try {
            await api.delete(`/todos/${id}/`);
            setTodos(todos.filter(todo => todo.id !== id)); // Remove todo from state
        } catch (error) {
            console.error('Error deleting todo:', error);
        }
    };

    const toggleComplete = async (todo) => {
        const updatedTodo = { ...todo, completed: !todo.completed };
        await updateTodo(updatedTodo);
    };

    return (
        <div className="app">
            <header className="app-header">
                <h1>My TODO List</h1>
            </header>
            <main className="app-main">
                <TodoForm
                    addTodo={addTodo}
                    editingTodo={editingTodo}
                    updateTodo={updateTodo}
                />
                <TodoList
                    todos={todos}
                    deleteTodo={deleteTodo}
                    toggleComplete={toggleComplete}
                    setEditingTodo={setEditingTodo}
                />
            </main>
        </div>
    );
}

export default App;

Building the TodoForm Component

This component (src/components/TodoForm.js) handles both creating new todos and editing existing ones. It uses controlled form inputs.

import React, { useState, useEffect } from 'react';
import { FaSave, FaPlusCircle } from 'react-icons/fa';

const TodoForm = ({ addTodo, editingTodo, updateTodo }) => {
    const [title, setTitle] = useState('');
    const [description, setDescription] = useState('');

    // If we are editing a todo, pre-fill the form
    useEffect(() => {
        if (editingTodo) {
            setTitle(editingTodo.title);
            setDescription(editingTodo.description || '');
        } else {
            setTitle('');
            setDescription('');
        }
    }, [editingTodo]);

    const handleSubmit = (e) => {
        e.preventDefault();
        if (!title.trim()) return; // Don't submit empty titles

        const todoData = { title, description };

        if (editingTodo) {
            updateTodo({ ...editingTodo, ...todoData });
        } else {
            addTodo(todoData);
        }

        // Reset form
        setTitle('');
        setDescription('');
    };

    return (
        <form className="todo-form" onSubmit={handleSubmit}>
            <div className="form-group">
                <input
                    type="text"
                    placeholder="What needs to be done?"
                    value={title}
                    onChange={(e) => setTitle(e.target.value)}
                    className="form-input"
                />
            </div>
            <div className="form-group">
                <textarea
                    placeholder="Description (optional)"
                    value={description}
                    onChange={(e) => setDescription(e.target.value)}
                    className="form-textarea"
                />
            </div>
            <button type="submit" className="btn btn-primary">
                {editingTodo ? (<><FaSave /> Update Todo</>) : (<><FaPlusCircle /> Add Todo</>)}
            </button>
        </form>
    );
};

export default TodoForm;

Building the TodoList and TodoItem Components

The TodoList component (src/components/TodoList.js) maps over the todos array and renders a TodoItem for each one.

import React from 'react';
import TodoItem from './TodoItem';

const TodoList = ({ todos, deleteTodo, toggleComplete, setEditingTodo }) => {
    return (
        <div className="todo-list">
            {todos.length === 0 ? (
                <p className="no-todos">No todos yet. Add one above!</p>
            ) : (
                todos.map(todo => (
                    <TodoItem
                        key={todo.id}
                        todo={todo}
                        deleteTodo={deleteTodo}
                        toggleComplete={toggleComplete}
                        setEditingTodo={setEditingTodo}
                    />
                ))
            )}
        </div>
    );
};

export default TodoList;

The TodoItem component (src/components/TodoItem.js) displays a single todo and its action buttons.

import React from 'react';
import { FaCheck, FaTimes, FaEdit, FaTrash } from 'react-icons/fa';

const TodoItem = ({ todo, deleteTodo, toggleComplete, setEditingTodo }) => {
    return (
        <div className={`todo-item ${todo.completed ? 'completed' : ''}`}>
            <div className="todo-content" onClick={() => toggleComplete(todo)}>
                <div className="todo-status">
                    {todo.completed ? <FaCheck className="icon-check" /> : <div className="status-pending"></div>}
                </div>
                <div className="todo-text">
                    <h3 className="todo-title">{todo.title}</h3>
                    {todo.description && <p className="todo-description">{todo.description}</p>}
                    <small className="todo-date">Created: {new Date(todo.created_at).toLocaleDateString()}</small>
                </div>
            </div>
            <div className="todo-actions">
                <button onClick={() => setEditingTodo(todo)} className="btn btn-edit">
                    <FaEdit />
                </button>
                <button onClick={() => deleteTodo(todo.id)} className="btn btn-delete">
                    <FaTrash />
                </button>
            </div>
        </div>
    );
};

export default TodoItem;

Styling the Application

The visual appeal comes from src/App.css. The provided CSS uses Flexbox for layout, clear visual feedback for completed items, and responsive button styling. Key styles include the .completed class for strikethrough text and distinct colors for primary, edit, and delete actions.

.app {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.todo-item.completed .todo-title {
  text-decoration: line-through;
  color: #888;
}
.btn-primary {
  background-color: #4CAF50;
  color: white;
}
.btn-edit {
  background-color: #2196F3;
  color: white;
}
.btn-delete {
  background-color: #f44336;
  color: white;
}

Running the React Development Server

With the Django backend still running, start the React frontend in your terminal (from the frontend directory):

npm start

Your application will open in your browser at http://localhost:3000. You can now add, edit, complete, and delete TODOs. The changes will be saved to your Django backend via the API!

Handling Cross-Origin Resource Sharing (CORS)

Since your frontend (localhost:3000) and backend (localhost:8000) are on different origins (ports), browsers block requests by default due to the same-origin policy. We installed and configured django-cors-headers in the backend to allow these requests. The settings are already in backend/settings.py.

Conclusion and Next Steps

Congratulations! You’ve successfully built a full-stack TODO application. You’ve learned how to:

  • Create a Django model and REST API with Django REST Framework.
  • Build a React frontend with functional components and Hooks (useState, useEffect).
  • Perform CRUD operations by connecting React to a Django API using Axios.
  • Style a responsive and user-friendly interface.

Where to Go From Here: This project is a solid foundation. Consider enhancing it by adding user authentication (with Django’s django-rest-auth or dj-rest-auth), task due dates, categories, or deploying it to a platform like Heroku or DigitalOcean.

To watch the full tutorial on YouTube, click here.

Feel free to explore the complete source code on GitHub and modify it to fit your needs. Happy coding from the team at Halogenius Ideas!

Watch The Video

Watch on YouTube

How did this article make you feel?

Share This Post

About admin

Founder and Lead Developer at Halogenius Ideas, bridging the gap between professional web design and accessible tech education. With years of experience in full-stack development and a passion for teaching, I lead a team dedicated to building stunning digital experiences while empowering the next generation of developers through comprehensive tutorials and courses. When I'm not coding or creating content, you'll find me exploring new technologies and mentoring aspiring developers.

Leave a Reply

Your email address will not be published. Required fields are marked *

three + = 11
Powered by MathCaptcha