MakeFirstStep· Frontend + ИИ

Урок 24 · React Lite (бонус) · финал курса

useEffect + fetch: мини-приложение на React и дорожная карта

Почти любое реальное приложение что-то загружает: список товаров, расписание, профиль. В React для этого есть useEffect — и именно с ним новички делают больше всего ошибок: бесконечные запросы, «залипшие» старые ответы, пустой экран при сбое сети. Сегодня мы научимся делать это правильно, перенесём приложение на API в React и составим план, куда расти дальше — к Уровню 3 и Next.js.

  • Объяснять, когда выполняется useEffect, и выбирать правильный массив зависимостей
  • Писать cleanup: останавливать таймеры, подписки и запросы
  • Загружать данные из API и показывать четыре состояния: загрузка, ошибка, пусто, успех
  • Отменять устаревшие запросы через AbortController
  • Делать поиск с debounce и фильтрацию без лишнего состояния
  • Понимать, что добавляют Next.js и серверные компоненты, и иметь свой план на 8 недель
Как работать с этой страницей

Код с кнопкой ▶ Запустить — живой. Блоки с вкладкой App.jsx и областью «Предпросмотр» — мини-приложения на React: нажмите «Запустить», и приложение появится справа, а всё, что оно выводит через console.log, — в консоли под ним. Первый запуск React-блока подгружает библиотеки из интернета, это занимает пару секунд. В задачах «настоящий сервер» заменён стендом — функцией fakeFetch, которая отвечает по таймеру: так результат всегда одинаковый и проверяется автоматически. Один живой пример ниже ходит на настоящий API — он без автопроверки.

Предполагаем, что вы уже знакомы с уроками 22–23: компонент — это функция, возвращающая JSX, useState хранит данные, .map() с key рисует списки. С fetch, промисами и AbortController вы работали в уроках 13–16.

01 useEffect: когда он срабатывает

Компонент React — это чистая функция: получила данные (props и state), вернула описание экрана (JSX). Всё. Рендер не должен ничего «делать» во внешнем мире: слать запросы, запускать таймеры, менять заголовок вкладки. Такие действия называются побочными эффектами, и для них в React есть отдельное место — хук useEffect.

Аналогия из жизни

В чойхоне официант сначала накрывает стол — это рендер: скатерть, пиалы, лепёшки. Только когда стол накрыт, он идёт на кухню за чайником — это эффект. Нельзя отправляться на кухню посреди накрытия стола: гость уже сидит и смотрит на пустой стол. React поступает так же: сначала показывает экран, и лишь после этого запускает ваш эффект.

Порядок: рендер → экран → эффект

Нажмите «Запустить», затем несколько раз на кнопку в предпросмотре — и следите за консолью.

