Функционально приложение уже работает. Осталось довести UX до уровня, на котором не стыдно показать кому-то. Это всё мелочи, но их сумма — разница между «хм, это что-то странное» и «нормальное приложение».

Индикатор первичной загрузки

Когда страница только открылась, список ещё не пришёл. Пользователь видит пустоту. Простая статусная строка «Загружаю...» покрывает 90% случаев — уже сделана в loadTodos из 9.2. Можно сделать чуть приятнее: добавить «скелетон» — серые плейсхолдеры в форме итоговых элементов.

function showLoadingSkeleton() {
  listEl.innerHTML = Array.from({ length: 5 }, () => `
    <li class="skeleton">
      <div class="skel-checkbox"></div>
      <div class="skel-text"></div>
    </li>
  `).join('');
}

В CSS добавляем серые блоки с лёгкой анимацией мерцания — пользователь сразу понимает, что что-то грузится, и видит ожидаемую структуру.

Сообщения об ошибках

В предыдущих главах мы писали ошибки прямо в statusEl. Это работает, но у нас все сообщения сливаются с «Нет задач» и «Загружаю». Сделаем отдельный визуальный приём для ошибок:

function showError(message) {
  statusEl.textContent = message;
  statusEl.classList.add('error');
  setTimeout(() => {
    statusEl.classList.remove('error');
    statusEl.textContent = '';
  }, 4000);
}

В CSS — красный фон, белый текст, лёгкий padding. Через четыре секунды само исчезает.

Все statusEl.textContent = '...' на ошибках из 9.2–9.3 меняем на showError('...').

Disabled во время отправки

Чтобы пользователь не дважды добавил одну и ту же задачу, форма блокируется на время запроса. Уже было в демке из 5.2 — повторим тот же приём:

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

  const submitBtn = formEl.querySelector('button[type="submit"]');
  submitBtn.disabled = true;

  // ...остальной код добавления...

  submitBtn.disabled = false;
});

Для оптимистичного UI блокировка нужна меньше (UI уже обновился), но без неё пользователь может за секунду нажать «Добавить» десять раз и создать дубли. С блокировкой — нет.

Сокращаем тройную нажатую задачу

Если пользователь напечатал что-то и быстро трижды нажал кнопку — даже с submitBtn.disabled = true между нажатиями могут пройти миллисекунды. Защита — очистка инпута первой строкой:

const title = inputEl.value.trim();
if (!title) return;
inputEl.value = '';                  // ← мгновенно
// дальше — добавление, async/await

Пустой инпут — следующий trim() вернёт пустую строку, и обработчик выйдет на ранний return.

Что включает финальная демка

Всё, что мы написали в этом модуле, плюс несколько мелочей:

  • Скелетон при первичной загрузке.
  • Пустое состояние «Нет задач».
  • Disabled на кнопке отправки во время запроса.
  • Красная плашка с автоисчезновением для ошибок.
  • Оптимистичные добавление, удаление, переключение с откатом.
  • Защита от XSS через escapeHtml.

Что мы сделали

За один модуль написали маленькое, но настоящее веб-приложение, в котором задействованы:

  • Модуль 3 — четыре HTTP-метода (GET, POST, PATCH, DELETE).
  • Модуль 4 — fetch и async/await.
  • Модуль 5 — query-параметры (?_limit=10), POST-тело с JSON, PATCH с одним полем.
  • Модуль 6 — обёртка api() с проверкой response.ok, обработка ошибок, защита от 204 No Content.

Это и есть REST-фронт в миниатюре. Дальше можно бесконечно дорабатывать — добавить роуты, фильтры, drag’n’drop, синхронизацию между вкладками. Но фундамент — вот он.