MakeFirstStep· Frontend + ИИ

Урок 23 · React Lite (бонус)

Props, списки и keys, useState

Вы уже умеете создавать компоненты и собирать из них страницу. Но страница, которая не меняется, — это просто красивая картинка. Сегодня мы научим компоненты принимать данные (props), выводить списки из массива, помнить состояние (useState) и реагировать на пользователя. Именно на этих четырёх вещах держится интерфейс любого сайта на React — от корзины интернет-магазина до чата.

  • Передавать данные в компонент через props, использовать children, деструктуризацию и значения по умолчанию
  • Выводить список из массива через .map() и объяснить, зачем нужен key и почему индекс в роли ключа ломает список
  • Хранить изменяемые данные в useState и обновлять их через setter, в том числе функциональным способом
  • Никогда не менять состояние «на месте»: добавлять, удалять и обновлять элементы массивов и объектов через spread, filter и map
  • Обрабатывать события (onClick, onChange, onSubmit) и делать управляемые поля ввода
  • Показывать и прятать части интерфейса: &&, тернарный оператор, ранний return
Как работать с этой страницей

Блоки кода с вкладками App.jsx и style.css — это мини-проекты React прямо в браузере: слева код, справа результат, внизу консоль. Меняйте код и нажимайте ▶ Запустить. Им нужен интернет — React подгружается из сети. В задачах под кодом есть строка // ---- проверка (не менять) ----: это маленький «робот-тестировщик», который после запуска сам нажимает кнопки и вводит текст, а потом пишет результат в консоль — по нему задача проверяется автоматически. В настоящем проекте такой блок не нужен, и трогать его не надо. Подсказки спрятаны под замочком 🔒: сначала пробуйте сами.

И ещё одна важная вещь. В реальном проекте на Vite React в консоли браузера ругается на ошибки, например «Each child in a list should have a unique key». Здесь, в учебной песочнице, работает «боевая» (production) сборка React, и этих предупреждений нет. Поэтому ошибки с ключами и состоянием мы будем ловить по результату, а не по красным строкам. Это даже полезнее: так вы учитесь видеть баг глазами.

01 Props: данные в компонент

Компонент — это функция, которая возвращает кусок интерфейса (на прошлом уроке вы создавали их в Vite). Одна и та же функция должна уметь рисовать разные карточки: про самбусу, про курутоб, про шакароб. Для этого ей нужны входные данные. В React они называются props — от слова «properties», свойства.

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

Меню в чайхоне. Рамка, шрифт и расположение строки — это компонент: они одинаковые для всех блюд. А «Самбуса — 5 сомони» и «Курутоб — 35 сомони» — это props: у каждого блюда свои. Типографии не нужно печатать новый бланк для каждого блюда — меняется только то, что вписано в строки.

Props передают как атрибуты в HTML. Внутри компонента они приходят одним объектом в первом аргументе функции:

Как props попадают в компонент
function DishCard(props) {
  return <div>{props.name} — {props.price} сомони</div>;
}

// props = { name: "Самбуса", price: 5 }
<DishCard name="Самбуса" price={5} />

Обратите внимание на запись значений: строки можно писать в кавычках (name="Самбуса"), а всё остальное — числа, булевы значения, массивы, объекты, функции — в фигурных скобках (price={5}). Фигурные скобки здесь означают: «внутри — обычное JavaScript-выражение».

Карточки блюд через props · Ctrl + Enter
// Один компонент — много карточек: данные приходят через props
function DishCard({ name, price }) {
  return (
    <div className="card">
      <b>{name}</b>
      <div>{price} сомони</div>
    </div>
  );
}