Рендер и эффект · Ctrl + Enter
import { useEffect, useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  // Это тело компонента — РЕНДЕР: React считает, как должен выглядеть экран
  console.log("1. рендер: считаем JSX, count =", count);

  // А это ЭФФЕКТ: React вызовет его уже ПОСЛЕ того, как экран обновлён
  useEffect(() => {
    console.log("2. эффект: экран обновлён, count =", count);
  });

  return <button onClick={() => setCount(count + 1)}>Нажато: {count}</button>;
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
  1. Рендер. React вызывает функцию компонента и получает JSX. Любые console.log в теле компонента выполняются на этом шаге.
  2. Коммит. React обновляет настоящий DOM — пользователь видит новый экран.
  3. Эффект. React вызывает функции из useEffect. Здесь безопасно ходить в сеть, ставить таймеры и трогать document.

Массив зависимостей: когда повторять эффект

Вторым аргументом useEffect принимает массив зависимостей. Он отвечает на вопрос «после каких рендеров запускать эффект снова?»

ЗаписьКогда срабатываетТипичное применение
useEffect(fn)после каждого рендерапочти никогда; чаще это ошибка
useEffect(fn, [])один раз, после первого рендеразагрузка данных при открытии, подписка
useEffect(fn, [a, b])после первого рендера и каждый раз, когда a или b изменилисьзапрос по id или поисковой строке

Попробуйте: нажимайте кнопки a и b и смотрите, какие из трёх эффектов откликаются.

Три варианта зависимостей · Ctrl + Enter
import { useEffect, useState } from "react";

export default function App() {
  const [a, setA] = useState(0);
  const [b, setB] = useState(0);

  useEffect(() => {
    console.log("A (без массива): после КАЖДОГО рендера");
  });

  useEffect(() => {
    console.log("B ([]): один раз, после первого рендера");
  }, []);

  useEffect(() => {
    console.log("C ([a]): изменился a, теперь a =", a);
  }, [a]);

  return (
    <div style={{ display: "flex", gap: 8 }}>
      <button onClick={() => setA(a + 1)}>a = {a}</button>
      <button onClick={() => setB(b + 1)}>b = {b}</button>
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Правило зависимостей

В массив нужно записать все значения из компонента (props, state, функции), которые эффект читает. Если эффект использует id, а в массиве его нет, эффект будет работать со «старым» id — самый частый источник багов. Хороший редактор (и ИИ) подскажет это правилом react-hooks/exhaustive-deps; не отключайте его, а исправляйте код.

Частая ошибка: бесконечный цикл

Эффект без массива, который меняет состояние, запускает новый рендер, тот — новый эффект, и так до бесконечности. Браузер зависает или React бросает ошибку «Too many re-renders». Не запускайте этот код у себя в рабочем проекте.

Так делать нельзя: бесконечный цикл
function Counter() {
  const [n, setN] = useState(0);

  useEffect(() => {
    setN(n + 1); // ✖ нет массива зависимостей: рендер → эффект → setN → рендер → ...
  });

  return <p>{n}</p>;
}

Читать подробнее: learn.javascript.ru — Замыкание · react.dev — useEffect · react.dev — Synchronizing with Effects

Практика: закрепляем

В каждой задаче «виртуальный пользователь» (useScript) сам меняет состояние по таймеру — так результат в консоли всегда одинаковый и проверяется автоматически. Блок useScript и всё помеченное «стенд» не меняйте.

ЗАДАЧА 1.1

Страница открыта

лёгкая

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

Задача 1.1 · Ctrl + Enter
import { useEffect, useState } from "react";

export default function App() {
  const [visits, setVisits] = useState(0);

  // стенд: через 50 мс счётчик меняется (как будто пользователь вернулся)
  useEffect(() => {
    const id = setTimeout(() => setVisits(1), 50);
    return () => clearTimeout(id);
  }, []);

  // TODO: добавьте useEffect, который ОДИН раз после первого рендера
  // выведет в консоль "Страница открыта"

  console.log("рендер", visits);

  return <p>Визитов: {visits}</p>;
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
рендер 0
Страница открыта
рендер 1
Подсказка 1

Эффект с пустым массивом зависимостей [] выполняется только после первого рендера.

Подсказка 2

useEffect(() => { console.log("Страница открыта"); }, []); Второй рендер происходит из-за стенда, но эффект с [] на него уже не реагирует.

ЗАДАЧА 1.2

Сохраняем только счёт

средняя

Стенд меняет счёт, затем тему, затем снова счёт. Напишите эффект, который выводит сохраняем счёт: N при каждом изменении счёта (включая первый показ), но не реагирует на смену темы.

Задача 1.2 · Ctrl + Enter
import { useEffect, useState } from "react";

// стенд: «виртуальный пользователь» выполняет шаги по таймеру
function useScript(steps) {
  useEffect(() => {
    const ids = steps.map(([ms, fn]) => setTimeout(fn, ms));
    return () => ids.forEach(clearTimeout);
  }, []);
}

export default function App() {
  const [count, setCount] = useState(0);
  const [theme, setTheme] = useState("light");

  // стенд: счёт +1, потом смена темы, потом счёт ещё +1
  useScript([
    [40, () => setCount(1)],
    [80, () => setTheme("dark")],
    [120, () => setCount(2)],
  ]);

  // TODO: эффект, который выводит "сохраняем счёт: N"
  // и срабатывает ТОЛЬКО когда изменился count (смена темы его не касается)

  return <p className={theme}>Счёт: {count}</p>;
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
сохраняем счёт: 0
сохраняем счёт: 1
сохраняем счёт: 2
Подсказка 1

Чтобы эффект следил только за счётом, положите в массив зависимостей именно эту переменную.

Подсказка 2

useEffect(() => { console.log("сохраняем счёт:", count); }, [count]);

ЗАДАЧА 1.3

Заголовок вкладки

лёгкая

Компонент получает имя, которое меняется. Напишите эффект: при каждой смене имени он ставит document.title и выводит его в консоль. Изменение document.title — классический побочный эффект: он выходит за пределы JSX.

Задача 1.3 · Ctrl + Enter
import { useEffect, useState } from "react";

function useScript(steps) {
  useEffect(() => {
    const ids = steps.map(([ms, fn]) => setTimeout(fn, ms));
    return () => ids.forEach(clearTimeout);
  }, []);
}

export default function App() {
  const [name, setName] = useState("Мадина");
  useScript([[50, () => setName("Фарход")]]); // стенд: имя меняется через 50 мс

  // TODO: эффект, который при каждой смене name
  // 1) ставит document.title = "Привет, " + name
  // 2) выводит console.log("заголовок:", document.title)

  return <h1>Привет, {name}!</h1>;
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
заголовок: Привет, Мадина
заголовок: Привет, Фарход
Подсказка 1

Эффект должен перезапускаться при смене name — значит, name в массиве зависимостей.

Подсказка 2

document.title = "Привет, " + name;, а следующей строкой console.log("заголовок:", document.title);

02 Cleanup: за собой надо убирать

Эффект может вернуть функцию — её называют cleanup («уборка»). React вызовет её в двух случаях: перед тем как запустить эффект заново (при смене зависимостей) и когда компонент исчезает с экрана.

Шаблон эффекта с cleanup
useEffect(() => {
  // 1) «включаем»: таймер, подписка, запрос
  const id = setInterval(tick, 1000);

  // 2) возвращаем «выключатель»
  return () => clearInterval(id);
}, []);
Аналогия из жизни

Вы приехали на маршрутке и включили в ней радио на всю громкость. Если выходите, не выключив — радио играет для пустого салона и мешает следующим пассажирам. Cleanup — это «выключить радио, выходя». Таймер без cleanup точно так же продолжает тикать у компонента, которого уже нет.

Порядок при смене зависимостей

  1. Эффект запустился с room = "Общий": подключились.
  2. room поменялся на "Группа JS": React сначала вызывает cleanup старого эффекта (отключились от «Общий»)…
  3. …и только затем запускает эффект с новым значением (подключились к «Группа JS»).
  4. Компонент убрали с экрана: последний cleanup — отключились.

StrictMode: зачем React запускает эффект дважды

Когда вы создаёте проект на Vite или Next.js, компоненты обычно обёрнуты в <StrictMode>. В режиме разработки он нарочно делает «репетицию»: запускает эффект, сразу вызывает cleanup и запускает эффект ещё раз. Так React проверяет, что ваш эффект умеет «выключаться» и не оставляет мусор. В готовой сборке (production) этого дублирования нет.

Компонент в StrictMode (режим разработки)
function Clock() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    console.log("эффект: запускаем таймер");
    const id = setInterval(() => setSeconds((s) => s + 1), 1000);
    return () => {
      console.log("cleanup: останавливаем таймер");
      clearInterval(id);
    };
  }, []);

  return <p>Прошло секунд: {seconds}</p>;
}
Что вы увидите в консоли при разработке с StrictMode
эффект: запускаем таймер
cleanup: останавливаем таймер
эффект: запускаем таймер
Как относиться к «двойному запуску»

Это не баг и не «React сломался». Если после двойного запуска приложение работает как надо — cleanup написан верно. Если запрос уходит дважды или таймеры ускоряются — вы забыли cleanup. Учебные окна на этой странице работают в режиме production, поэтому дубля в них не видно; проверьте сами, когда создадите проект на Vite.

Частая ошибка: забыли очистить таймер

Без cleanup интервал живёт после ухода компонента: утечка памяти и обновления состояния несуществующего компонента.

Так делать нельзя: нет cleanup
useEffect(() => {
  setInterval(() => console.log("тик"), 1000); // ✖ никто не остановит этот таймер
}, []);

Читать подробнее: learn.javascript.ru — setTimeout и setInterval · react.dev — You Might Not Need an Effect

Практика: закрепляем

ЗАДАЧА 2.1

Остановите таймер

лёгкая

Компонент Ticker каждые 100 мс пишет «тик N». Стенд убирает его с экрана через 350 мс. Допишите cleanup: он останавливает таймер и выводит таймер остановлен. Если забыть cleanup, тики не прекратятся — запустите стартовый код и убедитесь.

Задача 2.1 · Ctrl + Enter
import { useEffect, useState } from "react";

function useScript(steps) {
  useEffect(() => {
    const ids = steps.map(([ms, fn]) => setTimeout(fn, ms));
    return () => ids.forEach(clearTimeout);
  }, []);
}

function Ticker() {
  useEffect(() => {
    let n = 0;
    const id = setInterval(() => {
      n++;
      console.log("тик", n);
    }, 100);
    // TODO: верните функцию очистки: она останавливает таймер (clearInterval)
    // и выводит "таймер остановлен"
  }, []);
  return <p>Идёт отсчёт…</p>;
}

export default function App() {
  const [show, setShow] = useState(true);
  useScript([[350, () => setShow(false)]]); // стенд: через 350 мс компонент скрывают
  return show ? <Ticker /> : <p>Скрыто</p>;
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
тик 1
тик 2
тик 3
таймер остановлен
Подсказка 1

В конце эффекта нужен return () => { ... }. Идентификатор таймера id у вас уже есть.

Подсказка 2

return () => { clearInterval(id); console.log("таймер остановлен"); };

ЗАДАЧА 2.2

Комнаты чата

средняя

Напишите эффект компонента Chat: при показе комнаты вызывайте connect(room), а при смене комнаты или закрытии чата — disconnect(room) для той комнаты, к которой подключались. Обратите внимание на порядок строк.

Задача 2.2 · Ctrl + Enter
import { useEffect, useState } from "react";

function useScript(steps) {
  useEffect(() => {
    const ids = steps.map(([ms, fn]) => setTimeout(fn, ms));
    return () => ids.forEach(clearTimeout);
  }, []);
}

// «подключение к комнате чата»
function connect(room) { console.log("подключились:", room); }
function disconnect(room) { console.log("отключились:", room); }

function Chat({ room }) {
  // TODO: подключайтесь к комнате, а при смене комнаты и при закрытии чата — отключайтесь
  return <p>Комната: {room}</p>;
}

export default function App() {
  const [room, setRoom] = useState("Общий");
  const [open, setOpen] = useState(true);
  // стенд: через 50 мс — другая комната, через 100 мс — чат закрывают
  useScript([
    [50, () => setRoom("Группа JS")],
    [100, () => setOpen(false)],
  ]);
  return open ? <Chat room={room} /> : <p>Чат закрыт</p>;
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
подключились: Общий
отключились: Общий
подключились: Группа JS
отключились: Группа JS
Подсказка 1

Эффект зависит от room: он должен перезапускаться при её смене. А cleanup «помнит» то значение room, с которым был запущен его эффект.

Подсказка 2

useEffect(() => { connect(room); return () => disconnect(room); }, [room]);

03 Загрузка данных: четыре состояния

Самая частая работа для useEffect — получить данные с сервера при открытии экрана. Запрос — это побочный эффект, поэтому ему не место в теле компонента. А раз ответ приходит не сразу, экран живёт в четырёх состояниях, и каждое нужно показать пользователю (вы уже встречали их в уроке 16):

СостояниеЧто видит пользовательКак узнаём
loading«Загрузка…», скелетон или спиннерзапрос ушёл, ответа ещё нет
errorпонятное сообщение и кнопка «Повторить»сеть упала или response.ok === false
empty«Ничего не найдено»ответ пришёл, но массив пустой
successсписок или карточкиответ пришёл, данные есть
Аналогия из жизни

Вы заказали такси в приложении. «Ищем машину» — loading. «Машин нет, попробуйте позже» — error. «В вашем районе нет доступных тарифов» — empty. «Водитель Фарход приедет через 4 минуты» — success. Если приложение показывает пустой белый экран вместо любого из этих сообщений, пользователь решит, что оно сломано.

Скелет компонента

Состояний у экрана несколько — держим их в useState. «Пусто» отдельно хранить не нужно: это успех с пустым массивом.

Шаблон: загрузка данных в useEffect
function StudentList() {
  const [status, setStatus] = useState("loading"); // loading | error | success
  const [students, setStudents] = useState([]);
  const [error, setError] = useState("");

  useEffect(() => {
    fetch("/api/students")
      .then((response) => {
        if (!response.ok) throw new Error("HTTP " + response.status); // 404 и 500 — тоже ошибка
        return response.json();
      })
      .then((data) => {
        setStudents(data);
        setStatus("success");
      })
      .catch((err) => {
        setError(err.message);
        setStatus("error");
      });
  }, []);

  if (status === "loading") return <p>Загрузка…</p>;
  if (status === "error") return <p>Ошибка: {error}</p>;
  if (students.length === 0) return <p>Ничего не найдено</p>;

  return (
    <ul>
      {students.map((s) => <li key={s.id}>{s.name}</li>)}
    </ul>
  );
}

Обратите внимание на порядок if: сначала ранние возвраты для загрузки, ошибки и пустого списка, а в конце — «счастливый путь» с данными. Так компонент читается сверху вниз, как чек-лист.

Частая ошибка: async прямо в useEffect

Писать useEffect(async () => { ... }) нельзя: async-функция всегда возвращает промис, а React ждёт от эффекта либо ничего, либо функцию cleanup. Если нужен await — объявите async-функцию внутри эффекта и вызовите её (как в задаче 3.1), либо используйте .then, как в шаблоне выше.

Так делать нельзя
useEffect(async () => {   // ✖ эффект вернёт промис, а не cleanup
  const response = await fetch("/api/students");
  setStudents(await response.json());
}, []);

Живой пример с настоящим API

Это приложение ходит на jsonplaceholder.typicode.com. Кнопка «Сломать адрес» направляет запрос на несуществующую страницу — вы увидите состояние ошибки; «Повторить» запускает эффект заново (число attempt лежит в зависимостях). Автоматической проверки здесь нет — просто исследуйте.

Живой запрос: пользователи · Ctrl + Enter
import { useEffect, useState } from "react";

const GOOD = "https://jsonplaceholder.typicode.com/users?_limit=5";
const BAD = "https://jsonplaceholder.typicode.com/nothing-here"; // вернёт 404

export default function App() {
  const [url, setUrl] = useState(GOOD);
  const [attempt, setAttempt] = useState(0); // «Повторить» увеличивает число и запускает эффект заново
  const [status, setStatus] = useState("loading");
  const [users, setUsers] = useState([]);
  const [error, setError] = useState("");

  useEffect(() => {
    const controller = new AbortController();
    setStatus("loading");

    fetch(url, { signal: controller.signal })
      .then((response) => {
        if (!response.ok) throw new Error("HTTP " + response.status);
        return response.json();
      })
      .then((data) => {
        setUsers(data);
        setStatus("success");
      })
      .catch((err) => {
        if (err.name === "AbortError") return; // это мы сами отменили — не ошибка
        setError(err.message);
        setStatus("error");
      });

    return () => controller.abort();
  }, [url, attempt]);

  return (
    <div>
      <button onClick={() => setUrl(url === GOOD ? BAD : GOOD)}>
        {url === GOOD ? "Сломать адрес" : "Починить адрес"}
      </button>{" "}
      <button onClick={() => setAttempt(attempt + 1)}>Повторить</button>

      {status === "loading" && <p>Загрузка…</p>}
      {status === "error" && <p style={{ color: "#c0392b" }}>Не получилось: {error}</p>}
      {status === "success" && users.length === 0 && <p>Список пуст</p>}
      {status === "success" && (
        <ul>
          {users.map((u) => (
            <li key={u.id}>{u.name} ({u.address.city})</li>
          ))}
        </ul>
      )}
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

Читать подробнее: learn.javascript.ru — Fetch · react.dev — Fetching data in Effects

Практика: закрепляем

ЗАДАЧА 3.1

Первый запрос в эффекте

лёгкая

Допишите эффект: один раз запросите fakeFetch("/api/students"), прочитайте JSON и положите данные в состояние. Строка в консоли показывает, какой экран сейчас нарисован.

Задача 3.1 · Ctrl + Enter
import { useEffect, useState } from "react";

const STUDENTS = [
  { id: 1, name: "Мадина" },
  { id: 2, name: "Фарход" },
  { id: 3, name: "Нилуфар" },
];

// стенд: «сервер» отвечает через 100 мс, как настоящий fetch
function fakeFetch(url) {
  return new Promise((resolve) => {
    setTimeout(() => resolve({ ok: true, status: 200, json: async () => STUDENTS }), 100);
  });
}

export default function App() {
  const [status, setStatus] = useState("loading");
  const [students, setStudents] = useState([]);

  console.log("экран:", status === "loading" ? "загрузка" : `список (${students.length})`);

  // TODO: useEffect, который один раз
  // 1) вызывает fakeFetch("/api/students")
  // 2) читает response.json()
  // 3) кладёт данные в students и ставит status "success"

  if (status === "loading") return <p>Загрузка…</p>;
  return (
    <ul>
      {students.map((s) => (
        <li key={s.id}>{s.name}</li>
      ))}
    </ul>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
экран: загрузка
экран: список (3)
Подсказка 1

Эффект с []. Внутри объявите async function load() и сразу вызовите её.

Подсказка 2

В load: const response = await fakeFetch(...), const data = await response.json(), затем setStudents(data) и setStatus("success").

ЗАДАЧА 3.2

Все четыре состояния

средняя

Стенд по очереди просит три «адреса»: список, пустой список и сломанный сервер (статус 500). Напишите загрузку с проверкой response.ok и четыре ветки отрисовки с нужными console.log. Компонент создаётся заново для каждого адреса (за это отвечает key), поэтому состояние каждый раз начинается с «загрузки».

Задача 3.2 · Ctrl + Enter
import { useEffect, useState } from "react";

function useScript(steps) {
  useEffect(() => {
    const ids = steps.map(([ms, fn]) => setTimeout(fn, ms));
    return () => ids.forEach(clearTimeout);
  }, []);
}

const STUDENTS = [
  { id: 1, name: "Мадина" },
  { id: 2, name: "Фарход" },
  { id: 3, name: "Нилуфар" },
];

// стенд: три «адреса» — список, пустой список и сломанный сервер (500)
function fakeFetch(url) {
  return new Promise((resolve) => {
    setTimeout(() => {
      if (url === "/api/students") resolve({ ok: true, status: 200, json: async () => STUDENTS });
      else if (url === "/api/empty") resolve({ ok: true, status: 200, json: async () => [] });
      else resolve({ ok: false, status: 500, json: async () => ({}) });
    }, 100);
  });
}

function StudentList({ url }) {
  const [status, setStatus] = useState("loading");
  const [items, setItems] = useState([]);
  const [error, setError] = useState("");

  // TODO 1: useEffect, который грузит url и ведёт status / items / error.
  // Не забудьте проверить response.ok и указать url в зависимостях.

  // TODO 2: четыре ветки отрисовки. В каждой — свой console.log:
  //   загрузка → "экран: загрузка"
  //   ошибка   → "экран: ошибка —", error
  //   пусто    → "экран: пусто"
  //   список   → "экран: список (N)"
  return null;
}

export default function App() {
  const [url, setUrl] = useState("/api/students");
  // стенд: адрес меняется дважды. key заставляет компонент начинать с чистого листа
  useScript([
    [400, () => setUrl("/api/empty")],
    [800, () => setUrl("/api/broken")],
  ]);
  return <StudentList key={url} url={url} />;
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
экран: загрузка
экран: список (3)
экран: загрузка
экран: пусто
экран: загрузка
экран: ошибка — HTTP 500
Подсказка 1

Проверка статуса: if (!response.ok) throw new Error("HTTP " + response.status); — исключение попадёт в .catch, где вы сохраните err.message и поставите status = "error".

Подсказка 2

Ветки идут в таком порядке: loading, error, items.length === 0, затем список. В каждой перед return — свой console.log. Зависимость эффекта — [url].

ЗАДАЧА 3.3

Найди баг: 404 — это «успех»?

средняя

Сервер ответил статусом 404, но компонент ведёт себя так, будто всё хорошо, и падает при отрисовке. Запустите стартовый код и посмотрите на ошибку в консоли, затем исправьте только эффект: ответ с ok === false должен приводить к экрану ошибки.

Задача 3.3 · Ctrl + Enter
import { useEffect, useState } from "react";

// стенд: сервер отвечает «404 Не найдено», но fetch это ошибкой НЕ считает
function fakeFetch(url) {
  return new Promise((resolve) => {
    setTimeout(() => resolve({ ok: false, status: 404, json: async () => ({ message: "Not found" }) }), 100);
  });
}

export default function App() {
  const [courses, setCourses] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    fakeFetch("/api/courses")
      .then((response) => response.json())
      .then((data) => setCourses(data))
      .catch((err) => setError(err.message));
  }, []);

  if (error) {
    console.log("экран: ошибка —", error);
    return <p>{error}</p>;
  }
  if (!courses) {
    console.log("экран: загрузка");
    return <p>Загрузка…</p>;
  }
  console.log("экран: курсов", courses.length);
  return (
    <ul>
      {courses.map((c) => (
        <li key={c.id}>{c.title}</li>
      ))}
    </ul>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
экран: загрузка
экран: ошибка — HTTP 404
Подсказка 1

fetch отклоняет промис только при сетевой ошибке. Ответы 404 и 500 для него — «успешно получили ответ».

Подсказка 2

В первом .then перед return response.json() добавьте проверку if (!response.ok) throw new Error("HTTP " + response.status);

04 Гонки запросов и AbortController

Представьте экран профиля с кнопками «Мадина» и «Фарход». Вы нажали «Мадина» — ушёл запрос, а он медленный. Не дожидаясь, нажали «Фарход» — второй запрос быстрый и вернулся первым. Экран показал Фархода. Но потом наконец приходит ответ на первый запрос и перезаписывает экран: в заголовке «Фарход», а данные — Мадины. Это называется гонкой запросов (race condition), и она случается на реальных сайтах постоянно.

Аналогия из жизни

Вы заказали плов в одной чойхоне, но тут же передумали и заказали курутоб в другой. Курутоб привезли быстро, вы начали есть — и вдруг курьер привозит плов, который вы отменили. Правильное поведение — позвонить в первую чойхону и отменить заказ. Для запросов отмена — это AbortController.

AbortController в двух строках

Вы создаёте контроллер, отдаёте его signal в fetch, а когда запрос больше не нужен, вызываете controller.abort(). Промис fetch тогда отклоняется с ошибкой AbortError.

AbortController без React · Ctrl + Enter
const controller = new AbortController();

fetch("https://jsonplaceholder.typicode.com/users/1", { signal: controller.signal })
  .then((r) => r.json())
  .then((user) => console.log("получили", user.name))
  .catch((err) => console.log("ошибка:", err.name));

controller.abort(); // передумали сразу же
console.log("aborted:", controller.signal.aborted);

Как это выглядит в эффекте

Cleanup эффекта — идеальное место для abort(): React вызывает его перед повторным запуском эффекта и при уходе компонента.

Шаблон: запрос с отменой
useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/users/${id}`, { signal: controller.signal })
    .then((r) => {
      if (!r.ok) throw new Error("HTTP " + r.status);
      return r.json();
    })
    .then((user) => setUser(user))
    .catch((err) => {
      if (err.name === "AbortError") return; // мы сами отменили — это не ошибка для пользователя
      setError(err.message);
    });

  return () => controller.abort(); // отменяем при смене id или уходе со страницы
}, [id]);
Запасной вариант: флаг ignore

Если отменять нечем (например, библиотека не принимает signal), используйте флаг: let ignore = false; в эффекте, if (!ignore) setUser(user) в обработчике и return () => { ignore = true; } в cleanup. Запрос не отменится, но его результат будет проигнорирован. Отмена лучше: она ещё и экономит трафик.

Частая ошибка: показываем AbortError пользователю

Если в .catch просто сделать setError(err.message), при каждом переключении пользователь на долю секунды увидит «The user aborted a request». Проверяйте err.name === "AbortError" и молча выходите.

Читать подробнее: learn.javascript.ru — Fetch: прерывание запроса · MDN — AbortController

Практика: закрепляем

ЗАДАЧА 4.1

Найди баг: чужой ответ на экране

сложная

Профиль 1 «сервер» отдаёт 300 мс, профиль 2 — 50 мс. Пользователь переключился с 1 на 2 через 20 мс. Запустите стартовый код: в консоли появится лишняя строка id 2 → Мадина — на экране профиль 2, а данные профиля 1. Почините эффект (отмена запроса или флаг ignore), чтобы остались только три строки. Если выбрали отмену, не забудьте про AbortError.

Задача 4.1 · Ctrl + Enter
import { useEffect, useState } from "react";

function useScript(steps) {
  useEffect(() => {
    const ids = steps.map(([ms, fn]) => setTimeout(fn, ms));
    return () => ids.forEach(clearTimeout);
  }, []);
}

// --- стенд: профиль 1 «сервер» отдаёт долго (300 мс), профиль 2 — быстро (50 мс) ---
const NAMES = { 1: "Мадина", 2: "Фарход" };
const DELAY = { 1: 300, 2: 50 };

function fakeFetch(url, { signal } = {}) {
  const id = Number(url.split("/").pop());
  return new Promise((resolve, reject) => {
    const t = setTimeout(
      () => resolve({ ok: true, status: 200, json: async () => ({ id, name: NAMES[id] }) }),
      DELAY[id]
    );
    signal?.addEventListener("abort", () => {
      clearTimeout(t);
      reject(new DOMException("Aborted", "AbortError"));
    });
  });
}

// --- компонент с багом ---
function Profile({ id }) {
  const [user, setUser] = useState(null);
  console.log("id", id, "→", user ? user.name : "нет данных");

  useEffect(() => {
    fakeFetch(`/api/users/${id}`)
      .then((r) => r.json())
      .then((u) => setUser(u));
  }, [id]);

  return <p>{user ? user.name : "Загрузка…"}</p>;
}

export default function App() {
  const [id, setId] = useState(1);
  useScript([[20, () => setId(2)]]); // стенд: пользователь быстро переключился с 1 на 2
  return <Profile id={id} />;
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
id 1 → нет данных
id 2 → нет данных
id 2 → Фарход
Подсказка 1

Старый ответ нужно либо отменить, либо проигнорировать, когда id уже сменился. И то и другое делается в cleanup эффекта.

Подсказка 2

С отменой: const controller = new AbortController();, передать { signal: controller.signal } вторым аргументом в fakeFetch, return () => controller.abort();, а в .catch игнорировать AbortError.

ЗАДАЧА 4.2

Отмена в чистом JS

лёгкая

Операция ждёт 500 мс, но пользователь передумал через 100 мс. Допишите отмену. Предскажите порядок строк: почему «после отмены» печатается раньше, чем «ошибка»?

Можно редактировать · Ctrl + Enter
// стенд: ждёт ms миллисекунд; понимает signal, как настоящий fetch
function wait(ms, signal) {
  return new Promise((resolve, reject) => {
    const t = setTimeout(() => resolve("готово"), ms);
    signal.addEventListener("abort", () => {
      clearTimeout(t);
      reject(new DOMException("Отменено", "AbortError"));
    });
  });
}

const controller = new AbortController();

wait(500, controller.signal)
  .then((r) => console.log(r))
  .catch((e) => console.log("ошибка:", e.name));

console.log("до отмены:", controller.signal.aborted);

// TODO: через 100 мс вызовите controller.abort()
// и сразу после него выведите "после отмены:", controller.signal.aborted
Ожидаемый результат
до отмены: false
после отмены: true
ошибка: AbortError
Подсказка 1

Нужен setTimeout на 100 мс, внутри которого вызывается controller.abort().

Подсказка 2

abort() выполняется сразу, а .catch — чуть позже, в очереди микрозадач (урок 13). Поэтому следующий за abort() console.log успевает первым.

ЗАДАЧА 4.3

Ушли со страницы — отменили запрос

средняя

Компонент Report запрашивает отчёт, но пользователь уходит со страницы через 50 мс. Сейчас отчёт всё равно «приходит» в никуда. Допишите отмену так, чтобы сработала ветка AbortError.

Задача 4.3 · Ctrl + Enter
import { useEffect, useState } from "react";

function useScript(steps) {
  useEffect(() => {
    const ids = steps.map(([ms, fn]) => setTimeout(fn, ms));
    return () => ids.forEach(clearTimeout);
  }, []);
}

// стенд: отчёт «считается» 200 мс; fakeFetch понимает signal, как настоящий fetch
function fakeFetch(url, { signal } = {}) {
  return new Promise((resolve, reject) => {
    const t = setTimeout(() => resolve({ ok: true, status: 200, json: async () => ({ rows: 42 }) }), 200);
    signal?.addEventListener("abort", () => {
      clearTimeout(t);
      reject(new DOMException("Aborted", "AbortError"));
    });
  });
}

function Report() {
  useEffect(() => {
    console.log("запрос начат");
    // TODO: создайте AbortController, передайте signal в fakeFetch
    // и верните cleanup, который отменяет запрос
    fakeFetch("/api/report")
      .then((r) => r.json())
      .then((data) => console.log("отчёт получен, строк:", data.rows))
      .catch((err) => {
        if (err.name === "AbortError") console.log("запрос отменён");
        else console.log("ошибка:", err.message);
      });
  }, []);
  return <p>Формируем отчёт…</p>;
}

export default function App() {
  const [show, setShow] = useState(true);
  useScript([[50, () => setShow(false)]]); // стенд: через 50 мс пользователь ушёл со страницы
  return show ? <Report /> : <p>Страница закрыта</p>;
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
запрос начат
запрос отменён
Подсказка 1

Контроллер создаётся внутри эффекта; fakeFetch принимает { signal } вторым аргументом.

Подсказка 2

const controller = new AbortController(); → fakeFetch("/api/report", { signal: controller.signal }) → в конце эффекта return () => controller.abort();

06 ИИ-блок: промпт для React-компонента против ручной работы

React-компоненты ИИ пишет очень уверенно — это один из самых частых запросов в мире. Но именно здесь он любит «срезать углы»: пропускает response.ok, забывает cleanup, не рисует состояние «пусто» или оставляет гонку запросов. Код при этом выглядит аккуратно и на «счастливом пути» работает. Поэтому сегодняшний навык — не «получить компонент», а предусмотреть крайние случаи в промпте и проверить результат по списку.

Правила курса, которые здесь особенно важны

1. Сначала 15 минут пробуете сами — у вас уже есть шаблоны из разделов 03–04. 2. Потом идёте к ИИ с промптом по формуле. 3. Не вставляйте в проект компонент, который не можете объяснить построчно: вы должны знать, зачем каждая зависимость в массиве и каждая строка cleanup. 4. Проверяйте не на «счастливом пути», а на сбоях: ответ 404/500, пустой список, быстрое переключение, уход со страницы.

Как просить: плохо и хорошо

Формула промпта курса: контекст → задача → ограничения → формат ответа → проверка. Для React-компонента добавляем три обязательных вещи: props (что приходит снаружи), состояние (что хранит компонент) и крайние случаи (что может пойти не так).

напиши React компонент со списком пользователей
Я учу React, уровень — новичок: знаю useState, props, key, useEffect. Напиши функциональный компонент UserList на чистом React без библиотек. Props: apiUrl (строка). Состояние: status (loading | error | success), users, error. Данные грузить из apiUrl в useEffect через fetch. Обязательно: проверка response.ok, четыре состояния экрана (загрузка, ошибка, пусто, список), отмена запроса через AbortController в cleanup и игнорирование AbortError, корректный массив зависимостей, key в списке. Ограничения: один файл, не больше 60 строк, комментарии на русском к каждому блоку. Формат ответа: код, затем список «какой крайний случай какой строкой обработан». В конце скажи, какие сценарии мне проверить руками.

После ответа ИИ прогоните компонент по чек-листу. Если хотя бы один пункт «нет» — вы нашли баг:

Чек-лист проверки компонента от ИИ

1) Есть четыре состояния: loading, error, empty, success? 2) Проверяется response.ok, а 404 и 500 приводят к экрану ошибки? 3) В массиве зависимостей всё, что читает эффект (url, id, query)? 4) Есть cleanup с abort() и AbortError не показывается пользователю? 5) При быстром переключении старый ответ не затирает новый? 6) В списке есть стабильный key (не индекс, если список меняется)? 7) Нет useEffect(async …) и лишнего состояния, которое можно вычислить? 8) Вы можете объяснить каждую строку?

Вручную или с ИИ: честное сравнение

ВручнуюС ИИ
Скорость20–30 минут на первый компонентминута на черновик, но плюс время на проверку
Пониманиевы знаете, зачем каждая строкалегко принять чужой код «на веру»
Крайние случаивспоминаете только то, что уже знаетеперечислит много, но молча пропустит то, о чём не попросили
Когда выбиратьучебные задачи, контрольные, всё новое для васрутина, которую вы уже умеете писать сами; второй взгляд на свой код

Лучшая схема — гибрид: пишете компонент сами, затем просите ИИ только проверить его по чек-листу, а не переписать. Так вы остаётесь автором, а ИИ работает как внимательный ревьюер. На контрольных ИИ выключен.

Читать подробнее: react.dev — You Might Not Need an Effect · react.dev — Fetching data with Effects

Практика: ловим ошибки ИИ

ЗАДАЧА 6.1

Проверь и почини компонент от ИИ

сложная

ИИ выдал компонент CourseList и уверял, что «всё учтено». Стенд ведёт его по четырём темам: js (медленно), быстро переключается на react, затем none (пустой список) и broken (сервер упал, статус 500). Запустите — вы увидите сразу три проблемы. Исправьте только CourseList: проверка ok, отсутствие гонки и AbortError, отдельная строка для пустого списка.

Задача 6.1 · Ctrl + Enter
import { useEffect, useState } from "react";

function useScript(steps) {
  useEffect(() => {
    const ids = steps.map(([ms, fn]) => setTimeout(fn, ms));
    return () => ids.forEach(clearTimeout);
  }, []);
}

// --- стенд: «сервер». Не меняйте ---
const DB = {
  js: [{ id: 1, title: "JavaScript с нуля" }, { id: 2, title: "DOM и события" }, { id: 3, title: "Асинхронность" }],
  react: [{ id: 4, title: "React: первые шаги" }, { id: 5, title: "Хуки на практике" }],
  none: [],
};
const DELAY = { js: 300, react: 50, none: 50, broken: 50 };

function fakeFetch(url, { signal } = {}) {
  const topic = new URL(url, "http://x").searchParams.get("topic");
  return new Promise((resolve, reject) => {
    const t = setTimeout(() => {
      if (topic === "broken") {
        // сервер упал: статус 500, а тело — не JSON
        resolve({
          ok: false,
          status: 500,
          json: () => Promise.reject(new SyntaxError("Unexpected token 'I'")),
        });
      } else {
        resolve({ ok: true, status: 200, json: async () => DB[topic] });
      }
    }, DELAY[topic]);
    signal?.addEventListener("abort", () => {
      clearTimeout(t);
      reject(new DOMException("Aborted", "AbortError"));
    });
  });
}

// --- компонент от ИИ: чините ТОЛЬКО его ---
function CourseList({ topic }) {
  const [courses, setCourses] = useState([]);

  useEffect(() => {
    fakeFetch(`/api/courses?topic=${topic}`)
      .then((response) => response.json())
      .then((data) => {
        console.log("показано:", data.map((c) => c.title).join(", "));
        setCourses(data);
      })
      .catch((err) => console.log("показано: ошибка —", err.message));
  }, [topic]);

  return (
    <ul>
      {courses.map((c) => (
        <li key={c.id}>{c.title}</li>
      ))}
    </ul>
  );
}

// --- стенд: пользователь меняет тему: js → react → none → broken ---
export default function App() {
  const [topic, setTopic] = useState("js");
  useScript([
    [20, () => setTopic("react")],
    [500, () => setTopic("none")],
    [700, () => setTopic("broken")],
  ]);
  return <CourseList topic={topic} />;
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
показано: React: первые шаги, Хуки на практике
показано: пусто
показано: ошибка — HTTP 500
Подсказка 1

Три бага: нет проверки response.ok (на 500 вылетает чужая ошибка про JSON); нет cleanup (ответ «js» приходит последним и перезаписывает экран); пустой массив даёт строку «показано: » без слов.

Подсказка 2

Добавьте AbortController и return () => controller.abort(); в .catch — if (err.name === "AbortError") return;; перед логом данных — ветку if (data.length === 0).

07 Практика на уроке: переносим приложение на API в React

В уроках 15–16 вы делали на чистом JS приложение: список людей из API, поиск, состояния загрузки и ошибки. Сегодня перенесём такое же приложение в React — шаг за шагом. Все шаги живые: ходят на настоящий API dummyjson.com (в нём английские имена). Каждый шаг — отдельная версия приложения; запускайте, меняйте, ломайте.

Как «переводится» DOM-версия на React

Было в чистом JSСтало в React
status.textContent = "Загрузка…"состояние status и ветка if (status === "loading")
main(), вызванный при загрузке страницыuseEffect(..., [])
очистка списка и цикл с appendusers.map(...) с key в JSX
input.addEventListener("input", ...)onChange и состояние query
ручной clearTimeout для debounceхук useDebounce с cleanup
переменная «последний запрос» против гонокAbortController в cleanup эффекта

Главный сдвиг мышления: в DOM-версии вы говорили браузеру, что менять («поставь этот текст», «добавь этот элемент»). В React вы описываете, как должен выглядеть экран при данном состоянии, а менять DOM берётся React.

Шаг 1. Список и состояния

Загрузка, ошибка, пусто, успех — всё, что мы разобрали в разделе 03. Запустите и подумайте: что произойдёт, если поменять адрес на неправильный?

Шаг 1: список из API · Ctrl + Enter
import { useEffect, useState } from "react";

export default function App() {
  const [status, setStatus] = useState("loading"); // loading | error | success
  const [users, setUsers] = useState([]);
  const [error, setError] = useState("");

  useEffect(() => {
    const controller = new AbortController();

    fetch("https://dummyjson.com/users?limit=6&select=firstName,lastName,company", {
      signal: controller.signal,
    })
      .then((response) => {
        if (!response.ok) throw new Error("HTTP " + response.status);
        return response.json();
      })
      .then((data) => {
        setUsers(data.users);
        setStatus("success");
      })
      .catch((err) => {
        if (err.name === "AbortError") return;
        setError(err.message);
        setStatus("error");
      });

    return () => controller.abort();
  }, []);

  if (status === "loading") return <p className="note">Загрузка…</p>;
  if (status === "error") return <p className="note error">Не получилось: {error}</p>;
  if (users.length === 0) return <p className="note">Список пуст</p>;

  return (
    <ul className="cards">
      {users.map((u) => (
        <li key={u.id} className="card">
          <b>{u.firstName} {u.lastName}</b>
          <span>{u.company.title}</span>
        </li>
      ))}
    </ul>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

Шаг 2. Поиск с debounce

Добавили поле поиска, хук useDebounce и выбор адреса: пустой запрос — обычный список, непустой — /users/search. Эффект теперь зависит от debounced, а не от query. Попробуйте ma, ol и бессмысленное zzz — увидите состояние «пусто».

Шаг 2: поиск · Ctrl + Enter
import { useEffect, useState } from "react";

function useDebounce(value, delay) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

export default function App() {
  const [query, setQuery] = useState("");
  const debounced = useDebounce(query.trim(), 400);

  const [status, setStatus] = useState("loading");
  const [users, setUsers] = useState([]);
  const [error, setError] = useState("");

  useEffect(() => {
    const controller = new AbortController();
    setStatus("loading");

    const base = "https://dummyjson.com/users";
    const select = "limit=6&select=firstName,lastName,company";
    const url = debounced
      ? `${base}/search?q=${encodeURIComponent(debounced)}&${select}`
      : `${base}?${select}`;

    fetch(url, { signal: controller.signal })
      .then((response) => {
        if (!response.ok) throw new Error("HTTP " + response.status);
        return response.json();
      })
      .then((data) => {
        setUsers(data.users);
        setStatus("success");
      })
      .catch((err) => {
        if (err.name === "AbortError") return;
        setError(err.message);
        setStatus("error");
      });

    return () => controller.abort();
  }, [debounced]);

  let content;
  if (status === "loading") content = <p className="note">Загрузка…</p>;
  else if (status === "error") content = <p className="note error">Не получилось: {error}</p>;
  else if (users.length === 0) content = <p className="note">Никого не нашли по запросу «{debounced}»</p>;
  else
    content = (
      <ul className="cards">
        {users.map((u) => (
          <li key={u.id} className="card">
            <b>{u.firstName} {u.lastName}</b>
            <span>{u.company.title}</span>
          </li>
        ))}
      </ul>
    );

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Поиск по имени (латиницей): ma, ol, ja…"
      />
      {content}
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

Шаг 3. Карточка выбранного человека

Нажмите на человека в списке: компонент UserDetail делает свой запрос по id. Он зависит от [id], а cleanup отменяет предыдущий запрос — значит, быстро щёлкая по списку, вы не получите чужую карточку. Общую часть (проверка ok) мы вынесли в функцию getJson: так проверка в одном месте.

Шаг 3: список + карточка · Ctrl + Enter
import { useEffect, useState } from "react";

function useDebounce(value, delay) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

// маленькая обёртка: один раз проверяем ok и возвращаем JSON
async function getJson(url, signal) {
  const response = await fetch(url, { signal });
  if (!response.ok) throw new Error("HTTP " + response.status);
  return response.json();
}

function UserDetail({ id }) {
  const [status, setStatus] = useState("loading");
  const [user, setUser] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    setStatus("loading");
    getJson(`https://dummyjson.com/users/${id}?select=firstName,lastName,email,phone,address`, controller.signal)
      .then((data) => {
        setUser(data);
        setStatus("success");
      })
      .catch((err) => {
        if (err.name !== "AbortError") setStatus("error");
      });
    return () => controller.abort();
  }, [id]);

  if (status === "loading") return <div className="detail">Загружаем профиль…</div>;
  if (status === "error") return <div className="detail error">Профиль не загрузился</div>;
  return (
    <div className="detail">
      <p><b>{user.firstName} {user.lastName}</b></p>
      <p>{user.email}</p>
      <p>{user.phone}</p>
      <p>{user.address.city}</p>
    </div>
  );
}

export default function App() {
  const [query, setQuery] = useState("");
  const debounced = useDebounce(query.trim(), 400);
  const [selectedId, setSelectedId] = useState(null);

  const [status, setStatus] = useState("loading");
  const [users, setUsers] = useState([]);
  const [error, setError] = useState("");

  useEffect(() => {
    const controller = new AbortController();
    setStatus("loading");
    const base = "https://dummyjson.com/users";
    const select = "limit=6&select=firstName,lastName,company";
    const url = debounced
      ? `${base}/search?q=${encodeURIComponent(debounced)}&${select}`
      : `${base}?${select}`;

    getJson(url, controller.signal)
      .then((data) => {
        setUsers(data.users);
        setStatus("success");
      })
      .catch((err) => {
        if (err.name === "AbortError") return;
        setError(err.message);
        setStatus("error");
      });
    return () => controller.abort();
  }, [debounced]);

  let content;
  if (status === "loading") content = <p className="note">Загрузка…</p>;
  else if (status === "error") content = <p className="note error">Не получилось: {error}</p>;
  else if (users.length === 0) content = <p className="note">Никого не нашли по запросу «{debounced}»</p>;
  else
    content = (
      <ul className="cards">
        {users.map((u) => (
          <li key={u.id}>
            <button
              className={"card" + (u.id === selectedId ? " on" : "")}
              onClick={() => setSelectedId(u.id)}
            >
              <b>{u.firstName} {u.lastName}</b>
              <span>{u.company.title}</span>
            </button>
          </li>
        ))}
      </ul>
    );

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Поиск по имени (латиницей): ma, ol, ja…"
      />
      {content}
      {selectedId && <UserDetail id={selectedId} />}
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Это почти готовая основа домашней работы

Скопируйте шаг 3 в свой проект и меняйте под свой API и свою тему: вместо людей — товары, курсы валют, погода, рецепты. Но сначала соберите версию с нуля по подсказкам ниже: так вы докажете себе, что понимаете каждую строку.

Сборка приложения с автопроверкой

Последняя задача урока проверяется автоматически: вы пишете эффект и разметку приложения, а «пользователь» (стенд) сам набирает в поле «д», потом «ду» и сверяет результат. Заодно проверим, что debounce действительно экономит запросы: после поиска к серверу должно быть ровно два обращения — первичная загрузка и один поиск.

Практика: собираем приложение

ЗАДАЧА 7.1

Справочник участников: поиск + состояния

сложная

Допишите UsersApp: (1) эффект по [debounced], который сначала ставит status = "loading", затем запрашивает "/api/users?q=" + encodeURIComponent(debounced) с AbortController, проверкой ok, игнорированием AbortError и cleanup; (2) разметку: <input> и ветки загрузка / ошибка / «Ничего не найдено» / список из <li>. Хук useDebounce уже готов.

Задача 7.1 · Ctrl + Enter
import { useEffect, useState } from "react";

// ---------- стенд: «сервер». Не меняйте ----------
const USERS = [
  { id: 1, name: "Мадина", city: "Душанбе" },
  { id: 2, name: "Фарход", city: "Худжанд" },
  { id: 3, name: "Нилуфар", city: "Душанбе" },
  { id: 4, name: "Рустам", city: "Бохтар" },
];
let calls = 0; // сколько раз обратились к «серверу»

function fakeFetch(url, { signal } = {}) {
  calls++;
  const q = (new URL(url, "http://x").searchParams.get("q") || "").toLowerCase();
  const list = USERS.filter((u) => (u.name + " " + u.city).toLowerCase().includes(q));
  return new Promise((resolve, reject) => {
    const t = setTimeout(() => resolve({ ok: true, status: 200, json: async () => list }), 100);
    signal?.addEventListener("abort", () => {
      clearTimeout(t);
      reject(new DOMException("Aborted", "AbortError"));
    });
  });
}

// ---------- хук из задачи 5.3 (уже готов) ----------
function useDebounce(value, delay) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

// ---------- ВАШЕ приложение ----------
function UsersApp() {
  const [query, setQuery] = useState("");
  const debounced = useDebounce(query, 150);
  const [status, setStatus] = useState("loading");
  const [users, setUsers] = useState([]);
  const [error, setError] = useState("");

  // TODO 1: useEffect по [debounced]: запрос "/api/users?q=" + encodeURIComponent(debounced)
  // с AbortController, проверкой response.ok, обработкой AbortError и cleanup.

  // TODO 2: верните разметку: поле <input> (value/onChange связаны с query)
  // и под ним одну из четырёх веток: загрузка / ошибка / «Ничего не найдено» / список <li>.
  return null;
}

// ---------- стенд: «пользователь» печатает в поле. Не меняйте ----------
function typeInto(input, text) {
  const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set;
  setter.call(input, text);
  input.dispatchEvent(new Event("input", { bubbles: true }));
}

function Harness() {
  useEffect(() => {
    const input = document.querySelector("input");
    const count = () => document.querySelectorAll("li").length;
    const ids = [
      setTimeout(() => console.log("карточек после загрузки:", count()), 300),
      setTimeout(() => typeInto(input, "д"), 400),
      setTimeout(() => typeInto(input, "ду"), 440),
      setTimeout(() => {
        console.log("карточек после поиска «ду»:", count());
        console.log("запросов к серверу:", calls);
      }, 900),
    ];
    return () => ids.forEach(clearTimeout);
  }, []);
  return null;
}

export default function App() {
  return (
    <>
      <UsersApp />
      <Harness />
    </>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
карточек после загрузки: 4
карточек после поиска «ду»: 2
запросов к серверу: 2
Подсказка 1

Структура та же, что в шагах 2 и 3 выше и в задаче 3.2. Для карточек нужны именно элементы <li> — стенд считает их.

Подсказка 2

Если запросов получилось 3, а не 2 — эффект зависит от query вместо debounced. Если карточек 0 — проверьте, что в успешной ветке рисуется users.map(...) с <li key=...>.

08 Обзор: что дальше даёт Next.js и серверные компоненты

Всё, что мы делали сегодня, — клиентский React: браузер скачивает JavaScript, запускает компонент, и только потом начинается fetch. Пользователь сначала видит «Загрузка…». Для кабинетов и приложений за логином это нормально. Для сайтов, где важны скорость первого показа и поисковики (каталоги, блоги, магазины), хочется получать данные до того, как страница уйдёт в браузер.

Next.js — популярный фреймворк поверх React, который берёт эту работу на себя. Коротко, что он добавляет:

ВозможностьЧто это значит на практике
Маршрутизация по папкампапка app/courses/page.jsx — это страница /courses; не нужно настраивать роутер вручную
Серверные компонентыкомпонент выполняется на сервере, может сам делать await fetch или читать базу, а в браузер уходит готовый HTML
Клиентские компонентывсё, где нужны useState, useEffect, клики и ввод, помечается строкой "use client"
Экраны загрузки и ошибокфайлы loading.jsx и error.jsx заменяют ручные ветки состояний на уровне страницы
Серверные обработчикисвои API-эндпоинты (route handlers) в том же проекте
Оптимизации и деплойсжатие картинок, разбиение кода, удобная публикация (например, на Vercel)

Загрузка данных: сегодняшний способ и серверный

Серверный компонент в Next.js (идея, не запускается здесь)
// app/students/page.jsx — серверный компонент (по умолчанию)
export default async function StudentsPage() {
  const response = await fetch("https://jsonplaceholder.typicode.com/users");
  const users = await response.json();

  return (
    <ul>
      {users.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}

Посмотрите, чего здесь нет: нет useState, нет useEffect, нет ветки «Загрузка…» внутри компонента — компонент просто async и ждёт данные. Сервер готовит HTML и отдаёт его уже заполненным. Поиск по такому списку, кнопки и всё интерактивное всё равно пишутся клиентскими компонентами — теми же useState и useEffect, которые вы выучили сегодня.

Что важно понимать без хайпа

1. Серверные компоненты не отменяют useEffect — они забирают на себя только начальную загрузку данных. Подписки, таймеры, ввод пользователя остаются на клиенте. 2. Next.js — не обязателен: приложению за логином часто хватает Vite + React. 3. В реальных проектах загрузку в useEffect часто заменяют библиотеками вроде TanStack Query или SWR: они кэшируют ответы, повторяют запросы и убирают гонки. Но чтобы понимать, что делают эти библиотеки, нужно знать то, что вы изучили сегодня. 4. Это обзор: в Уровне 3 всё будет подробно и с практикой. (Кстати, платформа, на которой вы проходите курс, тоже написана на Next.js.)

Vite + React (клиентский)

Просто и быстро стартовать. Подходит для кабинетов, панелей, внутренних инструментов. Данные грузятся в браузере через useEffect или библиотеку.

Next.js (клиент + сервер)

Нужны быстрый первый показ, SEO, готовая маршрутизация и серверный код в одном проекте. Данные можно получать на сервере, а интерактив оставить клиентским.

Читать подробнее: Next.js — документация · react.dev — Server Components

09 Дорожная карта: путь к Уровню 3

Уровень 3 — это React Pro + Next.js. Между концом этого курса и его началом у вас есть время, и лучше провести его с пользой: не «изучать всё подряд», а двигаться по плану. Ниже — план на 8 недель самостоятельной подготовки: темы по неделям и один проект на каждые две недели.

Аналогия из жизни

Дорога из Душанбе в Хорог не покоряется за один рывок: есть Куляб, Дарваз, остановки, ночёвки. План — это карта остановок. Даже если вы идёте медленнее, чем задумано, вы знаете, где находитесь и куда двигаться дальше.

План на 8 недель

НеделяТемаЧто практиковать
1Повторение: компоненты, props, useState, формыуправляемые поля (controlled inputs), форма с валидацией, список с добавлением и удалением
2useEffect, собственные хуки, useReducerхуки useDebounce, useLocalStorage, useFetch; Проект 1
3Маршруты (React Router), общее состояние через Contextнесколько страниц, страница товара по id, тема оформления через Context
4Работа с данными: TanStack Query или SWR, пагинациякэш, повтор запроса, «загрузить ещё»; Проект 2
5TypeScript в Reactтипы для props и ответов API, interface, useState<User[]>
6Введение в Next.js: страницы, лейауты, ссылкисоздать проект на Next.js, перенести 3 страницы; Проект 3 — перенос Проекта 2 на Next.js
7Серверные и клиентские компоненты, loading.jsx, error.jsxполучать данные на сервере, интерактив вынести в "use client"
8Деплой, переменные окружения, доступность, оформлениеопубликовать сайт, README, скриншоты; Проект 4 — итоговый мини-продукт в портфолио
Сверьте план с программой Уровня 3

Это план подготовки, а не программа курса. Порядок тем можно менять, а то, что окажется в Уровне 3, лучше уточнить у преподавателя. Если сомневаетесь, что вам нужнее — повторите useEffect и работу с API: именно на них держится почти всё остальное.

Как заниматься, чтобы план работал

  • 30–60 минут каждый день лучше, чем 5 часов раз в неделю. Мозгу нужны повторения.
  • Один коммит в день в GitHub: репозиторий — ваше портфолио, а ряд зелёных квадратов дисциплинирует.
  • Читайте чужой код: откройте любой небольшой React-проект на GitHub и попробуйте объяснить, что делает каждый компонент.
  • Документация — друг. Раздел «Learn» на react.dev написан для новичков и с живыми примерами.
  • Правила ИИ сохраняются: сначала сами, потом промпт, ничего без построчного понимания.

Идеи проектов

ИдеяЧему учит
Трекер расходов в сомони с хранением в localStorageформы, useReducer, собственный хук
Погода в городах Таджикистана (API Open-Meteo, без ключа)useEffect, состояния загрузки, выбор города
Каталог рецептов курутоба, оши палав, самбусы с поиском и фильтрамифильтрация, URL-параметры, карточки
Мини-магазин с корзиной и итоговой суммойContext, управление состоянием, пересчёты
Расписание занятий курса с отметкой посещённых уроковсписки, localStorage, дата и время
Словарь таджикско-русский с поиском с debounceпоиск, производные данные, оптимизация
Доска задач (Kanban)сложное состояние, перетаскивание, компонентная архитектура
Мини-блог на Next.js со страницами постовмаршруты, серверные компоненты, деплой

Попросите ИИ составить план — и проверьте его

Промпт №26 из шпаргалки курса. Он даёт каркас, но решать, что реально осилите вы, придётся самим:

Я закончил курс «Frontend + AI» (HTML/CSS, JavaScript, DOM, fetch, Git, основы React: props, state, useEffect). Составь план на 8 недель для перехода от новичка в React к уверенному React + Next.js: темы по неделям и по одному проекту на каждые 2 недели. Я занимаюсь 1 час в будни и 3 часа в выходные. Формат ответа: таблица «неделя — тема — что практиковать — результат». В конце напиши, что в этом плане самое рискованное и что можно убрать, если я отстану.

Проверка по формуле курса: сверьте план с программой Уровня 3, уберите лишнее, оцените время сами. План, который вы не сможете выполнить, хуже скромного, но живого.

Читать подробнее: react.dev — Learn · Next.js — Learn · MDN — Learn web development

Практика: ваш план

ЗАДАЧА 9.1

Мой план на 8 недель

лёгкая

Возьмите таблицу плана выше и перенесите в тетрадь или в файл PLAN.md в вашем репозитории. Для каждой недели вместо общих слов запишите конкретное: какие дни и во сколько вы занимаетесь, какую одну вещь сделаете руками, как поймёте, что неделя удалась. Выберите идею проекта для Проекта 1 и напишите, что она будет уметь. Автопроверки нет — отметьте задачу вручную, когда план готов.

Подсказка 1

Если не знаете, с чего начать, возьмите промпт №26 выше и сравните его ответ со своими мыслями — но итоговый план пишите своими словами.

Подсказка 2

Хороший пункт плана проверяем: «в среду 19:00–20:00 — сделать useLocalStorage и использовать его в трекере». Плохой: «изучить хуки».

10 Домашнее задание

Доделать React-приложение на API, залить его на GitHub и составить план подготовки к Уровню 3. Артефакт: репозиторий с приложением + файл PLAN.md.

Что нужно сделать

  1. Выберите свой API без ключа и с CORS (проверьте в браузере, что ответ приходит): jsonplaceholder.typicode.com, dummyjson.com, api.open-meteo.com, restcountries.com, rickandmortyapi.com. Тема своя: товары, страны, погода, персонажи.
  2. Создайте проект React (Vite, как в уроке 17, шаблон React) или соберите в онлайн-среде и перенесите файлы в репозиторий.
  3. Список из API через useEffect + fetch: минимум 6 карточек с 2+ полями, key в списке.
  4. Четыре состояния: загрузка, ошибка (проверьте, подставив неверный адрес), «ничего не найдено», успех. Кнопка «Повторить» при ошибке.
  5. Поиск с debounce (свой хук useDebounce) — через запрос к серверу или фильтрацию списка.
  6. Отмена запросов: AbortController в cleanup, AbortError не показывается пользователю.
  7. Проверка ИИ. Если использовали ИИ — приложите промпт (формула: контекст → задача → ограничения → формат → проверка) и пройдённый чек-лист из раздела 06.
  8. План PLAN.md: 8 недель, у каждой — конкретные действия, выбранная идея Проекта 1 и ссылки на 2–3 ресурса, которые будете читать.
  9. Залейте на GitHub (урок 19): осмысленные коммиты, минимум 5, файл README со скриншотами и описанием.

Что сдавать

  • Ссылку на репозиторий GitHub с приложением, README и PLAN.md.
  • Два скриншота: страница с данными и страница с состоянием ошибки.
  • Короткое описание в README: какой API выбрали, какие крайние случаи обработали.

Как оцениваем (0–10)

ЧтоБаллы
Загрузка данных в useEffect, правильный массив зависимостей, key в списке2
Четыре состояния экрана работают (включая проверку response.ok)2
Поиск с debounce, лишних запросов нет2
Cleanup: AbortController, обработка AbortError1
Репозиторий на GitHub: коммиты, README со скриншотами1
PLAN.md: конкретный план на 8 недель и идея проекта1
Чистый код: понятные имена, нет лишнего состояния; ИИ-код вы можете объяснить построчно1
Перед сдачей

Выключите интернет (или поставьте в DevTools на вкладке Network режим Offline) и перезагрузите приложение. Вместо пустого экрана должно появиться понятное сообщение с кнопкой «Повторить». Затем напечатайте в поиске быстро несколько букв подряд и посмотрите на вкладку Network: запрос должен уходить один, а не по одному на букву.

✓ Шпаргалка урока

  • Рендер должен быть чистым. Запросы, таймеры, document.title, подписки — побочные эффекты, им место в useEffect.
  • Порядок: рендер → обновление DOM → эффект.
  • Зависимости: без массива — после каждого рендера; [] — один раз; [a, b] — при изменении a или b. В массиве всё, что эффект читает.
  • Cleanup: return () => ... в эффекте. Вызывается перед повторным запуском и при уходе компонента. StrictMode в разработке нарочно запускает «эффект → cleanup → эффект».
  • Четыре состояния: loading, error, empty, success. «Пусто» — это успех с пустым массивом.
  • Шаблон: fetch → if (!response.ok) throw new Error(...) → json() → setState; в .catch — ошибка, но AbortError игнорируем.
  • Нельзя useEffect(async () => ...): async-функцию объявляем внутри.
  • Гонка запросов: cleanup с controller.abort() (или флаг ignore), чтобы старый ответ не затирал новый.
  • Производные данные (отфильтрованный список) считаем при рендере, а не храним в состоянии.
  • Debounce: useDebounce(value, delay) — setTimeout в эффекте и clearTimeout в cleanup; запрос зависит от debounced.
  • Next.js: маршруты по папкам, серверные компоненты (данные на сервере, без useEffect), "use client" для интерактива.
  • С ИИ: в промпте для компонента — props, состояние, крайние случаи; проверка по чек-листу из 8 пунктов.
  • Дальше: 8 недель плана, проект раз в две недели, коммит в день.

🎓 Поздравляем: вы прошли курс

Восемь недель назад вы знали HTML и CSS и ещё не написали ни одной строки JavaScript. Сегодня вы сделали приложение на React, которое загружает данные с настоящего API, умеет искать, не ломается при сбое сети и отменяет ненужные запросы. Это серьёзный путь, и вы прошли его сами — каждой задачей, каждой ошибкой в консоли и каждым «почему оно не работает?», которое в итоге заработало.

Что вы теперь умеете

Язык и браузер

Переменные, условия, циклы, функции, массивы и объекты. DOM, события, формы, состояние приложения, localStorage.

Асинхронность и данные

Промисы, async/await, fetch, статусы HTTP, JSON, обработка ошибок, обёртка над запросами.

Инструменты команды

Модули и Vite, Git и GitHub, отладка, ревью кода, README, два проекта с защитой.

Работа с ИИ

Промпт по формуле, проверка ответа, правило «ничего без понимания», личная шпаргалка из рабочих промптов.

React

Компоненты, props, useState, списки с key, useEffect, загрузка данных, cleanup, поиск с debounce.

Аналогия из жизни

Хороший плов не получается с первого раза — он получается с десятого, когда рука сама знает, сколько масла и когда добавить рис. Программирование такое же: теперь у вас есть рецепты, а «рука» вырабатывается практикой. Не бросайте её.

Три совета на дорогу

  1. Пишите код каждый день, хотя бы по чуть-чуть. Регулярность важнее длины занятий.
  2. Делайте проекты про то, что вам интересно. Тогда сложные места не отпугивают, а затягивают.
  3. Не бойтесь ошибок и вопросов. Красное сообщение в консоли — не оценка вам, а подсказка. А когда застрянете — опишите баг так, как мы учились: что ожидали, что получили, какой код.
Что сделать прямо сегодня

Откройте PLAN.md, запишите время первого занятия на завтра — и сделайте первый коммит. Спасибо вам за усердие, вопросы и терпение. Желаем уверенных шагов к Уровню 3 — до встречи там!