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 defaultfunctionApp() {
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>;
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Рендер. React вызывает функцию компонента и получает JSX. Любые console.log в теле компонента выполняются на этом шаге.
Коммит. React обновляет настоящий DOM — пользователь видит новый экран.
Эффект. 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 defaultfunctionApp() {
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». Не запускайте этот код у себя в рабочем проекте.
В каждой задаче «виртуальный пользователь» (useScript) сам меняет состояние по таймеру — так результат в консоли всегда одинаковый и проверяется автоматически. Блок useScript и всё помеченное «стенд» не меняйте.
ЗАДАЧА 1.1
Страница открыта
лёгкая
Допишите эффект, который выводит Страница открытаодин раз — после первого рендера, — а не после каждого. Сначала предскажите, сколько раз и в каком порядке появятся строки, затем запустите.
Задача 1.1 · Ctrl + Enter
import { useEffect, useState } from "react";
export defaultfunctionApp() {
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";
// стенд: «виртуальный пользователь» выполняет шаги по таймеруfunctionuseScript(steps) {
useEffect(() => {
const ids = steps.map(([ms, fn]) =>setTimeout(fn, ms));
return () => ids.forEach(clearTimeout);
}, []);
}
export defaultfunctionApp() {
const [count, setCount] = useState(0);
const [theme, setTheme] = useState("light");
// стенд: счёт +1, потом смена темы, потом счёт ещё +1useScript([
[40, () =>setCount(1)],
[80, () =>setTheme("dark")],
[120, () =>setCount(2)],
]);
// TODO: эффект, который выводит "сохраняем счёт: N"// и срабатывает ТОЛЬКО когда изменился count (смена темы его не касается)return <p className={theme}>Счёт: {count}</p>;
}
Компонент получает имя, которое меняется. Напишите эффект: при каждой смене имени он ставит document.title и выводит его в консоль. Изменение document.title — классический побочный эффект: он выходит за пределы JSX.
Эффект может вернуть функцию — её называют cleanup («уборка»). React вызовет её в двух случаях: перед тем как запустить эффект заново (при смене зависимостей) и когда компонент исчезает с экрана.
Вы приехали на маршрутке и включили в ней радио на всю громкость. Если выходите, не выключив — радио играет для пустого салона и мешает следующим пассажирам. Cleanup — это «выключить радио, выходя». Таймер без cleanup точно так же продолжает тикать у компонента, которого уже нет.
Порядок при смене зависимостей
Эффект запустился с room = "Общий": подключились.
room поменялся на "Группа JS": React сначала вызывает cleanup старого эффекта (отключились от «Общий»)…
…и только затем запускает эффект с новым значением (подключились к «Группа JS»).
Компонент убрали с экрана: последний cleanup — отключились.
StrictMode: зачем React запускает эффект дважды
Когда вы создаёте проект на Vite или Next.js, компоненты обычно обёрнуты в <StrictMode>. В режиме разработки он нарочно делает «репетицию»: запускает эффект, сразу вызывает cleanup и запускает эффект ещё раз. Так React проверяет, что ваш эффект умеет «выключаться» и не оставляет мусор. В готовой сборке (production) этого дублирования нет.
Это не баг и не «React сломался». Если после двойного запуска приложение работает как надо — cleanup написан верно. Если запрос уходит дважды или таймеры ускоряются — вы забыли cleanup. Учебные окна на этой странице работают в режиме production, поэтому дубля в них не видно; проверьте сами, когда создадите проект на Vite.
Частая ошибка: забыли очистить таймер
Без cleanup интервал живёт после ухода компонента: утечка памяти и обновления состояния несуществующего компонента.
Так делать нельзя: нет cleanup
useEffect(() => {
setInterval(() => console.log("тик"), 1000); // ✖ никто не остановит этот таймер
}, []);
Компонент Ticker каждые 100 мс пишет «тик N». Стенд убирает его с экрана через 350 мс. Допишите cleanup: он останавливает таймер и выводит таймер остановлен. Если забыть cleanup, тики не прекратятся — запустите стартовый код и убедитесь.
Напишите эффект компонента Chat: при показе комнаты вызывайте connect(room), а при смене комнаты или закрытии чата — disconnect(room) для той комнаты, к которой подключались. Обратите внимание на порядок строк.
Задача 2.2 · Ctrl + Enter
import { useEffect, useState } from "react";
functionuseScript(steps) {
useEffect(() => {
const ids = steps.map(([ms, fn]) =>setTimeout(fn, ms));
return () => ids.forEach(clearTimeout);
}, []);
}
// «подключение к комнате чата»functionconnect(room) { console.log("подключились:", room); }
functiondisconnect(room) { console.log("отключились:", room); }
functionChat({ room }) {
// TODO: подключайтесь к комнате, а при смене комнаты и при закрытии чата — отключайтесьreturn <p>Комната: {room}</p>;
}
export defaultfunctionApp() {
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, с которым был запущен его эффект.
Самая частая работа для useEffect — получить данные с сервера при открытии экрана. Запрос — это побочный эффект, поэтому ему не место в теле компонента. А раз ответ приходит не сразу, экран живёт в четырёх состояниях, и каждое нужно показать пользователю (вы уже встречали их в уроке 16):
Состояние
Что видит пользователь
Как узнаём
loading
«Загрузка…», скелетон или спиннер
запрос ушёл, ответа ещё нет
error
понятное сообщение и кнопка «Повторить»
сеть упала или response.ok === false
empty
«Ничего не найдено»
ответ пришёл, но массив пустой
success
список или карточки
ответ пришёл, данные есть
Аналогия из жизни
Вы заказали такси в приложении. «Ищем машину» — loading. «Машин нет, попробуйте позже» — error. «В вашем районе нет доступных тарифов» — empty. «Водитель Фарход приедет через 4 минуты» — success. Если приложение показывает пустой белый экран вместо любого из этих сообщений, пользователь решит, что оно сломано.
Скелет компонента
Состояний у экрана несколько — держим их в useState. «Пусто» отдельно хранить не нужно: это успех с пустым массивом.
Обратите внимание на порядок if: сначала ранние возвраты для загрузки, ошибки и пустого списка, а в конце — «счастливый путь» с данными. Так компонент читается сверху вниз, как чек-лист.
Частая ошибка: async прямо в useEffect
Писать useEffect(async () => { ... }) нельзя: async-функция всегда возвращает промис, а React ждёт от эффекта либо ничего, либо функцию cleanup. Если нужен await — объявите async-функцию внутри эффекта и вызовите её (как в задаче 3.1), либо используйте .then, как в шаблоне выше.
Так делать нельзя
useEffect(async () => { // ✖ эффект вернёт промис, а не cleanupconst response = awaitfetch("/api/students");
setStudents(await response.json());
}, []);
Живой пример с настоящим API
Это приложение ходит на jsonplaceholder.typicode.com. Кнопка «Сломать адрес» направляет запрос на несуществующую страницу — вы увидите состояние ошибки; «Повторить» запускает эффект заново (число attempt лежит в зависимостях). Автоматической проверки здесь нет — просто исследуйте.
Допишите эффект: один раз запросите 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 мс, как настоящий fetchfunctionfakeFetch(url) {
returnnewPromise((resolve) => {
setTimeout(() =>resolve({ ok: true, status: 200, json: async () => STUDENTS }), 100);
});
}
export defaultfunctionApp() {
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";
functionuseScript(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)functionfakeFetch(url) {
returnnewPromise((resolve) => {
setTimeout(() => {
if (url ==="/api/students") resolve({ ok: true, status: 200, json: async () => STUDENTS });
elseif (url ==="/api/empty") resolve({ ok: true, status: 200, json: async () => [] });
elseresolve({ ok: false, status: 500, json: async () => ({}) });
}, 100);
});
}
functionStudentList({ 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)"returnnull;
}
export defaultfunctionApp() {
const [url, setUrl] = useState("/api/students");
// стенд: адрес меняется дважды. key заставляет компонент начинать с чистого листаuseScript([
[400, () =>setUrl("/api/empty")],
[800, () =>setUrl("/api/broken")],
]);
return <StudentList key={url} url={url} />;
}
Проверка статуса: 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 должен приводить к экрану ошибки.
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.
Cleanup эффекта — идеальное место для abort(): React вызывает его перед повторным запуском эффекта и при уходе компонента.
Шаблон: запрос с отменой
useEffect(() => {
const controller = newAbortController();
fetch(`/api/users/${id}`, { signal: controller.signal })
.then((r) => {
if (!r.ok) thrownewError("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" и молча выходите.
Профиль 1 «сервер» отдаёт 300 мс, профиль 2 — 50 мс. Пользователь переключился с 1 на 2 через 20 мс. Запустите стартовый код: в консоли появится лишняя строка id 2 → Мадина — на экране профиль 2, а данные профиля 1. Почините эффект (отмена запроса или флаг ignore), чтобы остались только три строки. Если выбрали отмену, не забудьте про AbortError.
Старый ответ нужно либо отменить, либо проигнорировать, когда 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, как настоящий fetchfunctionwait(ms, signal) {
returnnewPromise((resolve, reject) => {
const t = setTimeout(() =>resolve("готово"), ms);
signal.addEventListener("abort", () => {
clearTimeout(t);
reject(newDOMException("Отменено", "AbortError"));
});
});
}
const controller = newAbortController();
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.
Контроллер создаётся внутри эффекта; fakeFetch принимает { signal } вторым аргументом.
Подсказка 2
const controller = new AbortController(); → fakeFetch("/api/report", { signal: controller.signal }) → в конце эффекта return () => controller.abort();
05 Поиск, debounce и фильтрация
Фильтрация: не нужно ни состояния, ни эффекта
Допустим, у вас есть список и строка поиска. Новички часто заводят второй useState для «отфильтрованного списка» и обновляют его в эффекте. Это лишнее: отфильтрованный список целиком вычисляется из двух значений — полного списка и строки поиска. Такие данные называются производными, и считать их нужно прямо при рендере.
Производные данные: считаем при рендере
const [query, setQuery] = useState("");
// ✔ вычисляем на лету, никаких useEffect и второго useStateconst visible = students.filter((s) =>
s.name.toLowerCase().includes(query.trim().toLowerCase())
);
Аналогия из жизни
Меню на стене чойхоны одно. Когда гость говорит «мне что-нибудь без мяса», официант не переписывает меню и не вешает второе — он просто в уме пропускает мясные блюда. Отфильтрованный список — это «взгляд на меню», а не новое меню.
Поиск через сервер и зачем нужен debounce
Если искать по списку из тысяч записей, фильтровать на клиенте уже нельзя — нужен запрос на сервер: /users/search?q=плов. Но если отправлять запрос на каждую нажатую клавишу, пока пользователь набирает «плов», уйдут четыре запроса: п, пл, пло, плов. Три из них никому не нужны, а ещё они могут вернуться в перепутанном порядке.
Debounce («дребезг») — приём: подождать, пока пользователь перестанет печатать на 300–500 мс, и только потом искать. Каждое новое нажатие сбрасывает таймер.
п · пл · пло · плов⏸поиск: плов
Четыре нажатия — один запрос
Сначала само ядро идеи в чистом JS — функция debounce, которая используется везде, не только в React. А в React то же самое оформляется как собственный хук:
Это красивый пример cleanup: каждое изменение value убирает таймер предыдущего изменения, и до конца доживает только последний. Использование: эффект запроса зависит не от query (меняется на каждой букве), а от debounced.
Живой пример: поиск с настоящим API
Печатайте английские имена (ma, ol, ja) и следите за счётчиком запросов: пока вы печатаете подряд, он не растёт. Предыдущий запрос отменяется через AbortController.
Частая ошибка: фильтр в эффекте с лишним состоянием
Копия данных в useState + useEffect для синхронизации — это лишний рендер при каждом изменении и риск показать устаревший список. Если значение можно вычислить из props и state — вычисляйте, а не храните.
Напишите чистую функцию filterStudents(list, query): ищет по имени или по городу, без учёта регистра, пробелы по краям запроса игнорирует, пустой запрос возвращает всех.
Реализуйте debounce(fn, ms). Она возвращает функцию, которая вызывает fn только если после последнего вызова прошло ms миллисекунд. Стенд «печатает» слово «плов», делает паузу и печатает «ш».
Можно редактировать · Ctrl + Enter
functiondebounce(fn, ms) {
// TODO: верните новую функцию, которая откладывает вызов fn на ms миллисекунд// и сбрасывает предыдущее ожидание при каждом новом вызове
}
const search = debounce((q) => console.log("поиск:", q), 100);
// стенд: пользователь печатает «плов» (буква каждые 30 мс), делает паузу, потом «ш»
["п", "пл", "пло", "плов"].forEach((q, i) => {
setTimeout(() =>search(q), i * 30);
});
setTimeout(() =>search("ш"), 500);
Ожидаемый результат
поиск: плов
поиск: ш
Подсказка 1
Нужна переменная timer в замыкании (вне возвращаемой функции). При каждом вызове сначала clearTimeout(timer).
Состояние debounced начинается со значения value. Эффект ставит setTimeout, который после delay копирует value в debounced.
Подсказка 2
Не забудьте cleanup return () => clearTimeout(id) и зависимости [value, delay], а в конце — return debounced;
ЗАДАЧА 5.4
Рефакторинг: уберите лишнее состояние
средняя
Компонент хранит visible в состоянии и обновляет его в эффекте. Запустите — в консоли лишние рендеры, а в одной строке запрос «ду», но найдено 4 (устаревшие данные). Перепишите так, чтобы visible вычислялось при рендере, а лишние useState и useEffect исчезли.
const visible = STUDENTS.filter((s) => s.city.toLowerCase().includes(query.toLowerCase())); — прямо в теле компонента, до console.log.
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 минут на первый компонент
минута на черновик, но плюс время на проверку
Понимание
вы знаете, зачем каждая строка
легко принять чужой код «на веру»
Крайние случаи
вспоминаете только то, что уже знаете
перечислит много, но молча пропустит то, о чём не попросили
Когда выбирать
учебные задачи, контрольные, всё новое для вас
рутина, которую вы уже умеете писать сами; второй взгляд на свой код
Лучшая схема — гибрид: пишете компонент сами, затем просите ИИ только проверить его по чек-листу, а не переписать. Так вы остаётесь автором, а ИИ работает как внимательный ревьюер. На контрольных ИИ выключен.
ИИ выдал компонент CourseList и уверял, что «всё учтено». Стенд ведёт его по четырём темам: js (медленно), быстро переключается на react, затем none (пустой список) и broken (сервер упал, статус 500). Запустите — вы увидите сразу три проблемы. Исправьте толькоCourseList: проверка ok, отсутствие гонки и AbortError, отдельная строка для пустого списка.
показано: 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(..., [])
очистка списка и цикл с append
users.map(...) с key в JSX
input.addEventListener("input", ...)
onChange и состояние query
ручной clearTimeout для debounce
хук useDebounce с cleanup
переменная «последний запрос» против гонок
AbortController в cleanup эффекта
Главный сдвиг мышления: в DOM-версии вы говорили браузеру, что менять («поставь этот текст», «добавь этот элемент»). В React вы описываете, как должен выглядеть экран при данном состоянии, а менять DOM берётся React.
Шаг 1. Список и состояния
Загрузка, ошибка, пусто, успех — всё, что мы разобрали в разделе 03. Запустите и подумайте: что произойдёт, если поменять адрес на неправильный?
Добавили поле поиска, хук useDebounce и выбор адреса: пустой запрос — обычный список, непустой — /users/search. Эффект теперь зависит от debounced, а не от query. Попробуйте ma, ol и бессмысленное zzz — увидите состояние «пусто».
Нажмите на человека в списке: компонент UserDetail делает свой запрос по id. Он зависит от [id], а cleanup отменяет предыдущий запрос — значит, быстро щёлкая по списку, вы не получите чужую карточку. Общую часть (проверка ok) мы вынесли в функцию getJson: так проверка в одном месте.
Скопируйте шаг 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; // сколько раз обратились к «серверу»functionfakeFetch(url, { signal } = {}) {
calls++;
const q = (newURL(url, "http://x").searchParams.get("q") ||"").toLowerCase();
const list = USERS.filter((u) => (u.name + " " + u.city).toLowerCase().includes(q));
returnnewPromise((resolve, reject) => {
const t = setTimeout(() =>resolve({ ok: true, status: 200, json: async () => list }), 100);
signal?.addEventListener("abort", () => {
clearTimeout(t);
reject(newDOMException("Aborted", "AbortError"));
});
});
}
// ---------- хук из задачи 5.3 (уже готов) ----------functionuseDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() =>setDebounced(value), delay);
return () =>clearTimeout(id);
}, [value, delay]);
return debounced;
}
// ---------- ВАШЕ приложение ----------functionUsersApp() {
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>.returnnull;
}
// ---------- стенд: «пользователь» печатает в поле. Не меняйте ----------functiontypeInto(input, text) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set;
setter.call(input, text);
input.dispatchEvent(newEvent("input", { bubbles: true }));
}
functionHarness() {
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);
}, []);
returnnull;
}
export defaultfunctionApp() {
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 (идея, не запускается здесь)
Посмотрите, чего здесь нет: нет 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, готовая маршрутизация и серверный код в одном проекте. Данные можно получать на сервере, а интерактив оставить клиентским.
Уровень 3 — это React Pro + Next.js. Между концом этого курса и его началом у вас есть время, и лучше провести его с пользой: не «изучать всё подряд», а двигаться по плану. Ниже — план на 8 недель самостоятельной подготовки: темы по неделям и один проект на каждые две недели.
Аналогия из жизни
Дорога из Душанбе в Хорог не покоряется за один рывок: есть Куляб, Дарваз, остановки, ночёвки. План — это карта остановок. Даже если вы идёте медленнее, чем задумано, вы знаете, где находитесь и куда двигаться дальше.
План на 8 недель
Неделя
Тема
Что практиковать
1
Повторение: компоненты, props, useState, формы
управляемые поля (controlled inputs), форма с валидацией, список с добавлением и удалением
2
useEffect, собственные хуки, useReducer
хуки useDebounce, useLocalStorage, useFetch; Проект 1
3
Маршруты (React Router), общее состояние через Context
несколько страниц, страница товара по id, тема оформления через Context
4
Работа с данными: TanStack Query или SWR, пагинация
кэш, повтор запроса, «загрузить ещё»; Проект 2
5
TypeScript в React
типы для props и ответов API, interface, useState<User[]>
6
Введение в Next.js: страницы, лейауты, ссылки
создать проект на Next.js, перенести 3 страницы; Проект 3 — перенос Проекта 2 на Next.js
7
Серверные и клиентские компоненты, loading.jsx, error.jsx
получать данные на сервере, интерактив вынести в "use client"
опубликовать сайт, README, скриншоты; Проект 4 — итоговый мини-продукт в портфолио
Сверьте план с программой Уровня 3
Это план подготовки, а не программа курса. Порядок тем можно менять, а то, что окажется в Уровне 3, лучше уточнить у преподавателя. Если сомневаетесь, что вам нужнее — повторите useEffect и работу с API: именно на них держится почти всё остальное.
Как заниматься, чтобы план работал
30–60 минут каждый день лучше, чем 5 часов раз в неделю. Мозгу нужны повторения.
Один коммит в день в GitHub: репозиторий — ваше портфолио, а ряд зелёных квадратов дисциплинирует.
Читайте чужой код: откройте любой небольшой React-проект на GitHub и попробуйте объяснить, что делает каждый компонент.
Документация — друг. Раздел «Learn» на react.dev написан для новичков и с живыми примерами.
Правила ИИ сохраняются: сначала сами, потом промпт, ничего без построчного понимания.
Идеи проектов
Идея
Чему учит
Трекер расходов в сомони с хранением в localStorage
формы, useReducer, собственный хук
Погода в городах Таджикистана (API Open-Meteo, без ключа)
useEffect, состояния загрузки, выбор города
Каталог рецептов курутоба, оши палав, самбусы с поиском и фильтрами
фильтрация, URL-параметры, карточки
Мини-магазин с корзиной и итоговой суммой
Context, управление состоянием, пересчёты
Расписание занятий курса с отметкой посещённых уроков
Промпт №26 из шпаргалки курса. Он даёт каркас, но решать, что реально осилите вы, придётся самим:
Я закончил курс «Frontend + AI» (HTML/CSS, JavaScript, DOM, fetch, Git, основы React: props, state, useEffect). Составь план на 8 недель для перехода от новичка в React к уверенному React + Next.js: темы по неделям и по одному проекту на каждые 2 недели. Я занимаюсь 1 час в будни и 3 часа в выходные. Формат ответа: таблица «неделя — тема — что практиковать — результат». В конце напиши, что в этом плане самое рискованное и что можно убрать, если я отстану.
Проверка по формуле курса: сверьте план с программой Уровня 3, уберите лишнее, оцените время сами. План, который вы не сможете выполнить, хуже скромного, но живого.
Возьмите таблицу плана выше и перенесите в тетрадь или в файл PLAN.md в вашем репозитории. Для каждой недели вместо общих слов запишите конкретное: какие дни и во сколько вы занимаетесь, какую одну вещь сделаете руками, как поймёте, что неделя удалась. Выберите идею проекта для Проекта 1 и напишите, что она будет уметь. Автопроверки нет — отметьте задачу вручную, когда план готов.
Подсказка 1
Если не знаете, с чего начать, возьмите промпт №26 выше и сравните его ответ со своими мыслями — но итоговый план пишите своими словами.
Подсказка 2
Хороший пункт плана проверяем: «в среду 19:00–20:00 — сделать useLocalStorage и использовать его в трекере». Плохой: «изучить хуки».
10 Домашнее задание
Доделать React-приложение на API, залить его на GitHub и составить план подготовки к Уровню 3. Артефакт: репозиторий с приложением + файл PLAN.md.
Что нужно сделать
Выберите свой API без ключа и с CORS (проверьте в браузере, что ответ приходит): jsonplaceholder.typicode.com, dummyjson.com, api.open-meteo.com, restcountries.com, rickandmortyapi.com. Тема своя: товары, страны, погода, персонажи.
Создайте проект React (Vite, как в уроке 17, шаблон React) или соберите в онлайн-среде и перенесите файлы в репозиторий.
Список из API через useEffect + fetch: минимум 6 карточек с 2+ полями, key в списке.
Четыре состояния: загрузка, ошибка (проверьте, подставив неверный адрес), «ничего не найдено», успех. Кнопка «Повторить» при ошибке.
Поиск с debounce (свой хук useDebounce) — через запрос к серверу или фильтрацию списка.
Отмена запросов:AbortController в cleanup, AbortError не показывается пользователю.
Проверка ИИ. Если использовали ИИ — приложите промпт (формула: контекст → задача → ограничения → формат → проверка) и пройдённый чек-лист из раздела 06.
План PLAN.md: 8 недель, у каждой — конкретные действия, выбранная идея Проекта 1 и ссылки на 2–3 ресурса, которые будете читать.
Залейте на GitHub (урок 19): осмысленные коммиты, минимум 5, файл README со скриншотами и описанием.
Что сдавать
Ссылку на репозиторий GitHub с приложением, README и PLAN.md.
Два скриншота: страница с данными и страница с состоянием ошибки.
Короткое описание в README: какой API выбрали, какие крайние случаи обработали.
Как оцениваем (0–10)
Что
Баллы
Загрузка данных в useEffect, правильный массив зависимостей, key в списке
2
Четыре состояния экрана работают (включая проверку response.ok)
2
Поиск с debounce, лишних запросов нет
2
Cleanup: AbortController, обработка AbortError
1
Репозиторий на 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 игнорируем.
Гонка запросов: 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.
Модули и Vite, Git и GitHub, отладка, ревью кода, README, два проекта с защитой.
Работа с ИИ
Промпт по формуле, проверка ответа, правило «ничего без понимания», личная шпаргалка из рабочих промптов.
React
Компоненты, props, useState, списки с key, useEffect, загрузка данных, cleanup, поиск с debounce.
Аналогия из жизни
Хороший плов не получается с первого раза — он получается с десятого, когда рука сама знает, сколько масла и когда добавить рис. Программирование такое же: теперь у вас есть рецепты, а «рука» вырабатывается практикой. Не бросайте её.
Три совета на дорогу
Пишите код каждый день, хотя бы по чуть-чуть. Регулярность важнее длины занятий.
Делайте проекты про то, что вам интересно. Тогда сложные места не отпугивают, а затягивают.
Не бойтесь ошибок и вопросов. Красное сообщение в консоли — не оценка вам, а подсказка. А когда застрянете — опишите баг так, как мы учились: что ожидали, что получили, какой код.
Что сделать прямо сегодня
Откройте PLAN.md, запишите время первого занятия на завтра — и сделайте первый коммит. Спасибо вам за усердие, вопросы и терпение. Желаем уверенных шагов к Уровню 3 — до встречи там!