Next.js To Do App Tutorial

Learn how to create a basic To Do App with Next.js by example

Step 1: Create Next.js App

npx create-next-app@latest

Note: Opt out of TailwindCSS for this tutorial.

Step 2: Install dependencies

Install lodash for its debounce utility.

npm i lodash
npm i --save-dev @types/lodash

Step 3: Clear out the default styles

Remove everything in /app/globals.css and /app/page.module.css.

Step 4: Add the To Do App code to the home page

/app/page.tsx

"use client";

import { useEffect, useState } from "react";
import _ from "lodash";

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

const URL = "https://jsonplaceholder.typicode.com";

export default function Home() {
  const [todos, setTodos] = useState<Todo[]>();

  useEffect(() => {
    async function fetchData() {
      const todos = await fetchTodos();
      setTodos(todos);
    }
    fetchData();
  }, []);

  async function fetchTodos(): Promise<Todo[]> {
    const response = await fetch(`${URL}/todos`);
    return await response.json();
  }

  async function handleClick(todo: Todo) {
    const idx = todos!.findIndex((t) => t.id === todo.id);
    const newState = [...todos!];
    newState.splice(idx, 1, { ...todo, completed: !todo.completed });
    setTodos(newState);
    const response = await fetch(`${URL}/todos/${todo.id}`, {
      method: "PATCH",
      body: JSON.stringify({
        completed: !todo.completed,
      }),
    });
    return await response.json();
  }

  async function handleCreate() {
    const response = await fetch(`${URL}/todos`, {
      method: "POST",
      body: JSON.stringify({
        title: "",
      }),
    });
    const json = await response.json();

    const newTodo: Todo = {
      id: json.id,
      title: "",
      completed: false,
    };
    const newState = [newTodo, ...todos!];
    setTodos(newState);
  }

  const debouncedTitleChange = _.debounce(
    async (newTitle: string, todo: Todo) => {
      await fetch(`${URL}/todos/${todo.id}`, {
        method: "PATCH",
        body: JSON.stringify({
          title: newTitle,
        }),
      });

      const idx = todos!.findIndex((t) => t.id === todo.id);
      const newState = [...todos!];
      newState.splice(idx, 1, { ...todo, title: newTitle });
      setTodos(newState);
    },
    500
  );

  async function handleTitleChange(
    e: React.ChangeEvent<HTMLInputElement>,
    todo: Todo
  ) {
    debouncedTitleChange(e.target.value, todo);
  }

  async function handleDelete(todo: Todo) {
    const idx = todos!.findIndex((t) => t.id === todo.id);
    const newState = [...todos!];
    newState.splice(idx, 1);
    setTodos(newState);
    await fetch(`${URL}/todos/${todo.id}`, {
      method: "DELETE",
    });
  }

  async function addTenThousandTodos() {
    const newTodos: Todo[] = [];
    for (let i = 0; i < 10000; i++) {
      newTodos.push({ id: 1000 + i, title: `to do ${i}`, completed: false });
    }
    setTodos([...newTodos, ...todos!]);
  }

  if (!todos) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      <h1>Next.js TODO</h1>
      {todos && (
        <div>
          <button onClick={handleCreate}>Create New To Do</button>
          <button onClick={addTenThousandTodos}>Add 10,000 To Dos</button>
          <ul>
            {todos.map((todo) => (
              <li key={todo.id}>
                <input
                  type="checkbox"
                  checked={todo.completed}
                  onChange={() => handleClick(todo)}
                />{" "}
                <input
                  type="text"
                  defaultValue={todo.title}
                  onChange={(e) => handleTitleChange(e, todo)}
                />
                <button onClick={() => handleDelete(todo)}>delete</button>
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}

Notes

  • "use client" is used at the top to make this page a client component, as there are user interactions on this page.
  • jsonplaceholder is used as the fake API for our To Do App.
  • useState is used for managing the state of the todos array.
  • useEffect is used to trigger the fetchTodos function once.
  • handleClick is an async function that updates the state of the todos state and sends a PATCH request to the fake API.
  • handleCreate is an async function that sends a POST request to the fake API and adds the new Todo object to the todos state.
  • debouncedTitleChange is an async function wrapped by lodash's debounce to update the title of a todo. This is to limit the number of requests sent to the server.
  • handleTitleChange is an async function triggered by updating a Todo's title. Parameters are passed to the debounced function.
  • addTenThousandToDos is a function that we will use in an upcoming benchmarking test.
  • A loading div is returned if todos are not yet fetched and loaded.
  • A JavaScript expression is used to map the list of todos to HTML elements.

References