DEV Community

Cover image for Build a Full-Stack Todo App with React & Node.js - The Complete Walkthrough
Christopher S. Aondona
Christopher S. Aondona

Posted on

Build a Full-Stack Todo App with React & Node.js - The Complete Walkthrough

You've probably built a todo app before. Maybe five times.

But have you ever really understood every line you wrote?

Today we're building one more. But differently.

At every step, I'll ask you questions. Try to answer them before scrolling. This is how you actually learn—active recall beats passive reading every time.


What We're Building

A full-stack todo app with:

  • React frontend with hooks
  • Express.js REST API
  • In-memory storage (for simplicity)
  • Full CRUD operations

By the end, you'll understand:

  • How useState and useEffect really work
  • REST API design patterns
  • Frontend-backend communication
  • Error handling strategies

Step 1: Project Setup

mkdir fullstack-todo && cd fullstack-todo
mkdir client server
Enter fullscreen mode Exit fullscreen mode

🤔 Before we continue...

Question 1: Why are we separating client and server into different folders? What's the alternative, and when would you use it?

Think about it for 30 seconds before continuing.


The answer: Separation of concerns. We could use a monolithic approach (like Next.js), but separate folders give us deployment flexibility and clearer mental models.


Step 2: Backend Setup

cd server
npm init -y
npm pkg set type="module"
npm install express cors
Enter fullscreen mode Exit fullscreen mode

Create server/index.js:

import express from 'express';
import cors from 'cors';

const app = express();
app.use(cors());
app.use(express.json());

let todos = [];
let nextId = 1;

// GET all todos
app.get('/api/todos', (req, res) => {
  res.json(todos);
});

// POST new todo
app.post('/api/todos', (req, res) => {
  const { title } = req.body;
  const todo = { id: nextId++, title, completed: false };
  todos.push(todo);
  res.status(201).json(todo);
});

// PATCH toggle completion
app.patch('/api/todos/:id', (req, res) => {
  const todo = todos.find(t => t.id === parseInt(req.params.id));
  if (!todo) return res.status(404).json({ error: 'Todo not found' });
  todo.completed = !todo.completed;
  res.json(todo);
});

// DELETE todo
app.delete('/api/todos/:id', (req, res) => {
  const index = todos.findIndex(t => t.id === parseInt(req.params.id));
  if (index === -1) return res.status(404).json({ error: 'Todo not found' });
  todos.splice(index, 1);
  res.status(204).send();
});

app.listen(3001, () => console.log('Server running on port 3001'));
Enter fullscreen mode Exit fullscreen mode

🤔 Stop and think...

Question 2: Why did we use 201 for POST and 204 for DELETE? What's the difference, and why does it matter?

Question 3: There's a bug in this code that would break in production. Can you spot it?

Hints:

  • What happens when the server restarts?
  • What happens if two users add todos simultaneously?

Step 3: Frontend Setup

cd ../client
npm create vite@latest . -- --template react
npm install
Enter fullscreen mode Exit fullscreen mode

Replace src/App.jsx:

import { useState, useEffect } from 'react';
import './App.css';

const API_URL = 'http://localhost:3001/api/todos';

function App() {
  const [todos, setTodos] = useState([]);
  const [newTodo, setNewTodo] = useState('');
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchTodos();
  }, []);

  async function fetchTodos() {
    try {
      setLoading(true);
      const res = await fetch(API_URL);
      if (!res.ok) throw new Error('Failed to fetch');
      const data = await res.json();
      setTodos(data);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }

  async function addTodo(e) {
    e.preventDefault();
    if (!newTodo.trim()) return;

    try {
      const res = await fetch(API_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title: newTodo }),
      });
      const todo = await res.json();
      setTodos([...todos, todo]);
      setNewTodo('');
    } catch (err) {
      setError('Failed to add todo');
    }
  }

  async function toggleTodo(id) {
    try {
      const res = await fetch(`${API_URL}/${id}`, { method: 'PATCH' });
      const updated = await res.json();
      setTodos(todos.map(t => t.id === id ? updated : t));
    } catch (err) {
      setError('Failed to update todo');
    }
  }

  async function deleteTodo(id) {
    try {
      await fetch(`${API_URL}/${id}`, { method: 'DELETE' });
      setTodos(todos.filter(t => t.id !== id));
    } catch (err) {
      setError('Failed to delete todo');
    }
  }

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <div className="app">
      <h1>My Todos</h1>

      <form onSubmit={addTodo}>
        <input
          value={newTodo}
          onChange={e => setNewTodo(e.target.value)}
          placeholder="Add a new todo..."
        />
        <button type="submit">Add</button>
      </form>

      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <span 
              onClick={() => toggleTodo(todo.id)}
              style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
            >
              {todo.title}
            </span>
            <button onClick={() => deleteTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

🤔 Understanding check...

Question 4: Why do we spread [...todos, todo] instead of todos.push(todo)? What would happen if we used push?

Question 5: The useEffect has an empty dependency array []. What does this mean? What would happen if we removed it entirely?


Step 4: Run It

Terminal 1:

cd server && node index.js
Enter fullscreen mode Exit fullscreen mode

Terminal 2:

cd client && npm run dev
Enter fullscreen mode Exit fullscreen mode

Visit http://localhost:5173 and test your app!


Challenges For You

If you really want to learn, try these without looking up the answers:

  1. Add persistence - Save todos to a JSON file
  2. Add validation - Prevent empty titles, limit title length
  3. Add optimistic updates - Update UI before server confirms
  4. Add filters - Show all/active/completed todos

What We Just Learned

  • REST API design patterns
  • React state management with hooks
  • Async/await with error handling
  • Frontend-backend separation

But here's the thing...

Did those questions help?

That active recall approach—stopping to think before seeing the answer—is proven to improve retention by 50% compared to passive reading.

Now imagine if you had an AI companion that could:

  • Ask you these questions in real-time
  • Adapt to YOUR specific confusion points
  • Review YOUR actual code, not generic examples
  • Remember what you've learned across projects

That's exactly what we're building at TekBreed.

Interactive tutorials and courses with AI that makes learning stick.

👉 Join the waitlist at tekbreed.com


What concept from this tutorial did you find most challenging? Drop a comment below!

Top comments (0)