Сейчас приложение умеет только показывать задачи. Добавим три действия: создать, удалить, переключить выполнение. Используем три разных HTTP-метода: POST, DELETE, PATCH — и применим важный приём: оптимистичный UI.

Что такое оптимистичный UI

Когда пользователь нажал «добавить» или «удалить», возможны два подхода.

Пессимистичный:

  1. Заблокировать форму.
  2. Дождаться ответа сервера.
  3. Если успех — обновить состояние и перерисовать.
  4. Разблокировать.

Безопасно, но медленно. Пользователь смотрит на спиннер, пока летит запрос.

Оптимистичный:

  1. Сразу обновить локальное состояние и перерисовать.
  2. Параллельно отправить запрос.
  3. Если сервер ответил ошибкой — откатить изменение и показать сообщение.

Кажется мгновенным. В 99% случаев сервер согласен — пользователь не видит задержки. На 1% ошибок откат корректно возвращает в прежнее состояние.

В нашем проекте используем оптимистичный подход — он практичнее и научит работать с реальным паттерном.

Создание задачи

const formEl  = document.querySelector('#form');
const inputEl = document.querySelector('#input');

formEl.addEventListener('submit', async (e) => {
  e.preventDefault();
  const title = inputEl.value.trim();
  if (!title) return;

  // Оптимистично добавляем задачу с временным id:
  const tempId = `tmp-${Date.now()}`;
  const optimistic = { id: tempId, title, completed: false, userId: 1 };
  todos.push(optimistic);
  inputEl.value = '';
  render();

  try {
    const saved = await api('/todos', {
      method: 'POST',
      body: JSON.stringify({ title, completed: false, userId: 1 }),
    });
    // Сервер вернул запись с настоящим id — подменяем временную:
    const idx = todos.findIndex(t => t.id === tempId);
    if (idx !== -1) todos[idx] = saved;
    render();
  } catch (error) {
    // Откатываем — убираем временную задачу:
    todos = todos.filter(t => t.id !== tempId);
    render();
    statusEl.textContent = 'Не получилось добавить: ' + error.message;
  }
});

Хитрость с временным id — чтобы было что подменить, когда придёт настоящий id от сервера. Префикс tmp- отличает временные id от настоящих числовых.

Удаление

async function deleteTodo(id) {
  // Сохраняем удаляемую задачу — на случай отката:
  const removed = todos.find(t => t.id === id);
  todos = todos.filter(t => t.id !== id);
  render();

  try {
    await api(`/todos/${id}`, { method: 'DELETE' });
  } catch (error) {
    // Откат — возвращаем задачу:
    if (removed) todos.push(removed);
    render();
    statusEl.textContent = 'Не удалось удалить: ' + error.message;
  }
}

Запрос идёт по конкретному URL ресурса: /todos/42. Тела нет, потому что DELETE и не должно ничего нести (см. главу 3.1).

Если задача добавлялась оптимистично и ещё не успела получить настоящий id — в id будет tmp-.... Делать DELETE для несуществующего ресурса бессмысленно — добавим проверку:

async function deleteTodo(id) {
  const removed = todos.find(t => t.id === id);
  todos = todos.filter(t => t.id !== id);
  render();

  // Не дёргаем сервер для ещё не сохранённой задачи:
  if (typeof id === 'string' && id.startsWith('tmp-')) return;

  try {
    await api(`/todos/${id}`, { method: 'DELETE' });
  } catch (error) {
    if (removed) todos.push(removed);
    render();
    statusEl.textContent = 'Не удалось удалить: ' + error.message;
  }
}

Переключение статуса

async function toggleTodo(id, completed) {
  // Оптимистично обновляем локально:
  const todo = todos.find(t => t.id === id);
  if (!todo) return;
  const previous = todo.completed;
  todo.completed = completed;
  render();

  if (typeof id === 'string' && id.startsWith('tmp-')) return;

  try {
    await api(`/todos/${id}`, {
      method: 'PATCH',
      body: JSON.stringify({ completed }),
    });
  } catch (error) {
    // Откат:
    todo.completed = previous;
    render();
    statusEl.textContent = 'Не удалось переключить: ' + error.message;
  }
}

Тут используем PATCH — передаём только одно изменившееся поле, ровно как в главе 5.4.

Что в итоге

На этом моменте у нас рабочий CRUD-клиент. Все четыре операции (Create, Read, Update, Delete) задействованы. Код — меньше ста строк, с оптимистичным UI и нормальными ошибочными ветками.

Дальше остался косметический штрих — индикаторы загрузки, нормальные сообщения об ошибках, блокировки кнопок во время операций. Об этом — в последней главе модуля.