export default function App() {
  return (
    <div>
      <DishCard name="Самбуса" price={5} />
      <DishCard name="Курутоб" price={35} />
      <DishCard name="Шакароб" price={15} />
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Props
Объект с данными, который родитель передаёт ребёнку. Родитель пишет их как атрибуты, ребёнок получает как аргумент функции.
Односторонний поток
Данные идут только сверху вниз: от родителя к ребёнку. Ребёнок не может переписать props своего родителя.
children
Особый prop: всё, что написано между открывающим и закрывающим тегами компонента.

Props нельзя менять

Props доступны компоненту только для чтения. Компонент должен вести себя как чистая функция: получил данные — нарисовал по ним интерфейс, но сами данные не трогает. Если компоненту нужно что-то изменять — это уже не props, а состояние (useState, раздел 04).

Частая ошибка

Попытка «подправить» props внутри компонента: props.price = props.price * 2. В режиме разработки React сразу выдаст ошибку, а в боевой сборке изменение может «тихо» сломать другие места. Нужно значение посчитать в новой переменной: const total = price * 2;

children — вложенное содержимое

Компоненты можно вкладывать друг в друга, как обычные HTML-теги. Всё, что вы написали между <Panel> и </Panel>, придёт в компонент в prop children. Вы сами решаете, где его вывести.

Panel с children · Ctrl + Enter
// children — всё, что написали МЕЖДУ открывающим и закрывающим тегом
function Panel({ title, children }) {
  return (
    <section className="card">
      <h3 style={{ margin: "0 0 6px" }}>{title}</h3>
      {children}
    </section>
  );
}

export default function App() {
  return (
    <div>
      <Panel title="Сегодня в MakeFirstStep">
        <p>JavaScript, 18:00</p>
        <p>Практика с ИИ, 19:30</p>
      </Panel>
      <Panel title="Объявление">
        <button>Записаться</button>
      </Panel>
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Аналогия из жизни

Подарочная коробка. Сама коробка (рамка, бантик, надпись) — это компонент Panel. Что лежит внутри — children. Одну и ту же коробку можно использовать для конфет, для книги, для платка. Коробке неважно, что внутри, — её дело красиво это упаковать.

Компонент-«обёртка» с children — один из самых частых приёмов: карточки, модальные окна, секции страницы, кнопки с иконками.

Читать подробнее: react.dev — Passing Props to a Component, learn.javascript.ru — Деструктурирующее присваивание

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

Начнём с простого: научим компоненты принимать данные.

ЗАДАЧА 1.1

Меню чайханы

лёгкая

Компонент DishCard всегда рисует «Самбуса — 5 сомони», хотя в App ему передают разные блюда. Заставьте его показывать данные из props: name и price.

Меню чайханы · Ctrl + Enter
// Карточка блюда. Сейчас она всегда показывает одно и то же.
function DishCard() {
  return <div className="card">Самбуса — 5 сомони</div>;
}

export default function App() {
  return (
    <div>
      <DishCard name="Самбуса" price={5} />
      <DishCard name="Курутоб" price={35} />
      <DishCard name="Шакароб" price={15} />
    </div>
  );
}

// ---- проверка (не менять) ----
setTimeout(() => {
  document.querySelectorAll(".card").forEach((card) => console.log(card.textContent));
}, 100);
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
Самбуса — 5 сомони
Курутоб — 35 сомони
Шакароб — 15 сомони
Подсказка 1

Props приходят первым аргументом функции. Записать их удобно сразу в деструктуризации: function DishCard({ name, price }).

Подсказка 2

Внутри JSX значения подставляются в фигурных скобках: {name} — {price} сомони.

ЗАДАЧА 1.2

Панель с содержимым

лёгкая

Компонент Panel рисует рамку с заголовком, но два абзаца, которые положили внутрь тегов, куда-то пропадают. Верните их на место: выведите содержимое под заголовком.

Панель с содержимым · Ctrl + Enter
// Панель-рамка: должна показывать заголовок И всё, что положили внутрь тегов
function Panel({ title }) {
  return (
    <section className="card">
      <h3>{title}</h3>
    </section>
  );
}

export default function App() {
  return (
    <Panel title="Сегодня в MakeFirstStep">
      <p>JavaScript, 18:00</p>
      <p>Практика с ИИ, 19:30</p>
    </Panel>
  );
}

// ---- проверка (не менять) ----
setTimeout(() => {
  const panel = document.querySelector(".card");
  console.log(panel.querySelector("h3").textContent);
  console.log(panel.querySelectorAll("p").length);
  console.log(panel.lastElementChild.textContent);
}, 100);
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
Сегодня в MakeFirstStep
2
Практика с ИИ, 19:30
Подсказка 1

Всё, что лежит между тегами компонента, приходит в prop с особым именем children.

Подсказка 2

Добавьте children в список props: { title, children } — и выведите {children} после заголовка.

ЗАДАЧА 1.3

Куда пропала цена?

средняя

Цена плова не показывается: на странице только слово «сомони». Это найди баг: исправьте одно слово в коде. Подумайте, чем отличаются имена, которые компонент ожидает, и имена, которые ему передали. React на такое не ругается, а просто кладёт undefined.

Куда пропала цена? · Ctrl + Enter
// Цена не показывается. Найдите и исправьте ОДНУ ошибку.
function Price({ value, currency }) {
  return <b>{value} {currency}</b>;
}

export default function App() {
  return (
    <p>
      Плов: <Price amount={45} currency="сомони" />
    </p>
  );
}

// ---- проверка (не менять) ----
setTimeout(() => {
  console.log(document.querySelector("b").textContent);
}, 100);
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
45 сомони
Подсказка 1

Сравните имя prop в объявлении Price({ value, ... }) и в вызове <Price amount=... />.

Подсказка 2

Имена должны совпадать буква в букву. Либо замените amount на value в вызове, либо наоборот — в объявлении.

02 Деструктуризация, значения по умолчанию и rest

Писать props.name, props.price, props.city в каждой строке утомительно. Вы уже знаете из урока про объекты, как это сокращается: деструктуризация. В React её используют почти везде — прямо в скобках параметров функции.

Было и стало
// было
function StudentCard(props) {
  return <p>{props.name}, {props.age} лет</p>;
}

// стало
function StudentCard({ name, age }) {
  return <p>{name}, {age} лет</p>;
}

Значения по умолчанию

Если родитель не передал prop, внутри будет undefined. Чтобы подставить запасное значение, используйте знак = в деструктуризации — как в параметрах обычной функции (урок 5).

Кнопка со значениями по умолчанию · Ctrl + Enter
// Значения по умолчанию — прямо в деструктуризации props
function Button({ label = "Нажми", color = "gray" }) {
  return (
    <button style={{ background: color, color: "white", border: 0, borderRadius: 8, marginRight: 8 }}>
      {label}
    </button>
  );
}

export default function App() {
  return (
    <div>
      <Button label="Купить" color="green" />
      <Button label="Отмена" />
      <Button />
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ловушка

Значение по умолчанию срабатывает только для undefined. Если передали null, 0 или пустую строку "", будет использовано именно это значение. Для null в JavaScript есть оператор ?? — вы знакомы с ним по уроку про условия. Разберём это в задаче 2.3.

Rest и spread: «раздать остальные props»

Бывает, что компонент сам использует один-два prop, а все остальные должен передать вложенному тегу — например, <input>. Здесь работают знакомые три точки:

  • { label, ...rest } в параметрах — собирает все остальные props в объект rest;
  • <input {...rest} /> в JSX — раздаёт этот объект как набор атрибутов.
Field: label + остальное на input · Ctrl + Enter
// ...rest собирает «все остальные» props, а {...rest} раздаёт их дальше
function Field({ label, ...rest }) {
  return (
    <label style={{ display: "block", margin: "6px 0" }}>
      {label}: <input {...rest} />
    </label>
  );
}

export default function App() {
  return (
    <div>
      <Field label="Имя" placeholder="Мадина" />
      <Field label="Пароль" type="password" placeholder="не менее 8 знаков" />
      <Field label="Возраст" type="number" min="14" max="99" />
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Аналогия из жизни

Маршрутка в Душанбе. Водитель берёт плату за проезд (это label — то, что нужно ему), а вашу просьбу «остановите у Зелёного базара» и сумку (это rest) он не оставляет себе, а просто передаёт дальше. Так и компонент: берёт, что ему нужно, а остальное пересылает дальше без разбирательств.

Совет

Используйте {...rest} осознанно. Если раздавать «всё подряд», в тег может попасть prop, который он не понимает, и это сложно отлаживать. Для простых компонентов лучше явно перечислять нужные props.

Читать подробнее: react.dev — значения props по умолчанию, learn.javascript.ru — Rest и spread

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

ЗАДАЧА 2.1

Кнопка с запасным текстом

лёгкая

Две из трёх кнопок пока пустые и без цвета, потому что родитель не передал им label и color. Задайте значения по умолчанию: label = "Нажми", color = "gray".

Кнопка с запасным текстом · Ctrl + Enter
// Задайте значения по умолчанию: label = "Нажми", color = "gray"
function Button({ label, color }) {
  return <button style={{ background: color }}>{label}</button>;
}

export default function App() {
  return (
    <div>
      <Button label="Купить" color="green" />
      <Button />
      <Button label="Отмена" />
    </div>
  );
}

// ---- проверка (не менять) ----
setTimeout(() => {
  document.querySelectorAll("button").forEach((b) => console.log(b.textContent, b.style.background));
}, 100);
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
Купить green
Нажми gray
Отмена gray
Подсказка 1

Знак = пишется прямо в деструктуризации, рядом с именем: { label = "...", color = "..." }.

Подсказка 2

Запись выглядит так: function Button({ label = "Нажми", color = "gray" }).

ЗАДАЧА 2.2

Поле, которое всё передаёт дальше

средняя

Компонент Field рисует подпись и поле ввода, но placeholder, type и maxLength, которые ему передают, теряются: у обоих полей обычный тип и без ограничений. Соберите остальные props через ...rest и передайте их в <input>.

Поле, которое всё передаёт дальше · Ctrl + Enter
// Field должен передавать «лишние» props на <input>. Сейчас placeholder, type и maxLength теряются.
function Field({ label }) {
  return (
    <label>
      {label}: <input />
    </label>
  );
}

export default function App() {
  return (
    <div>
      <Field label="Имя" placeholder="Мадина" maxLength={10} />
      <br />
      <Field label="Пароль" type="password" placeholder="••••" />
    </div>
  );
}

// ---- проверка (не менять) ----
setTimeout(() => {
  document.querySelectorAll("input").forEach((i) => console.log(i.type, i.placeholder, i.maxLength));
}, 100);
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
text Мадина 10
password •••• -1
Подсказка 1

В параметрах: { label, ...rest }. Теперь в rest лежат все props, кроме label.

Подсказка 2

В JSX раздайте их полю: <input {...rest} />. Значение -1 в ответе — это обычное «ограничения нет» для maxLength.

ЗАДАЧА 2.3

Предскажите, что покажут метки

средняя

В коде шесть меток с разными значениями text. Не запуская код, запишите в тетрадь, что появится в квадратных скобках у каждой. Потом запустите и сверьтесь. Затем исправьте Label: пусть для null тоже показывается «—». Для 0 и пустой строки поведение менять не нужно.

Предскажите, что покажут метки · Ctrl + Enter
// 1) Предскажите, что покажет каждая из шести меток. 2) Запустите и сверьтесь.
// 3) Исправьте Label так, чтобы и для null показывалось «—».
function Label({ text = "—" }) {
  return <span>[{text}]</span>;
}

export default function App() {
  return (
    <div>
      <Label />
      <Label text="Плов" />
      <Label text={undefined} />
      <Label text={null} />
      <Label text={0} />
      <Label text="" />
    </div>
  );
}

// ---- проверка (не менять) ----
setTimeout(() => {
  document.querySelectorAll("span").forEach((s) => console.log(s.textContent));
}, 100);
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат после исправления
[—]
[Плов]
[—]
[—]
[0]
[]
Подсказка 1

Значение по умолчанию в скобках не срабатывает на null. Уберите его оттуда и разберитесь с запасным значением внутри функции.

Подсказка 2

Оператор ?? подставляет правую часть, если слева null или undefined, а 0 и "" оставляет как есть: const shown = text ?? "—";

03 Списки: .map() и key

Данные на сайте почти всегда приходят списком: товары в каталоге, сообщения в чате, студенты в группе. Писать <li> для каждого элемента вручную нельзя — длина списка заранее неизвестна. В React список рисуют так: берут массив и превращают каждый его элемент в кусок JSX методом .map(). С map вы познакомились на уроке про массивы — он возвращает новый массив, и React умеет выводить массив из JSX-элементов.

Список уроков через map · Ctrl + Enter
// Список из массива: .map() превращает каждый элемент в <li>
const lessons = ["HTML и CSS", "JavaScript", "React Lite"];

export default function App() {
  return (
    <ul>
      {lessons.map((lesson) => (
        <li key={lesson}>{lesson}</li>
      ))}
    </ul>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
  1. Массив данных. lessons — обычный массив строк.
  2. map превращает каждую строку в элемент. Для "JavaScript" получается <li>JavaScript</li>.
  3. Получился массив из трёх JSX-элементов. React выводит их на страницу один за другим.
  4. Всё это записано в фигурных скобках { ... } — внутри JSX это «вставка JavaScript».

Зачем нужен key

Каждому элементу списка нужно дать особый prop key — уникальный «паспорт» элемента. По ключу React узнаёт, какой именно элемент изменился, добавился или исчез, и перерисовывает только нужное. Без ключей он не может отличить «удалили второй элемент» от «изменили содержимое второго и третьего».

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

Гардероб в чайхоне или театре. Вы сдаёте куртку и получаете номерок. Гардеробщик не запоминает ваше лицо и не смотрит на цвет куртки, а ищет по номеру. Если номерки выдавать «по порядку в очереди» (первый, второй, третий) и первый человек уйдёт, все остальные внезапно получат чужие номерки. Хороший номерок принадлежит человеку, а не месту в очереди. Именно так работают ключи.

Ключ должен быть:

  • уникальным среди соседей (в одном списке), но не обязательно на всей странице;
  • стабильным: у одного и того же элемента при каждой перерисовке один и тот же ключ;
  • взятым из данных: id из базы, уникальный артикул или логин — а не случайное число.
Карточки студентов по id · Ctrl + Enter
// Массив объектов: у каждого есть уникальный id — он и станет key
const students = [
  { id: 1, name: "Мадина", city: "Душанбе" },
  { id: 2, name: "Фарход", city: "Хуҷанд" },
  { id: 3, name: "Зарина", city: "Бохтар" },
];

function StudentCard({ name, city }) {
  return (
    <div className="card">
      <b>{name}</b> — {city}
    </div>
  );
}

export default function App() {
  return (
    <div>
      {students.map((student) => (
        <StudentCard key={student.id} name={student.name} city={student.city} />
      ))}
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

Ключ пишется на самом внешнем элементе, который вы возвращаете из map — здесь это <StudentCard key=...>. Внутри компонента StudentCard ключ не нужен, и сам компонент его «не увидит»: key — служебный атрибут, он не попадает в props.

Что брать в качестве key

ВариантГодится?Почему
key={item.id}Да, лучшийПринадлежит самому элементу и не меняется при удалении и сортировке
key={item} для строкДа, если строки не повторяютсяСамо значение уникально — например, список названий уроков
key={index}Только для списка, который никогда не меняетсяИндекс — это номер места, а не паспорт элемента. Подробности — в разделе 06
key={Math.random()}Нет, никогдаКаждая перерисовка создаёт новые ключи, и React пересоздаёт все элементы с нуля
Частая ошибка

Вместо стрелочной функции с круглыми скобками (tag) => ( ... ) написать фигурные (tag) => { ... } и забыть слово return. Функция честно отработает и вернёт undefined — а вместо списка на странице будет пусто. Это то самое правило из урока про функции: у стрелочной функции с { } результат нужно возвращать явно. Именно в такую ловушку вы попадёте в задаче 3.4.

Совет

Фильтрация и вывод идут цепочкой, как на уроке про массивы: orders.filter(...).map(...). Сначала оставьте нужные элементы, потом превратите их в JSX. Если нужен счётчик («найдено: 2»), удобно сначала сохранить результат filter в переменную и взять у неё .length.

Читать подробнее: react.dev — Rendering Lists, MDN — Array.prototype.map()

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

ЗАДАЧА 3.1

Расписание уроков

лёгкая

Выведите все элементы массива lessons в виде <li> внутри <ul>. Не забудьте key.

Расписание уроков · Ctrl + Enter
const lessons = ["HTML и CSS", "JavaScript", "React Lite"];

export default function App() {
  return (
    <ul>
      {/* выведите по одному <li> на каждый урок из массива lessons */}
    </ul>
  );
}

// ---- проверка (не менять) ----
setTimeout(() => {
  const items = document.querySelectorAll("li");
  console.log(items.length);
  items.forEach((li) => console.log(li.textContent));
}, 100);
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
3
HTML и CSS
JavaScript
React Lite
Подсказка 1

Внутри <ul> поставьте фигурные скобки и вызовите lessons.map(...).

Подсказка 2

{lessons.map((lesson) => <li key={lesson}>{lesson}</li>)} — строки в этом массиве не повторяются, поэтому сама строка подходит в роли ключа.

ЗАДАЧА 3.2

Меню из массива объектов

лёгкая

Выведите через компонент MenuItem все блюда из массива menu. Ключом должен быть id блюда.

Меню из массива объектов · Ctrl + Enter
const menu = [
  { id: 1, name: "Самбуса", price: 5 },
  { id: 2, name: "Курутоб", price: 35 },
  { id: 3, name: "Шакароб", price: 15 },
  { id: 4, name: "Чой", price: 3 },
];

function MenuItem({ name, price }) {
  return <div className="card">{name} · {price} сомони</div>;
}

export default function App() {
  return (
    <div>
      {/* выведите все блюда из menu через MenuItem, не забыв про key */}
    </div>
  );
}

// ---- проверка (не менять) ----
setTimeout(() => {
  document.querySelectorAll(".card").forEach((c) => console.log(c.textContent));
}, 100);
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
Самбуса · 5 сомони
Курутоб · 35 сомони
Шакароб · 15 сомони
Чой · 3 сомони
Подсказка 1

Для каждого блюда map должен вернуть <MenuItem ... /> с тремя props: key, name, price.

Подсказка 2

{menu.map((dish) => <MenuItem key={dish.id} name={dish.name} price={dish.price} />)}

ЗАДАЧА 3.3

Кто ещё не заплатил

средняя

В массиве orders заказы чайханы. Покажите только неоплаченные — по одному <li> в формате «Клиент — сумма сомони». В абзаце над списком выведите, сколько их всего.

Кто ещё не заплатил · Ctrl + Enter
const orders = [
  { id: 1, client: "Рустам", total: 120, paid: true },
  { id: 2, client: "Зарина", total: 80, paid: false },
  { id: 3, client: "Далер", total: 45, paid: false },
  { id: 4, client: "Нилуфар", total: 200, paid: true },
];

export default function App() {
  // покажите только неоплаченные заказы и их количество
  return (
    <div>
      <p>Ждут оплаты: ?</p>
      <ul></ul>
    </div>
  );
}

// ---- проверка (не менять) ----
setTimeout(() => {
  console.log(document.querySelector("p").textContent);
  document.querySelectorAll("li").forEach((li) => console.log(li.textContent));
}, 100);
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
Ждут оплаты: 2
Зарина — 80 сомони
Далер — 45 сомони
Подсказка 1

Перед return создайте переменную: const unpaid = orders.filter(...). Её длина — это число в абзаце, а сама она — то, что идёт в map.

Подсказка 2

Условие для filter: (order) => !order.paid. Ключом сделайте order.id.

ЗАДАЧА 3.4

Пустой список тегов

лёгкая

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

Пустой список тегов · Ctrl + Enter
// Теги не появляются на странице. Найдите причину.
const tags = ["react", "props", "state"];

export default function App() {
  return (
    <div>
      {tags.map((tag) => {
        <span key={tag} className="card">#{tag}</span>;
      })}
    </div>
  );
}

// ---- проверка (не менять) ----
setTimeout(() => {
  const cards = document.querySelectorAll(".card");
  console.log(cards.length);
  cards.forEach((c) => console.log(c.textContent));
}, 100);
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
3
#react
#props
#state
Подсказка 1

Посмотрите на скобки после стрелки: => {. Что в таких скобках нужно написать, чтобы функция вернула значение?

Подсказка 2

Добавьте return перед <span ...> либо замените { } на круглые скобки ( ).

04 useState: компонент, который помнит

Props приходят снаружи и не меняются. Но на странице есть данные, которые меняются от действий пользователя: сколько раз нажали «лайк», что написано в поле, открыто ли меню. Эти данные называются состоянием (state). Для него в React есть хук useState.

Сначала посмотрим, почему обычной переменной недостаточно. Запустите пример и понажимайте кнопку:

Обычная переменная не работает · Ctrl + Enter
// Так НЕ работает: обычная переменная не заставляет React перерисовать экран.
// Нажмите кнопку несколько раз и сравните экран с консолью.
export default function App() {
  let count = 0;

  function handleClick() {
    count = count + 1;
    console.log("count в памяти:", count);
  }

  return (
    <div>
      <p>На экране: {count}</p>
      <button onClick={handleClick}>+1</button>
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

В консоли число растёт, а на экране всегда ноль. Две причины:

  1. React перерисовывает компонент, только когда его об этом «попросили». Простое изменение переменной ему не видно.
  2. При каждой перерисовке функция компонента выполняется заново с нуля, и let count = 0 снова становится нулём. Обычные переменные внутри компонента не переживают перерисовку.

Нужно место, где значение живёт между перерисовками, и способ сообщить React «значение изменилось, перерисуй». Это и делает useState:

Счётчик на useState · Ctrl + Enter
import { useState } from "react";

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

  return (
    <div>
      <p>Вы нажали {count} раз</p>
      <button onClick={() => setCount(count + 1)}>Нажми меня</button>
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
useState(0)

const [count, setCount] = useState(0);
useState возвращает пару: текущее значение и функцию, которой его меняют
Начальное значение
Аргумент useState(0). Используется только при первом показе компонента. При следующих перерисовках React отдаёт уже сохранённое значение.
Текущее значение
Первый элемент пары — count. Это обычная переменная, которую можно читать в JSX.
Сеттер
Второй элемент — setCount. Вызов setCount(5) сохраняет новое значение и просит React перерисовать компонент.

Имена в паре выбираете вы, но принято писать [что, setЧто]: [liked, setLiked], [name, setName], [items, setItems]. Это деструктуризация массива — вы знаете её с урока 7.

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

Табло на футбольном матче. Судьи (setScore) говорят: «гол!», и табло обновляется. Если зритель на трибуне просто сам себе подумает «уже 3:2» — табло не изменится, и никто больше об этом не узнает. Менять значение напрямую — это «подумать про себя». Позвать сеттер — это «официально сообщить судье».

Своё состояние у каждого компонента

Каждый экземпляр компонента хранит собственное состояние. Если вы вывели Counter дважды, у вас два независимых счётчика.

У каждого свой счётчик · Ctrl + Enter
import { useState } from "react";

// У каждого экземпляра компонента — своё собственное состояние
function Counter({ title }) {
  const [n, setN] = useState(0);
  return (
    <div className="card">
      {title}: <b>{n}</b> <button onClick={() => setN(n + 1)}>+</button>
    </div>
  );
}

export default function App() {
  return (
    <div>
      <Counter title="Плов" />
      <Counter title="Шашлык" />
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

Функциональное обновление

Когда новое значение зависит от старого («прибавить 1»), безопасно передавать в сеттер функцию: setCount((c) => c + 1). React сам подставит в c самое свежее значение. Почему это важно? Внутри одного обработчика переменная count — это «снимок» значения на момент перерисовки. Три вызова setCount(count + 1) подряд превращаются в три раза «поставить 0 + 1» — и счётчик вырастет только на 1.

Снимок и функциональное обновление
// count сейчас 0
setCount(count + 1);  // поставить 0 + 1
setCount(count + 1);  // поставить 0 + 1  (count всё ещё 0 в этом «снимке»)
setCount(count + 1);  // поставить 0 + 1  → итог: 1

// функциональная запись
setCount((c) => c + 1);  // 0 → 1
setCount((c) => c + 1);  // 1 → 2
setCount((c) => c + 1);  // 2 → 3  → итог: 3
Частая ошибка

Ожидать, что сразу после setCount(5) переменная count станет равна 5. Она изменится только при следующей перерисовке. Если нужно «прямо сейчас» работать с новым значением, положите его в отдельную переменную: const next = count + 1; setCount(next); console.log(next);

Правила хуков

Хуки вызывают только на верхнем уровне компонента — не внутри if, циклов и вложенных функций — и только в компонентах (функциях с именем с заглавной буквы). Так React не теряет порядок хранимых значений.

Хранить только необходимое

Если значение можно вычислить из другого состояния — не заводите под него отдельное. В задаче 4.2 число лайков вычисляется из флага liked: два значения, которые легко рассинхронизировать, превращаются в одно.

Читать подробнее: react.dev — State: A Component's Memory, react.dev — useState

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

ЗАДАЧА 4.1

Счётчик посетителей

лёгкая

Оживите счётчик: «+» прибавляет 1, «−» вычитает 1, «Сброс» обнуляет. Для этого добавьте useState и обработчики onClick. Не забудьте импорт в самом верху файла.

Счётчик посетителей · Ctrl + Enter
// Оживите счётчик: добавьте useState и обработчики для трёх кнопок.
// Не забудьте строку импорта в самом верху: import { useState } from "react";
export default function App() {
  return (
    <div>
      <p>Счёт: 0</p>
      <button>−</button> <button>+</button> <button>Сброс</button>
    </div>
  );
}

// ---- проверка (не менять) ----
const wait = () => new Promise((r) => setTimeout(r, 60));
const $ = (s) => document.querySelector(s);
const $$ = (s) => [...document.querySelectorAll(s)];
const btn = (t) => $$("button").find((b) => b.textContent.trim() === t);
async function click(el) {
  el.click();
  await wait();
}
(async () => {
  await wait();
  console.log($("p").textContent);
  await click(btn("+"));
  await click(btn("+"));
  await click(btn("+"));
  await click(btn("−"));
  console.log($("p").textContent);
  await click(btn("Сброс"));
  console.log($("p").textContent);
})();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
Счёт: 0
Счёт: 2
Счёт: 0
Подсказка 1

Начните с const [count, setCount] = useState(0); и замените в абзаце 0 на {count}.

Подсказка 2

Обработчики: onClick={() => setCount(count + 1)}, onClick={() => setCount(count - 1)}, onClick={() => setCount(0)}. Стрелка нужна, чтобы функцию не вызвать сразу при отрисовке.

ЗАДАЧА 4.2

Кнопка «лайк»

средняя

Сделайте кнопку лайка: по первому клику сердечко закрашивается (♥) и число растёт на 1, по второму всё возвращается назад (♡, исходное число). В состоянии храните только флаг liked, а число лайков вычисляйте из него.

Кнопка «лайк» · Ctrl + Enter
// Кнопка «лайк»: по клику сердечко закрашивается и счётчик растёт на 1, по второму клику — всё возвращается.
// Подсказка по дизайну: хранить в состоянии нужно только liked, а число лайков можно вычислить.
export default function App() {
  const likes = 12;
  return <button>♡ {likes}</button>;
}

// ---- проверка (не менять) ----
const wait = () => new Promise((r) => setTimeout(r, 60));
async function click(el) {
  el.click();
  await wait();
}
(async () => {
  const b = document.querySelector.bind(document);
  await wait();
  console.log(b("button").textContent);
  await click(b("button"));
  console.log(b("button").textContent);
  await click(b("button"));
  console.log(b("button").textContent);
})();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
♡ 12
♥ 13
♡ 12
Подсказка 1

Состояние: const [liked, setLiked] = useState(false);. Клик переключает: setLiked(!liked).

Подсказка 2

Число: const likes = liked ? 13 : 12;. Сердечко: {liked ? "♥" : "♡"}.

ЗАДАЧА 4.3

Кнопка «+3»

средняя

Найди баг. Кнопка «+3» вызывает setCount три раза, но счёт растёт только на 1. Исправьте обработчик, не уменьшая количество вызовов setCount.

Кнопка «+3» · Ctrl + Enter
// Кнопка «+3» прибавляет только 1. Почему? Исправьте, не меняя число вызовов setCount.
import { useState } from "react";

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

  function addThree() {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  }

  return (
    <div>
      <p>Счёт: {count}</p>
      <button onClick={addThree}>+3</button>
    </div>
  );
}

// ---- проверка (не менять) ----
const wait = () => new Promise((r) => setTimeout(r, 60));
async function click(el) {
  el.click();
  await wait();
}
(async () => {
  const p = () => document.querySelector("p").textContent;
  await wait();
  console.log(p());
  await click(document.querySelector("button"));
  console.log(p());
  await click(document.querySelector("button"));
  console.log(p());
})();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
Счёт: 0
Счёт: 3
Счёт: 6
Подсказка 1

Три вызова видят один и тот же «снимок» count. Нужно, чтобы каждый следующий вызов отталкивался от результата предыдущего.

Подсказка 2

Передайте в сеттер функцию: setCount((c) => c + 1), три раза подряд.

ЗАДАЧА 4.4

Три независимых счётчика

средняя

Сначала предскажите: что покажут три счётчика после двух нажатий на первом и одного на третьем? Затем добавьте в Counter prop step (по умолчанию 1) — кнопка должна прибавлять step — и задайте третьему счётчику step={10}.

Три независимых счётчика · Ctrl + Enter
// 1) Предскажите: что покажут три счётчика, если нажать «+» два раза на первом и один раз на третьем?
// 2) Добавьте Counter prop step (по умолчанию 1): кнопка прибавляет step.
// 3) Третьему счётчику задайте step={10}.
import { useState } from "react";

function Counter({ step = 1 }) {
  const [n, setN] = useState(0);
  return (
    <div className="card">
      <span>Счёт: {n}</span> <button onClick={() => setN(n + 1)}>+</button>
    </div>
  );
}

export default function App() {
  return (
    <div>
      <Counter />
      <Counter />
      <Counter />
    </div>
  );
}

// ---- проверка (не менять) ----
const wait = () => new Promise((r) => setTimeout(r, 60));
async function click(el) {
  el.click();
  await wait();
}
(async () => {
  const buttons = () => [...document.querySelectorAll("button")];
  await wait();
  await click(buttons()[0]);
  await click(buttons()[0]);
  await click(buttons()[2]);
  document.querySelectorAll("span").forEach((s) => console.log(s.textContent));
})();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат после исправления
Счёт: 2
Счёт: 0
Счёт: 10
Подсказка 1

Состояние у каждого Counter своё, поэтому клики по первому не влияют на третий. Новая часть здесь — prop step.

Подсказка 2

В обработчике замените n + 1 на n + step, а в App у третьего вызова напишите <Counter step={10} />.

05 Главное правило: не менять состояние «на месте»

Состояние-массив и состояние-объект — самое частое место для ошибок новичков. Правило одно: никогда не меняйте состояние напрямую — всегда передавайте в сеттер новую копию. React сравнивает старое и новое значение «по ссылке». Если вы изменили тот же самый массив и передали его обратно, для React ничего не изменилось — он пропускает перерисовку.

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

Электронная очередь в банке. Если оператор молча зачеркнёт строчку в старом талоне, табло в зале этого не заметит. Чтобы все увидели изменение, нужно выдать новый талон и повесить его на табло. Новая копия массива — это новый талон: он и сообщает React «смотри, тут что-то поменялось».

Запустите пример и нажмите кнопку. В консоли «Лук» есть, а на экране его нет:

push — мутация (так нельзя) · Ctrl + Enter
// Так НЕ работает: push меняет массив «на месте», а setItems получает ту же самую ссылку.
import { useState } from "react";

export default function App() {
  const [items, setItems] = useState(["Хлеб", "Молоко"]);

  function addItem() {
    items.push("Лук");
    setItems(items);
    console.log("в массиве:", items.length);
  }

  return (
    <div>
      <ul>
        {items.map((item, i) => <li key={i}>{item}</li>)}
      </ul>
      <button onClick={addItem}>Добавить «Лук»</button>
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

Правильно — создать новый массив и отдать его в сеттер:

Spread — новая копия · Ctrl + Enter
// Так работает: setItems получает НОВЫЙ массив
import { useState } from "react";

export default function App() {
  const [items, setItems] = useState(["Хлеб", "Молоко"]);

  function addItem() {
    setItems([...items, "Лук"]);
  }

  return (
    <div>
      <ul>
        {items.map((item, i) => <li key={i}>{item}</li>)}
      </ul>
      <button onClick={addItem}>Добавить «Лук»</button>
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

Таблица рецептов

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

Что сделатьЗаписьНельзя (мутация)
Добавить в конецsetItems([...items, newItem])items.push(newItem)
Добавить в началоsetItems([newItem, ...items])items.unshift(newItem)
Удалить по idsetItems(items.filter((x) => x.id !== id))items.splice(i, 1)
Изменить один элементsetItems(items.map((x) => x.id === id ? { ...x, done: true } : x))items[i].done = true
Изменить поле объектаsetUser({ ...user, age: 20 })user.age = 20

Обратите внимание на запись { ...x, done: true }: это копия объекта x, в которой поле done заменено. Остальные поля скопированы как есть. Эту «копию с заменой» вы видели на уроке 7 в разделе про spread. Тот же приём работает и для состояния-объекта:

Объект в состоянии · Ctrl + Enter
import { useState } from "react";

export default function App() {
  const [user, setUser] = useState({ name: "Мадина", age: 19, city: "Душанбе" });

  // {...user, age: ...} — копия объекта, в которой поменяли одно поле
  const birthday = () => setUser({ ...user, age: user.age + 1 });
  const move = () => setUser({ ...user, city: "Хуҷанд" });

  return (
    <div className="card">
      <p>{user.name}, {user.age} лет, {user.city}</p>
      <button onClick={birthday}>С днём рождения</button> <button onClick={move}>Переехать</button>
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ловушка

Spread делает поверхностную копию. Если внутри объекта лежит вложенный объект или массив (user.address.city), его нужно скопировать тоже: { ...user, address: { ...user.address, city: "Хуҷанд" } }. Поэтому в состоянии стараются хранить данные «плоскими».

Совет

Если сомневаетесь, не мутирую ли я состояние, посмотрите на метод. Мутируют: push, pop, shift, unshift, splice, sort, reverse. Не мутируют (возвращают новое): map, filter, slice, concat, spread. Для сортировки сначала сделайте копию: [...items].sort(...).

Читать подробнее: react.dev — Updating Arrays in State, react.dev — Updating Objects in State

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

ЗАДАЧА 5.1

Список покупок не растёт

лёгкая

Найди баг. По нажатию на «Добавить» в список покупок должен добавляться «Лук», но на экране ничего не меняется. Исправьте addItem. Нажимать кнопку нужно один раз — ключом служит само название.

Список покупок не растёт · Ctrl + Enter
// Кнопка «Добавить» ничего не делает. Исправьте обработчик addItem.
import { useState } from "react";

export default function App() {
  const [items, setItems] = useState(["Хлеб", "Молоко"]);

  function addItem() {
    items.push("Лук");
    setItems(items);
  }

  return (
    <div>
      <ul>
        {items.map((item) => <li key={item}>{item}</li>)}
      </ul>
      <button onClick={addItem}>Добавить</button>
    </div>
  );
}

// ---- проверка (не менять) ----
const wait = () => new Promise((r) => setTimeout(r, 60));
(async () => {
  await wait();
  console.log(document.querySelectorAll("li").length);
  document.querySelector("button").click();
  await wait();
  console.log(document.querySelectorAll("li").length);
})();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
2
3
Подсказка 1

Какой метод массива меняет его «на месте»? Он как раз используется в обработчике.

Подсказка 2

Замените две строки одной: setItems([...items, "Лук"]);

ЗАДАЧА 5.2

Профиль студента

средняя

Два задания. (1) «С днём рождения» должна увеличивать возраст на экране, сейчас она его «мутирует». (2) Допишите rename: имя меняется на «Зарина», возраст остаётся тем, что был.

Профиль студента · Ctrl + Enter
// 1) «С днём рождения» не меняет возраст на экране — исправьте.
// 2) Допишите rename: имя становится «Зарина», возраст остаётся прежним.
import { useState } from "react";

export default function App() {
  const [user, setUser] = useState({ name: "Мадина", age: 19 });

  function birthday() {
    user.age = user.age + 1;
    setUser(user);
  }

  function rename() {
    // ваш код
  }

  return (
    <div>
      <p>{user.name}, {user.age}</p>
      <button onClick={birthday}>С днём рождения</button> <button onClick={rename}>Сменить имя</button>
    </div>
  );
}

// ---- проверка (не менять) ----
const wait = () => new Promise((r) => setTimeout(r, 60));
async function click(el) {
  el.click();
  await wait();
}
(async () => {
  const p = () => document.querySelector("p").textContent;
  const b = () => document.querySelectorAll("button");
  await wait();
  console.log(p());
  await click(b()[0]);
  console.log(p());
  await click(b()[1]);
  console.log(p());
})();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
Мадина, 19
Мадина, 20
Зарина, 20
Подсказка 1

В setUser нужно передавать новый объект, а не тот же самый user.

Подсказка 2

setUser({ ...user, age: user.age + 1 }) и setUser({ ...user, name: "Зарина" }).

ЗАДАЧА 5.3

Удалить и отметить

сложная

Список дел уже нарисован. Допишите две функции: removeTask(id) удаляет задачу, toggleTask(id) переключает у задачи флаг done. Ни splice, ни присваивания полям объекта в состоянии использовать нельзя. Эти две операции — основа домашнего задания.

Удалить и отметить · Ctrl + Enter
// Допишите removeTask и toggleTask. Массив tasks менять «на месте» нельзя.
import { useState } from "react";

const start = [
  { id: 1, title: "Сделать зарядку", done: false },
  { id: 2, title: "Купить лепёшки", done: false },
  { id: 3, title: "Позвонить маме", done: false },
];

export default function App() {
  const [tasks, setTasks] = useState(start);

  function removeTask(id) {
    // уберите задачу с этим id
  }

  function toggleTask(id) {
    // переверните done у задачи с этим id, остальные не трогайте
  }

  return (
    <ul>
      {tasks.map((t) => (
        <li key={t.id}>
          <button className="toggle" onClick={() => toggleTask(t.id)}>{t.done ? "✓" : "○"}</button>{" "}
          <span>{t.title}</span>{" "}
          <button className="del" onClick={() => removeTask(t.id)}>✕</button>
        </li>
      ))}
    </ul>
  );
}

// ---- проверка (не менять) ----
const wait = () => new Promise((r) => setTimeout(r, 60));
async function click(el) {
  el.click();
  await wait();
}
(async () => {
  const $$ = (s) => [...document.querySelectorAll(s)];
  const show = () =>
    console.log($$("li").map((li) => li.querySelector(".toggle").textContent + " " + li.querySelector("span").textContent).join(" | "));
  await wait();
  show();
  await click($$(".del")[1]);
  await click($$(".toggle")[0]);
  show();
})();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
○ Сделать зарядку | ○ Купить лепёшки | ○ Позвонить маме
✓ Сделать зарядку | ○ Позвонить маме
Подсказка 1

Удаление — это «оставить всех, кроме одного»: метод filter с условием t.id !== id.

Подсказка 2

Переключение — map: для нужной задачи вернуть копию { ...t, done: !t.done }, для остальных — саму t. Не забудьте передать результат в setTasks.

06 Почему индекс в роли key ломает список

Теперь, когда у нас есть состояние и удаление, можно увидеть, зачем нужны «паспорта» элементов. Многие пишут key={index} — так проще, и сначала всё выглядит нормально. Проверим на опыте.

В каждой строке есть поле «заметка». Запустите пример с индексом, впишите в поле у Мадины «привет», потом нажмите «Удалить» напротив Мадины:

key = index (ломается) · Ctrl + Enter
// key = index. Напишите что-нибудь в поле «заметка» у Мадины и удалите Мадину.
import { useState } from "react";

export default function App() {
  const [people, setPeople] = useState([
    { id: 1, name: "Мадина" },
    { id: 2, name: "Фарход" },
    { id: 3, name: "Зарина" },
  ]);

  return (
    <ul>
      {people.map((p, index) => (
        <li key={index}>
          <b>{p.name}</b> <input placeholder="заметка" />{" "}
          <button onClick={() => setPeople(people.filter((x) => x.id !== p.id))}>Удалить</button>
        </li>
      ))}
    </ul>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

Мадина исчезла, но «привет» осталось — и теперь стоит у Фархода. Теперь то же самое с key={p.id}:

key = id (правильно) · Ctrl + Enter
// key = p.id. Повторите тот же опыт: заметка остаётся у своего человека.
import { useState } from "react";

export default function App() {
  const [people, setPeople] = useState([
    { id: 1, name: "Мадина" },
    { id: 2, name: "Фарход" },
    { id: 3, name: "Зарина" },
  ]);

  return (
    <ul>
      {people.map((p) => (
        <li key={p.id}>
          <b>{p.name}</b> <input placeholder="заметка" />{" "}
          <button onClick={() => setPeople(people.filter((x) => x.id !== p.id))}>Удалить</button>
        </li>
      ))}
    </ul>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

Здесь заметка уходит вместе с Мадиной, а остальные не затронуты.

Что произошло на самом деле

Было (key = index)Стало после удаления МадиныЧто решил React
key=0 → Мадина + «привет»key=0 → Фарход«Строка 0 осталась. Поменялось только имя» — поле с текстом сохраняется
key=1 → Фарходkey=1 → Зарина«Строка 1 осталась. Поменялось имя»
key=2 → Зарина— (нет)«Строка 2 исчезла» — удаляется последняя строка
С индексом React «удалил» последнюю строку и сдвинул данные вверх. Введённый текст остался в строке 0 — теперь она принадлежит Фарходу

С ключом id React видит: «ключ 1 пропал» — и убирает ровно ту строку (вместе с её полем ввода), а остальные оставляет как были.

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

Частая ошибка

Генерировать ключ «на лету» — key={Math.random()} или key={crypto.randomUUID()} внутри map. Тогда при каждой перерисовке все ключи новые, React считает, что все элементы заменены, и пересоздаёт их с нуля: поля теряют фокус и введённый текст. Идентификатор нужно создавать один раз — при создании данных (например, когда пользователь добавляет задачу), и хранить его вместе с данными.

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

ЗАДАЧА 6.1

Заметки уехали к соседу

средняя

Найди баг. У каждого человека в списке есть поле для заметки. Проверка пишет заметки всем троим и удаляет Мадину. После этого у Фархода должна остаться его заметка «про Фарход», а у Зарины — «про Зарину». Сейчас они перепутаны. Исправьте одну строку.

Заметки уехали к соседу · Ctrl + Enter
// Заметки «переезжают» к другому человеку после удаления. Исправьте одну строку.
import { useState } from "react";

export default function App() {
  const [people, setPeople] = useState([
    { id: 1, name: "Мадина" },
    { id: 2, name: "Фарход" },
    { id: 3, name: "Зарина" },
  ]);

  return (
    <ul>
      {people.map((p, index) => (
        <li key={index}>
          <b>{p.name}</b> <input placeholder="заметка" />{" "}
          <button onClick={() => setPeople(people.filter((x) => x.id !== p.id))}>Удалить</button>
        </li>
      ))}
    </ul>
  );
}

// ---- проверка (не менять) ----
const wait = () => new Promise((r) => setTimeout(r, 60));
(async () => {
  await wait();
  const notes = ["про Мадину", "про Фарход", "про Зарину"];
  document.querySelectorAll("input").forEach((input, i) => (input.value = notes[i]));
  document.querySelector("button").click(); // удаляем первого — Мадину
  await wait();
  document.querySelectorAll("li").forEach((li) => {
    console.log(li.querySelector("b").textContent + ": " + li.querySelector("input").value);
  });
})();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
Фарход: про Фарход
Зарина: про Зарину
Подсказка 1

Посмотрите на key у <li>. Что именно React использует, чтобы понять, какая строка пропала?

Подсказка 2

Замените key={index} на key={p.id}. Переменная index в скобках map после этого не нужна.

07 События и управляемые поля ввода

Состояние нужно, чтобы на что-то реагировать. А реагировать мы будем на события: клики, ввод текста, отправку формы. В React они подключаются как props с именем on… в стиле camelCase: onClick, onChange, onSubmit. В отличие от HTML (onclick="..."), значением служит не строка, а сама функция.

onClick и объект события · Ctrl + Enter
import { useState } from "react";

export default function App() {
  const [log, setLog] = useState("пока ничего");

  // React передаёт обработчику объект события
  function handleClick(event) {
    setLog("клик по кнопке «" + event.target.textContent + "»");
  }

  return (
    <div>
      <button onClick={handleClick}>Первая</button>{" "}
      <button onClick={handleClick}>Вторая</button>{" "}
      <button onClick={() => setLog("привет из стрелочной функции")}>Стрелка</button>
      <p>{log}</p>
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
СобытиеКогда срабатываетТипичное использование
onClickКлик по элементуКнопки, карточки, пункты меню
onChangeИзменилось значение поля — на каждый символПоля ввода, чекбоксы, select
onSubmitОтправка формы (Enter или кнопка)Добавление в список, вход, поиск
onKeyDownНажата клавишаEnter в поле, Escape для закрытия окна

Передаём функцию, а не вызываем её

Самая частая ошибка с событиями: написать onClick={sayHi()} со скобками. Скобки вызывают функцию прямо во время отрисовки, и в onClick попадает не функция, а то, что она вернула. Запустите пример: «Салом!» появляется в консоли без единого клика.

sayHi() и sayHi · Ctrl + Enter
// Запустите и посмотрите в консоль: «Салом!» уже там, хотя мы ни разу не нажали кнопку.
function sayHi() {
  console.log("Салом!");
}

export default function App() {
  return (
    <div>
      <button onClick={sayHi()}>Неправильно: sayHi()</button>{" "}
      <button onClick={sayHi}>Правильно: sayHi</button>
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

Три правильных способа:

Как подключать обработчик
<button onClick={sayHi}>Без параметров</button>
<button onClick={() => remove(item.id)}>С параметром: оборачиваем в стрелку</button>
<button onClick={() => setCount(count + 1)}>Прямо в стрелке</button>

Если обработчику нужно передать аргумент (item.id), оборачивайте вызов в стрелочную функцию. Тогда вызов произойдёт не сейчас, а когда пользователь нажмёт.

Управляемое поле (controlled input)

В обычном HTML значением поля владеет браузер. В React принято иначе: значение живёт в состоянии, а поле только показывает его и сообщает об изменениях. Такое поле называется управляемым. Для этого нужны два атрибута:

  • value={name} — что показывать;
  • onChange={(e) => setName(e.target.value)} — как обновлять состояние, когда пользователь печатает. Новый текст лежит в e.target.value (у чекбокса — e.target.checked).
Управляемые поля: input, checkbox, select · Ctrl + Enter
import { useState } from "react";

export default function App() {
  const [name, setName] = useState("");
  const [student, setStudent] = useState(false);
  const [city, setCity] = useState("Душанбе");

  return (
    <div>
      <p>
        <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Ваше имя" />
      </p>
      <p>
        <label>
          <input type="checkbox" checked={student} onChange={(e) => setStudent(e.target.checked)} /> Я студент
        </label>
      </p>
      <p>
        <select value={city} onChange={(e) => setCity(e.target.value)}>
          <option>Душанбе</option>
          <option>Хуҷанд</option>
          <option>Бохтар</option>
        </select>
      </p>
      <p className="card">
        {name || "Аноним"} из города {city}{student ? ", студент" : ""}
      </p>
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Аналогия из жизни

Диктор и телесуфлёр. Вы печатаете слово, оно уходит в «центр» (состояние), и уже оттуда возвращается на экран в поле. Поле — это просто окошко, в котором показано значение из состояния. Пока состояние не обновилось, ничего нового в окошке не появится. Поэтому если написать value без onChange, поле «замёрзнет»: напечатать в нём ничего нельзя.

Формы: onSubmit и preventDefault

Форма по умолчанию при отправке перезагружает страницу. Состояние при этом сбрасывается — это ровно то, чего мы не хотим. Поэтому в начале обработчика onSubmit вызывают event.preventDefault() — знакомый вам приём из урока про формы.

Форма и onSubmit · Ctrl + Enter
import { useState } from "react";

export default function App() {
  const [text, setText] = useState("");
  const [sent, setSent] = useState("");

  function handleSubmit(event) {
    event.preventDefault(); // не даём браузеру перезагрузить страницу
    setSent(text);
    setText("");
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Ваше сообщение" />{" "}
      <button>Отправить</button>
      <p>Отправлено: {sent || "—"}</p>
    </form>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Частая ошибка

Повесить onClick на кнопку формы вместо onSubmit на саму форму. Тогда Enter в поле не отправит форму. Правило: действие «отправить» — на <form onSubmit>, а кнопка внутри формы остаётся обычной.

Совет

Если в состоянии лежит текст из поля, не забывайте про .trim(): пользователь легко может отправить строку из одних пробелов. Проверка if (text.trim() === "") return; — привычка, которая спасёт от пустых задач в списке.

Читать подробнее: react.dev — Responding to Events, react.dev — <input>

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

ЗАДАЧА 7.1

Приветствие по мере ввода

лёгкая

Сделайте поле управляемым: пока имя не введено, показывается «Привет, гость!», по мере набора — «Привет, Имя!».

Приветствие по мере ввода · Ctrl + Enter
// Сделайте поле управляемым: приветствие должно меняться по мере ввода имени.
export default function App() {
  const name = "";

  return (
    <div>
      <input placeholder="Ваше имя" />
      <p>Привет, {name || "гость"}!</p>
    </div>
  );
}

// ---- проверка (не менять) ----
const wait = () => new Promise((r) => setTimeout(r, 60));
function typeInto(el, value) {
  Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value").set.call(el, value);
  el.dispatchEvent(new Event("input", { bubbles: true }));
}
(async () => {
  await wait();
  console.log(document.querySelector("p").textContent);
  typeInto(document.querySelector("input"), "Мадина");
  await wait();
  console.log(document.querySelector("p").textContent);
})();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
Привет, гость!
Привет, Мадина!
Подсказка 1

Нужны состояние name с начальным значением "" и два атрибута у поля: value и onChange.

Подсказка 2

<input value={name} onChange={(e) => setName(e.target.value)} />

ЗАДАЧА 7.2

Пост с лимитом символов

средняя

Сделайте поле поста с лимитом 20 символов: в <small> показывайте «длина/20», а кнопку «Опубликовать» отключайте атрибутом disabled, если текст пустой или длиннее лимита.

Пост с лимитом символов · Ctrl + Enter
// Поле для поста с лимитом 20 символов.
// 1) Храните текст в состоянии (управляемое поле).
// 2) В <small> показывайте «длина/20».
// 3) Кнопка «Опубликовать» выключена (disabled), если текст пустой или длиннее 20 символов.
export default function App() {
  return (
    <div>
      <textarea placeholder="Что нового?" />
      <p><small>0/20</small></p>
      <button>Опубликовать</button>
    </div>
  );
}

// ---- проверка (не менять) ----
const wait = () => new Promise((r) => setTimeout(r, 60));
function typeInto(el, value) {
  Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value").set.call(el, value);
  el.dispatchEvent(new Event("input", { bubbles: true }));
}
const state = () => document.querySelector("small").textContent + " " + document.querySelector("button").disabled;
(async () => {
  await wait();
  console.log(state());
  typeInto(document.querySelector("textarea"), "Салом");
  await wait();
  console.log(state());
  typeInto(document.querySelector("textarea"), "я".repeat(25));
  await wait();
  console.log(state());
})();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
0/20 true
5/20 false
25/20 true
Подсказка 1

Длину не нужно хранить отдельно: она всегда равна text.length. В состоянии — только text.

Подсказка 2

<button disabled={text.length === 0 || text.length > 20}> — атрибут принимает булево значение.

ЗАДАЧА 7.3

Форма добавления покупки

средняя

Допишите handleSubmit: отмените перезагрузку страницы, не добавляйте пустые строки и строки из одних пробелов, добавьте в список объект { id: Date.now(), text } и очистите поле. Идентификатор создаётся один раз — при добавлении, это и будет ключ.

Форма добавления покупки · Ctrl + Enter
// Допишите handleSubmit: отмените перезагрузку, не добавляйте пустые строки (и строки из одних пробелов),
// добавьте {id, text} в список и очистите поле.
import { useState } from "react";

export default function App() {
  const [items, setItems] = useState([]);
  const [text, setText] = useState("");

  function handleSubmit(event) {
    // ваш код
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Что купить?" />{" "}
      <button>Добавить</button>
      <ul>
        {items.map((item) => <li key={item.id}>{item.text}</li>)}
      </ul>
    </form>
  );
}

// ---- проверка (не менять) ----
const wait = () => new Promise((r) => setTimeout(r, 60));
function typeInto(el, value) {
  Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value").set.call(el, value);
  el.dispatchEvent(new Event("input", { bubbles: true }));
}
async function submitWith(value) {
  typeInto(document.querySelector("input"), value);
  await wait();
  document.querySelector("form").requestSubmit();
  await wait();
}
(async () => {
  await wait();
  await submitWith("Лепёшки");
  await submitWith("   ");
  await submitWith("Чой");
  console.log([...document.querySelectorAll("li")].map((li) => li.textContent).join(" | "));
  console.log("поле: [" + document.querySelector("input").value + "]");
})();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
Лепёшки | Чой
поле: []
Подсказка 1

Порядок действий: event.preventDefault() → const clean = text.trim() → если пусто, return → добавить → очистить поле через setText("").

Подсказка 2

Добавление в конец без мутации: setItems([...items, { id: Date.now(), text: clean }]);

ЗАДАЧА 7.4

Живой поиск городов

сложная

Список городов нужно фильтровать на лету по тексту из поля, без учёта регистра. Если ничего не найдено — показать <p>Ничего не найдено</p>. Состояние только одно — query; отфильтрованный список вычисляйте при каждой отрисовке.

Живой поиск городов · Ctrl + Enter
// Живой поиск: показывайте только города, в названии которых есть введённый текст (без учёта регистра).
// Если ничего не найдено — выведите <p>Ничего не найдено</p>.
import { useState } from "react";

const cities = ["Душанбе", "Худжанд", "Бохтар", "Куляб", "Истаравшан"];

export default function App() {
  const [query, setQuery] = useState("");

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Поиск города" />
      <ul>
        {cities.map((city) => <li key={city}>{city}</li>)}
      </ul>
    </div>
  );
}

// ---- проверка (не менять) ----
const wait = () => new Promise((r) => setTimeout(r, 60));
function typeInto(el, value) {
  Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value").set.call(el, value);
  el.dispatchEvent(new Event("input", { bubbles: true }));
}
(async () => {
  await wait();
  for (const q of ["ду", "УД", "xyz"]) {
    typeInto(document.querySelector("input"), q);
    await wait();
    const names = [...document.querySelectorAll("li")].map((li) => li.textContent).join(", ");
    console.log(q + ": " + (names || document.querySelector("p").textContent));
  }
})();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
ду: Душанбе
УД: Худжанд
xyz: Ничего не найдено
Подсказка 1

Перед return вычислите const found = cities.filter(...) и выводите found вместо cities.

Подсказка 2

Условие: city.toLowerCase().includes(query.trim().toLowerCase()). Сообщение: {found.length === 0 && <p>Ничего не найдено</p>}.

08 Условный рендер: показать или спрятать

Интерфейс постоянно «ветвится»: у гостя одна кнопка, у вошедшего другая; пока данные грузятся — крутилка, потом список; корзина пустая — подсказка, не пустая — товары. В React для этого нет специальных тегов вроде v-if. Вы используете обычный JavaScript — условия и операторы, которые уже знаете. Есть три приёма.

1. Оператор &&: «показать или ничего»

Если нужно показать кусок интерфейса или не показывать совсем, пишут условие && <JSX />. Если условие ложно, получается false, а React ничего не рисует на месте false, null и undefined.

&& — бейдж с числом · Ctrl + Enter
import { useState } from "react";

export default function App() {
  const [unread, setUnread] = useState(3);

  return (
    <div className="card">
      <b>Сообщения</b>
      {/* && : «если левая часть истинна — покажи правую» */}
      {unread > 0 && <span style={{ color: "crimson" }}> ({unread} новых)</span>}
      <p>
        <button onClick={() => setUnread(unread + 1)}>Пришло сообщение</button>{" "}
        <button onClick={() => setUnread(0)}>Прочитать всё</button>
      </p>
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

2. Тернарный оператор: «одно или другое»

Когда нужно выбрать из двух вариантов, используют условие ? А : Б. Тернарный оператор вы изучали на уроке про условия.

Тернарный оператор — вошёл / не вошёл · Ctrl + Enter
import { useState } from "react";

export default function App() {
  const [loggedIn, setLoggedIn] = useState(false);

  return (
    <div className="card">
      {/* тернарный оператор: «или одно, или другое» */}
      {loggedIn ? <p>Привет, Мадина! Ваши курсы ждут.</p> : <p>Войдите, чтобы увидеть курсы.</p>}
      <button onClick={() => setLoggedIn(!loggedIn)}>{loggedIn ? "Выйти" : "Войти"}</button>
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

3. Ранний return: «особый случай — и сразу выход»

Если компонент в особом случае выглядит совсем иначе (загрузка, пусто, ошибка), удобно обработать его в начале функции и сразу сделать return. Основной вариант остаётся ниже, без вложенных условий.

Ранний return: загрузка и «пусто» · Ctrl + Enter
import { useState } from "react";

function Courses({ loading, courses }) {
  // ранний return: сначала обрабатываем «особые» случаи
  if (loading) return <p>Загрузка…</p>;
  if (courses.length === 0) return <p>Курсов пока нет</p>;

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

export default function App() {
  const [loading, setLoading] = useState(true);
  const [courses, setCourses] = useState([]);

  return (
    <div className="card">
      <Courses loading={loading} courses={courses} />
      <button onClick={() => setLoading(!loading)}>Загрузка вкл/выкл</button>{" "}
      <button onClick={() => setCourses(["HTML и CSS", "JavaScript"])}>Добавить курсы</button>
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
ПриёмКогда использоватьПример
&&Есть что показать — или ничего{isNew && <Badge />}
? :Два варианта на одном месте{loggedIn ? <Menu /> : <LoginButton />}
Ранний returnОсобый случай меняет весь компонентif (loading) return <Spinner />;
Аналогия из жизни

Вывеска на магазине: «Открыто» или «Закрыто». Это тернарный оператор: всегда висит одна из двух табличек. А вот табличка «Скидка!» висит только если скидка есть — это &&. Если скидки нет, нет и таблички, а не табличка «Скидки нет».

Ловушка: «0 && …» рисует ноль

Оператор && возвращает левую часть, если она ложная. false, null, undefined React не показывает, но число 0 показывает как 0. Поэтому {items.length && <List />} при пустом списке выведет на странице цифру 0. Сравнивайте явно: {items.length > 0 && ...}.

0 && ... — ловушка · Ctrl + Enter
// Ловушка: 0 && ... возвращает 0, а React выводит число 0 на экран
import { useState } from "react";

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

  return (
    <div className="card">
      <p>Неправильно: [{count && <b>есть товары</b>}]</p>
      <p>Правильно: [{count > 0 && <b>есть товары</b>}]</p>
      <button onClick={() => setCount(count + 1)}>Добавить товар ({count})</button>
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Совет

Если условий становится много, не нагромождайте вложенные тернарные операторы — читать их невозможно. Вынесите логику в переменную перед return или в отдельный компонент с ранним return.

Читать подробнее: react.dev — Conditional Rendering, learn.javascript.ru — Логические операторы

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

ЗАДАЧА 8.1

Бейдж «Новинка»

лёгкая

В компонент Dish нужно добавить бейдж <span>Новинка</span> рядом с названием, но только у тех блюд, у которых isNew истинно. Запись <Dish name="Манту" isNew /> без значения означает isNew={true}.

Бейдж «Новинка» · Ctrl + Enter
// Покажите бейдж «Новинка» (<span>) только у блюд с isNew.
function Dish({ name, isNew }) {
  return <div className="card">{name}</div>;
}

export default function App() {
  return (
    <div>
      <Dish name="Манту" isNew />
      <Dish name="Плов" />
      <Dish name="Лагман" isNew={false} />
    </div>
  );
}

// ---- проверка (не менять) ----
setTimeout(() => {
  document.querySelectorAll(".card").forEach((c) => console.log(c.textContent.trim()));
}, 100);
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
Манту Новинка
Плов
Лагман
Подсказка 1

Оператор && пишется в фигурных скобках внутри JSX, после названия блюда.

Подсказка 2

{name} {isNew && <span>Новинка</span>}

ЗАДАЧА 8.2

Пустая корзина

средняя

Найди баг. В пустой корзине вместо подсказки почему-то цифра 0. Исправьте: пока корзина пуста, показывайте <p>Корзина пуста</p>, иначе — список.

Пустая корзина · Ctrl + Enter
// Пока корзина пуста, на экране появляется странный «0». Нужно:
// пустая корзина -> текст «Корзина пуста»; иначе -> список товаров.
import { useState } from "react";

export default function App() {
  const [cart, setCart] = useState([]);

  function addItem() {
    setCart([...cart, { id: cart.length + 1, title: "Лепёшка" }]);
  }

  return (
    <div>
      <button onClick={addItem}>Добавить лепёшку</button>
      <div id="cart">
        {cart.length && (
          <ul>
            {cart.map((item) => <li key={item.id}>{item.title}</li>)}
          </ul>
        )}
      </div>
    </div>
  );
}

// ---- проверка (не менять) ----
const wait = () => new Promise((r) => setTimeout(r, 60));
(async () => {
  await wait();
  console.log(document.querySelector("#cart").textContent);
  document.querySelector("button").click();
  await wait();
  console.log(document.querySelector("#cart").textContent);
})();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
Корзина пуста
Лепёшка
Подсказка 1

Откуда берётся 0? Вспомните ловушку из теории: что возвращает 0 && ...?

Подсказка 2

Замените && на тернарный оператор: {cart.length === 0 ? <p>Корзина пуста</p> : <ul>...</ul>}

ЗАДАЧА 8.3

Профиль без пользователя

средняя

Пока пользователь не вошёл, user равен null, и Profile падает с ошибкой на user.name. Добавьте в начало Profile ранний return с текстом «Войдите в аккаунт».

Профиль без пользователя · Ctrl + Enter
// Profile падает, пока user === null. Добавьте ранний return:
// если пользователя нет — вернуть <p>Войдите в аккаунт</p>.
import { useState } from "react";

function Profile({ user }) {
  return <p>Привет, {user.name}!</p>;
}

export default function App() {
  const [user, setUser] = useState(null);

  return (
    <div>
      <Profile user={user} />
      <button onClick={() => setUser(user ? null : { name: "Мадина" })}>{user ? "Выйти" : "Войти"}</button>
    </div>
  );
}

// ---- проверка (не менять) ----
const wait = () => new Promise((r) => setTimeout(r, 60));
(async () => {
  const show = () => console.log(document.querySelector("p").textContent);
  await wait();
  show();
  document.querySelector("button").click();
  await wait();
  show();
  document.querySelector("button").click();
  await wait();
  show();
})();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
Войдите в аккаунт
Привет, Мадина!
Войдите в аккаунт
Подсказка 1

Условие для раннего выхода — «пользователя нет»: if (!user).

Подсказка 2

if (!user) return <p>Войдите в аккаунт</p>; — строкой выше основного return.

ЗАДАЧА 8.4

FAQ-аккордеон

сложная

Сделайте раскрывающиеся вопросы: клик по вопросу показывает ответ (<p className="answer">) и закрывает остальные, повторный клик по тому же вопросу закрывает ответ. Подумайте: что достаточно хранить в состоянии, чтобы одновременно мог быть открыт не больше чем один вопрос?

FAQ-аккордеон · Ctrl + Enter
// FAQ-аккордеон. Клик по вопросу открывает его ответ и закрывает остальные; повторный клик закрывает.
// Ответ — <p className="answer">. Подумайте: что хранить в состоянии, чтобы был открыт не больше одного?
import { useState } from "react";

const faq = [
  { id: 1, q: "Сколько стоит курс?", a: "Ответ про цену" },
  { id: 2, q: "Нужен ли свой ноутбук?", a: "Ответ про ноутбук" },
  { id: 3, q: "Будет ли сертификат?", a: "Ответ про сертификат" },
];

export default function App() {
  return (
    <div>
      {faq.map((item) => (
        <div key={item.id} className="card">
          <button>{item.q}</button>
          {/* здесь должен быть ответ */}
        </div>
      ))}
    </div>
  );
}

// ---- проверка (не менять) ----
const wait = () => new Promise((r) => setTimeout(r, 60));
async function click(el) {
  el.click();
  await wait();
}
(async () => {
  const shown = () => [...document.querySelectorAll(".answer")].map((a) => a.textContent).join(", ") || "закрыто";
  const q = (i) => document.querySelectorAll(".card button")[i];
  await wait();
  console.log(shown());
  await click(q(0));
  console.log(shown());
  await click(q(1));
  console.log(shown());
  await click(q(1));
  console.log(shown());
})();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
закрыто
Ответ про цену
Ответ про ноутбук
закрыто
Подсказка 1

Достаточно одного значения — id открытого вопроса (или null, если все закрыты): const [openId, setOpenId] = useState(null);

Подсказка 2

По клику: setOpenId(openId === item.id ? null : item.id). Ответ показывайте так: {openId === item.id && <p className="answer">{item.a}</p>}.

09 ИИ-блок: ИИ объясняет ошибки React — а мы проверяем

Две ошибки React новички видят чаще всего: «я нажимаю, а экран не меняется» и «список ведёт себя странно после удаления». ИИ объясняет их быстро и убедительно. Но убедительно — не значит верно. Ваша задача сегодня — взять объяснение ИИ и проверить его на собственном коде: запустить, поломать, починить и убедиться, что причина названа правильно.

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

1. Сначала 15 минут пробуете сами: вспомните разделы 04–06 этого урока. 2. Только потом идёте к ИИ. 3. Не вставляйте в проект код, который не можете объяснить строчка за строчкой — для React это значит: вы знаете, почему здесь новый массив, а не push, и откуда берётся каждый key. 4. Проверяйте ответ ИИ запуском: объяснение, которое нельзя проверить на коде, ничего не стоит.

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

Формула промпта курса: контекст → задача → ограничения → формат ответа → проверка. Два готовых промпта из библиотеки курса — для «состояние не обновляется» и «зачем нужен key» — уже построены по ней.

у меня в реакте не работает кнопка, почини
Я учу React на уровне новичка: знаю props, useState и .map(). Вот мой компонент: [вставьте код]. При нажатии на «Добавить» новый элемент не появляется на экране, хотя в консоли он есть в массиве. Объясни, почему useState не сработал и как исправить. Ограничения: без сторонних библиотек, без useEffect, поменяй как можно меньше строк. Формат ответа: 1) причина в двух предложениях простыми словами; 2) исправленный код целиком; 3) что именно изменилось. Проверка: в конце скажи, не мутирую ли я состояние напрямую.
зачем в реакте key
Объясни на моём примере, зачем React нужен key в .map() и что сломается без него: [вставьте код списка]. Я новичок. Формат: короткое объяснение с аналогией, затем покажи, как ломается список, если использовать index в качестве key, и как это исправить. Проверка: предложи, как мне самому убедиться в баге на удалении элемента из середины списка (какие поля ввода и какие действия нужны).
Чек-лист: как проверить объяснение ИИ

1) Состояние не обновляется: нашёл ли ИИ мутацию (push, obj.x = …, sort без копии)? Если он предложил «просто добавить useEffect» или «принудительно перерисовать» — это почти всегда лишнее. 2) Ключи: какой key он выбрал? Если index «чтобы убрать предупреждение» — это тот самый баг из раздела 06. Ключ должен приходить из данных. 3) Проверка запуском: воспроизведите баг на удалении из середины — с полем ввода в каждой строке — до и после исправления. 4) Можете ли вы своими словами объяснить, почему исправление работает?

Хороший ИИ-помощник объясняет, а не только чинит. Если в ответе вы не поняли хоть одно слово, спросите: «объясни проще, на примере чайханы». И не стесняйтесь спорить: если ИИ предложил key={index}, возразите ему примером из раздела 06 и посмотрите, как он выкрутится.

Практика: проверяем ИИ

ЗАДАЧА 9.1

Список дел «от ИИ»

сложная

Этот код выдал ИИ по запросу «сделай список дел на React». Он запускается, но в нём три ошибки: кнопка «Добавить» не обновляет экран, после удаления из середины заметки уезжают к соседям, а после «удалить → добавить» удаление последней строки сносит сразу две. Найдите и исправьте все три. Последняя ошибка коварная: задумайтесь, откуда берётся id новой задачи и может ли он повториться.

Список дел «от ИИ» · Ctrl + Enter
// Этот список дел «написал ИИ». Он запускается, но работает неправильно. Найдите ВСЕ ошибки и исправьте.
// Признаки: после добавления список не обновляется; после удаления заметки уезжают к соседям;
// после «удалить, потом добавить» удаление сносит сразу две строки.
import { useState } from "react";

export default function App() {
  const [todos, setTodos] = useState([
    { id: 1, text: "Позвонить маме" },
    { id: 2, text: "Купить лепёшки" },
  ]);

  function addTodo() {
    todos.push({ id: todos.length + 1, text: "Новая задача" });
    setTodos(todos);
  }

  return (
    <div>
      <ul>
        {todos.map((t, i) => (
          <li key={i}>
            <b>{t.text}</b> <input placeholder="заметка" />{" "}
            <button className="del" onClick={() => setTodos(todos.filter((x) => x.id !== t.id))}>✕</button>
          </li>
        ))}
      </ul>
      <button className="add" onClick={addTodo}>Добавить</button>
    </div>
  );
}

// ---- проверка (не менять) ----
const wait = () => new Promise((r) => setTimeout(r, 60));
async function click(el) {
  el.click();
  await wait();
}
(async () => {
  const $ = (s) => document.querySelector(s);
  const $$ = (s) => [...document.querySelectorAll(s)];
  const count = () => console.log($$("li").length);
  await wait();
  await click($(".add"));
  count();
  $$("input").forEach((input, i) => (input.value = ["a", "b", "c"][i]));
  await click($$(".del")[0]);
  console.log($$("li").map((li) => li.querySelector("b").textContent + ": " + li.querySelector("input").value).join(" | "));
  await click($(".add"));
  count();
  await click($$(".del")[2]);
  count();
})();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
3
Купить лепёшки: b | Новая задача: c
3
2
Подсказка 1

Три места: push + setTodos(todos) (мутация), key={i} (индекс вместо паспорта) и id: todos.length + 1 (после удаления длина уменьшается, и новый id совпадает с существующим).

Подсказка 2

Добавление: setTodos([...todos, { id: Date.now(), text: "Новая задача" }]). Ключ: key={t.id}. Идентификатор создаётся один раз — в момент добавления.

ЗАДАЧА 9.2

Свой промпт про key

лёгкая

Возьмите свой код из задачи 6.1 или 9.1 и составьте для ИИ промпт по формуле курса. Отправьте, получите ответ и прогоните его по чек-листу выше. Запишите в комментарии: какой key предложил ИИ, пришлось ли вам с ним спорить, и как вы убедились, что объяснение верное. Автоматической проверки нет — отметьте задачу вручную, когда будет готово.

Можно редактировать · Ctrl + Enter
// Мой промпт:
// ...

// Какой key предложил ИИ и что я проверил:
// ...
Подсказка 1

Если ответ ИИ вас не убедил, не просите «исправь ещё раз», а назовите конкретный провал: «после удаления первой строки заметка осталась у второй».

Подсказка 2

Сверьте ответ с разделом 06 этого урока. Если ИИ утверждает что-то, чего там нет, — перепроверьте на коде.

10 Практика на уроке: каталог курсов с лайками

Соберём всё вместе: каталог курсов MakeFirstStep. Карточки рисуются из массива через props, у каждой есть кнопка «лайк», которая запоминает своё состояние. Идём шагами — после каждого шага код работает. Каждый шаг можно запускать и менять прямо здесь.

Шаг 1. Компонент и props

Сначала один компонент CourseCard и три вызова с разными данными.

Шаг 1. CourseCard и props · Ctrl + Enter
// Шаг 1. Компонент CourseCard и три карточки с разными props
function CourseCard({ title, price }) {
  return (
    <div className="card">
      <b>{title}</b>
      <div>{price} сомони в месяц</div>
    </div>
  );
}

export default function App() {
  return (
    <div>
      <CourseCard title="HTML и CSS" price={300} />
      <CourseCard title="JavaScript" price={400} />
      <CourseCard title="React Lite" price={450} />
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

Шаг 2. Данные в массив и map

Три почти одинаковых строки — повод вынести данные в массив. Добавляем id для ключей и выводим карточки через map.

Шаг 2. Массив и map с key · Ctrl + Enter
// Шаг 2. Данные — в массив, карточки — через map с key
const courses = [
  { id: 1, title: "HTML и CSS", price: 300 },
  { id: 2, title: "JavaScript", price: 400 },
  { id: 3, title: "React Lite", price: 450 },
];

function CourseCard({ title, price }) {
  return (
    <div className="card">
      <b>{title}</b>
      <div>{price} сомони в месяц</div>
    </div>
  );
}

export default function App() {
  return (
    <div>
      {courses.map((course) => (
        <CourseCard key={course.id} title={course.title} price={course.price} />
      ))}
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

Шаг 3. Лайк на useState

В каждую карточку добавляем собственное состояние liked. Число лайков не храним — вычисляем: likes + (liked ? 1 : 0). Нажмите на несколько сердечек: карточки не мешают друг другу.

Шаг 3. Лайк в каждой карточке · Ctrl + Enter
// Шаг 3. Лайк: у каждой карточки своё состояние liked
import { useState } from "react";

const courses = [
  { id: 1, title: "HTML и CSS", likes: 10 },
  { id: 2, title: "JavaScript", likes: 25 },
  { id: 3, title: "React Lite", likes: 7 },
];

function CourseCard({ title, likes }) {
  const [liked, setLiked] = useState(false);
  const count = likes + (liked ? 1 : 0);

  return (
    <div className="card">
      <b>{title}</b>{" "}
      <button onClick={() => setLiked(!liked)}>{liked ? "♥" : "♡"} {count}</button>
    </div>
  );
}

export default function App() {
  return (
    <div>
      {courses.map((course) => (
        <CourseCard key={course.id} title={course.title} likes={course.likes} />
      ))}
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

Шаг 4 (бонус). Общий счётчик: поднимаем состояние

А если нужен счётчик «понравилось N из M» над списком? Карточки не могут рассказать об этом соседям, а App не может заглянуть в состояние карточки. Решение — поднять состояние вверх: App хранит, какие курсы лайкнуты, а карточка получает liked и функцию onToggle через props. Да, props могут быть и функциями: так ребёнок «сообщает» родителю о событии.

Шаг 4 (бонус). Общий счётчик · Ctrl + Enter
// Шаг 4 (бонус). Поднимаем состояние: App знает, какие курсы лайкнуты, и считает общий счётчик
import { useState } from "react";

const courses = [
  { id: 1, title: "HTML и CSS" },
  { id: 2, title: "JavaScript" },
  { id: 3, title: "React Lite" },
];

// onToggle — это prop-ФУНКЦИЯ: карточка вызывает её, когда нажали на сердечко
function CourseCard({ title, liked, onToggle }) {
  return (
    <div className="card">
      <b>{title}</b>{" "}
      <button onClick={onToggle}>{liked ? "♥ Нравится" : "♡ Лайк"}</button>
    </div>
  );
}

export default function App() {
  const [likedIds, setLikedIds] = useState([]);

  function toggle(id) {
    setLikedIds(likedIds.includes(id) ? likedIds.filter((x) => x !== id) : [...likedIds, id]);
  }

  return (
    <div>
      <h3>Понравилось: {likedIds.length} из {courses.length}</h3>
      {courses.map((course) => (
        <CourseCard
          key={course.id}
          title={course.title}
          liked={likedIds.includes(course.id)}
          onToggle={() => toggle(course.id)}
        />
      ))}
    </div>
  );
}
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Аналогия из жизни

Староста группы. Каждый студент может сказать «я пришёл», но общий список посещаемости ведёт староста — один человек, у которого он есть целиком. Студент (карточка) не хранит у себя общую ведомость, а сообщает старосте (App) через функцию onToggle. А староста объявляет итог: «пришло 3 из 5».

Практика: собираем каталог

ЗАДАЧА 10.1

Каталог с лайками

средняя

Сейчас на экране одна заглушка. Соберите каталог по шагам 1–3: CourseCard получает props title и likes, App выводит все курсы через map, а у каждой карточки своё состояние liked: клик — ♥ и число на 1 больше, повторный клик — всё обратно.

Каталог с лайками · Ctrl + Enter
// Каталог курсов. Сейчас на экране одна заглушка. Соберите всё, что мы разобрали:
// 1) CourseCard получает props title и likes и показывает название и кнопку «♡ число»;
// 2) App выводит все курсы из массива через map с key;
// 3) у каждой карточки своё состояние liked: клик → «♥ число+1», повторный клик → обратно «♡ число».
const courses = [
  { id: 1, title: "HTML и CSS", likes: 10 },
  { id: 2, title: "JavaScript", likes: 25 },
  { id: 3, title: "React Lite", likes: 7 },
];

function CourseCard() {
  return (
    <div className="card">
      <b>Курс</b> <button className="like">♡ 0</button>
    </div>
  );
}

export default function App() {
  return (
    <div>
      <CourseCard />
    </div>
  );
}

// ---- проверка (не менять) ----
const wait = () => new Promise((r) => setTimeout(r, 60));
async function click(el) {
  el.click();
  await wait();
}
(async () => {
  const cards = () => [...document.querySelectorAll(".card")];
  const show = () =>
    console.log(cards().map((c) => c.querySelector("b").textContent + " " + c.querySelector(".like").textContent).join(" | "));
  await wait();
  show();
  await click(cards()[0].querySelector(".like"));
  await click(cards()[2].querySelector(".like"));
  show();
  await click(cards()[0].querySelector(".like"));
  show();
})();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
HTML и CSS ♡ 10 | JavaScript ♡ 25 | React Lite ♡ 7
HTML и CSS ♥ 11 | JavaScript ♡ 25 | React Lite ♥ 8
HTML и CSS ♡ 10 | JavaScript ♡ 25 | React Lite ♥ 8
Подсказка 1

Порядок работы: сначала props и map (проверьте, что видны три карточки), и только потом добавляйте useState в CourseCard.

Подсказка 2

В CourseCard: const [liked, setLiked] = useState(false);, на кнопке onClick={() => setLiked(!liked)} и {liked ? "♥" : "♡"} {likes + (liked ? 1 : 0)}. Не забудьте className="like" у кнопки и импорт useState.

11 Домашнее задание: Todo-лист на React

Todo-лист на React: добавить задачу, отметить выполненной, удалить. Артефакт — интерактивный компонент на useState + список через .map(). Основные приёмы вы уже потренировали в задачах 5.3 и 7.3 — теперь соберите их в одно приложение. Ниже стартовый код с «самопроверкой»: запустите его — в консоли появятся строки со знаками ✗, ваша цель — все ✓.

Todo-лист: стартовый код и самопроверка · Ctrl + Enter
// Todo-лист. Допишите состояние и три действия: добавить, отметить, удалить.
// Блок «самопроверка» внизу сам нажимает кнопки и пишет в консоль ✓ или ✗ — добейтесь, чтобы все строки были с ✓.
import { useState } from "react";

export default function App() {
  // состояние: массив задач { id, text, done } и текст из поля

  function handleSubmit(event) {
    event.preventDefault();
    // добавить задачу
  }

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input placeholder="Новая задача" />{" "}
        <button>Добавить</button>
      </form>
      <p id="left">Осталось: 0</p>
      <ul>
        {/* для каждой задачи: <li> с чекбоксом, <span> с текстом (класс done у выполненной) и кнопкой ✕ (className="del") */}
      </ul>
    </div>
  );
}

// ---- самопроверка (не менять) ----
const wait = () => new Promise((r) => setTimeout(r, 60));
function typeInto(el, value) {
  Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value").set.call(el, value);
  el.dispatchEvent(new Event("input", { bubbles: true }));
}
async function add(text) {
  typeInto(document.querySelector("input[placeholder]"), text);
  await wait();
  document.querySelector("form").requestSubmit();
  await wait();
}
(async () => {
  const $ = (s) => document.querySelector(s);
  const $$ = (s) => [...document.querySelectorAll(s)];
  const check = (ok, msg) => console.log((ok ? "✓ " : "✗ ") + msg);
  await wait();
  await add("Купить лепёшки");
  await add("Позвонить маме");
  check($("input[placeholder]").value === "", "поле очищается после добавления");
  await add("   ");
  check($$("li").length === 2, "добавление: две задачи, пустая строка не добавилась");
  check($("#left").textContent.trim() === "Осталось: 2", "счётчик: Осталось: 2");
  if ($$("li").length < 2) return console.log("✗ дальше проверять нечего: выведите задачи в список");
  $$("li input[type=checkbox]")[0].click();
  await wait();
  check($$("li span")[0].classList.contains("done"), "отметка: у первой задачи класс done");
  check(!$$("li span")[1].classList.contains("done"), "отметка: вторая задача не затронута");
  check($("#left").textContent.trim() === "Осталось: 1", "счётчик: Осталось: 1");
  $$("li input[type=checkbox]")[0].click();
  await wait();
  check(!$$("li span")[0].classList.contains("done"), "повторный клик снимает отметку");
  $$("li .del")[0].click();
  await wait();
  check($$("li").length === 1 && $$("li span")[0].textContent === "Позвонить маме", "удаление: осталась «Позвонить маме»");
  check($("#left").textContent.trim() === "Осталось: 1", "счётчик после удаления");
})();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

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

  1. Состояние. Массив задач { id, text, done } и текст поля ввода (управляемое поле).
  2. Добавление. Через <form onSubmit> с preventDefault. Пустые строки и строки из одних пробелов не добавляются. После добавления поле очищается. id создаётся при добавлении (Date.now()).
  3. Список. Через .map() с key={task.id}. У каждой задачи: чекбокс, текст в <span> и кнопка ✕ с className="del".
  4. Отметка. Чекбокс переключает done. У выполненной задачи у <span> класс done (зачёркнутый текст).
  5. Удаление. Кнопка ✕ убирает задачу из списка.
  6. Счётчик <p id="left">Осталось: N</p> — число невыполненных. Подсказка: это не отдельное состояние, а вычисляемое значение.
  7. Условный рендер. Если задач нет — вместо списка показывается «Пока задач нет» (самопроверка к этому пункту не придирается, но на оценке он учитывается).
  8. Никаких мутаций. Ни push, ни splice, ни присваиваний полям объектов в состоянии — только spread, map и filter.
  9. Проверка ИИ. Если использовали ИИ — приложите свой промпт и ответ на вопрос: какую ошибку (мутация, ключ) ИИ допустил или чуть не допустил, и как вы это проверили.

Бонус для сильных

  • Фильтры «Все / Активные / Выполненные» — одно дополнительное состояние filter.
  • Кнопка «Очистить выполненные» — одна строка с filter.
  • Редактирование текста задачи по двойному клику.

Что сдавать

  • Ссылку на репозиторий в GitHub с проектом на Vite (App.jsx и стили).
  • Скриншот: список с тремя задачами, одна из них выполнена.
  • Короткий README: что умеет приложение и какие приёмы (props, map, useState) где использованы.

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

ЧтоБаллы
Добавление: форма, preventDefault, пустые строки отсекаются, поле очищается2
Отметка выполненной и счётчик «Осталось»2
Удаление через filter1
Список через .map() с key={id}, не индекс2
Нет мутаций состояния (spread, map, filter)2
Пустое состояние «Пока задач нет», чистый код, ИИ-код вы можете объяснить1
Перед сдачей

Проверьте сами: добавьте три задачи, удалите среднюю и отметьте последнюю. Если зачёркнутой оказалась не та задача, значит, где-то остался индекс вместо id.

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

  • Props: данные идут от родителя к ребёнку как атрибуты, приходят объектом: function Card({ name, price }). Props только для чтения. Строки — в кавычках, остальное — в { }.
  • children: всё между тегами компонента. Выводим {children} там, где нужно: рамки, панели, обёртки.
  • По умолчанию и rest: { label = "Нажми" } срабатывает только для undefined; для null — ??. { a, ...rest } собирает остальное, <input {...rest} /> раздаёт.
  • Списки: items.map((x) => <Item key={x.id} … />). С { } после стрелки нужен return. Фильтр — filter перед map.
  • key: уникальный и стабильный, из данных (id). Не index для изменяемых списков (ломаются поля ввода и состояние строк) и не Math.random().
  • useState: const [x, setX] = useState(начальное). Начальное значение — только при первом показе. Обычная переменная экран не обновит. Хуки — только на верхнем уровне компонента.
  • Функциональное обновление: setX((x) => x + 1), когда новое значение зависит от старого. Вычисляемое не храним в состоянии.
  • Не мутируем: добавить [...arr, x], удалить arr.filter(...), изменить arr.map(...), объект { ...obj, k: v }. Нельзя: push, splice, obj.k = v, sort без копии.
  • События: onClick={fn} — передаём функцию, не вызываем: onClick={() => remove(id)}. Форма: onSubmit + event.preventDefault().
  • Управляемое поле: value={text} + onChange={(e) => setText(e.target.value)}; у чекбокса — checked и e.target.checked.
  • Условный рендер: cond && <A /> («или ничего»), cond ? <A /> : <B /> («одно или другое»), ранний return для особых случаев. 0 && … рисует 0 — сравнивайте: n > 0 && ….
  • Поднятие состояния: общее состояние живёт в ближайшем общем родителе, а детям отдаём значение и функцию-обработчик через props.
  • С ИИ: сначала сами, потом промпт по формуле. Объяснение ИИ проверяем запуском: нет ли мутации и откуда взят key.