Урок 28 из 49 · Месяц 4. React
Списки и ключи, CSS Modules и обработчики событий
Содержание урока
- Списки: выводим массив через map
- Список объектов
- filter + map
- Ключи (key): зачем они нужны
- Правила ключей
- Почему индекс — плохой ключ
- Ключ на компоненте
- Передача объектных типов через пропсы
- Объект целиком
- Разворачивание объекта через spread
- Массив через пропсы
- CSS Modules
- Проблема обычного CSS
- Как пользоваться
- Несколько классов и условные классы
- Обработчики событий
- Как подписаться на событие
- Передача аргумента в обработчик
- Объект события
- Отправка формы
- Обработчик как проп
- Типичные ошибки
- Практика
- Итоги
Почти любой сайт показывает списки: товары, комментарии, сообщения, задачи. В этом уроке научимся выводить массивы данных через map, разберёмся, зачем React нужны ключи key, начнём стилизовать компоненты через CSS Modules, передавать в пропсы объекты и массивы и, наконец, реагировать на действия пользователя — клики, ввод текста, отправку формы.
Списки: выводим массив через map
В прошлом уроке мы копировали <ProductCard /> вручную три раза. В реальности данные приходят с сервера массивом, и мы не знаем заранее, сколько там элементов. Решение — метод map, который вы знаете из урока Методы перебора массива.
map превращает каждый элемент массива в новый. А React умеет показывать массив JSX-элементов. Значит, можно превратить массив данных в массив разметки:
const fruits = ['Яблоко', 'Банан', 'Апельсин'];
function FruitList() {
return (
<ul>
{fruits.map((fruit) => (
<li key={fruit}>{fruit}</li>
))}
</ul>
);
}
Результат в браузере:
<ul>
<li>Яблоко</li>
<li>Банан</li>
<li>Апельсин</li>
</ul>
Разбор:
{fruits.map(...)}— фигурные скобки, потому что внутри JavaScript.(fruit) => (<li>...</li>)— стрелочная функция с круглыми скобками сразу возвращает JSX.key={fruit}— уникальный ключ для каждого элемента списка. Про него — ниже.
Список объектов
Чаще всего в массиве лежат объекты:
const users = [
{ id: 1, name: 'Айгерим', role: 'Дизайнер' },
{ id: 2, name: 'Данияр', role: 'Frontend-разработчик' },
{ id: 3, name: 'Мария', role: 'Тестировщик' },
];
function Team() {
return (
<div>
<h2>Наша команда</h2>
{users.map((user) => (
<div key={user.id} className="member">
<h3>{user.name}</h3>
<p>{user.role}</p>
</div>
))}
</div>
);
}
filter + map
Методы можно соединять в цепочку. Например, показать только товары в наличии:
const products = [
{ id: 1, title: 'Ноутбук', price: 350000, inStock: true },
{ id: 2, title: 'Планшет', price: 180000, inStock: false },
{ id: 3, title: 'Телефон', price: 220000, inStock: true },
];
function AvailableProducts() {
const available = products.filter((p) => p.inStock);
if (available.length === 0) {
return <p>Товаров нет в наличии</p>;
}
return (
<ul>
{available.map((p) => (
<li key={p.id}>
{p.title} — {p.price.toLocaleString('ru-RU')} ₸
</li>
))}
</ul>
);
}
Обратите внимание на проверку пустого списка — хороший интерфейс всегда показывает понятное сообщение, а не пустоту.
Ключи (key): зачем они нужны
Если убрать key, в консоли появится предупреждение: Each child in a list should have a unique "key" prop. Зачем React это нужно?
Представьте гардероб в театре. Пальто вешают на крючки с номерками. Если гость уйдёт, гардеробщик по номерку точно знает, какое пальто снять — и не перевешивает все остальные. key — это номерок для элемента списка. Когда список меняется (элемент добавили, удалили, переставили), React по ключам понимает, какой элемент какой, и обновляет только нужное.
Правила ключей
- Ключ уникален среди соседей в одном списке (в разных списках ключи могут повторяться).
- Ключ стабилен — у одного и того же элемента он не меняется между рендерами.
- Ключ ставится на внешний элемент, который возвращает
map, а не внутри него. - Лучший ключ —
idиз данных (из базы данных, с сервера).
Почему индекс — плохой ключ
Возникает соблазн написать так:
{todos.map((todo, index) => (
<li key={index}>{todo.text}</li> // ⚠️ плохо, если список меняется
))}
Пока список не меняется, всё работает. Но если удалить первый элемент, у всех остальных индексы сдвинутся: бывший второй станет «нулевым». React решит, что удалили последний элемент, а у остальных просто поменялся текст. Если внутри элементов есть поля ввода или чекбоксы, введённые данные «перепрыгнут» на соседние строки.
Ключ на компоненте
Если элемент списка — ваш компонент, ключ ставится на сам компонент:
{users.map((user) => (
<UserCard key={user.id} name={user.name} role={user.role} />
))}
key — служебный атрибут для React, внутрь компонента он не передаётся. Если id нужен внутри, передайте его отдельным пропсом: id={user.id}.
Передача объектных типов через пропсы
Объект целиком
Вместо того чтобы передавать каждое поле отдельно, можно передать весь объект одним пропсом:
function UserCard({ user }) {
return (
<div className="card">
<img src={user.avatar} alt={user.name} width={64} />
<h3>{user.name}</h3>
<p>{user.email}</p>
<p>Город: {user.address.city}</p>
</div>
);
}
const user = {
id: 1,
name: 'Айгерим',
email: 'aigerim@example.com',
avatar: 'https://placehold.co/64',
address: { city: 'Алматы', street: 'Абая, 10' },
};
// использование
<UserCard user={user} />
Разворачивание объекта через spread
Если поля объекта совпадают с названиями пропсов, объект можно «развернуть» оператором ...:
function ProductCard({ title, price, inStock }) {
return (
<div>
<h3>{title}</h3>
<p>{price} ₸</p>
<p>{inStock ? 'В наличии' : 'Нет в наличии'}</p>
</div>
);
}
{products.map((product) => (
<ProductCard key={product.id} {...product} />
))}
{...product} — то же самое, что title={product.title} price={product.price} inStock={product.inStock} id={product.id}.
| Способ | Пример | Плюсы | Минусы |
|---|---|---|---|
| Отдельные пропсы | title={p.title} price={p.price} |
Явно видно, что передаём | Много кода |
| Объект целиком | product={p} |
Коротко, удобно для вложенных данных | Не видно, какие поля используются |
| Spread | {...p} |
Очень коротко | Можно случайно передать лишнее |
Массив через пропсы
Массив передаётся так же, как объект — в фигурных скобках. Внутри компонента его можно перебрать через map:
function TagList({ tags }) {
return (
<div className="tags">
{tags.map((tag) => (
<span key={tag} className="tag">#{tag}</span>
))}
</div>
);
}
<TagList tags={['react', 'javascript', 'frontend']} />
CSS Modules
Проблема обычного CSS
Обычный CSS-файл действует на всю страницу. Если в Header.css и Card.css есть класс .title, стили перемешаются, и какой победит — зависит от порядка подключения. В большом проекте это постоянная головная боль.
CSS Modules решают проблему: классы из такого файла действуют только на тот компонент, который его импортировал. Vite поддерживает их из коробки — ничего устанавливать не нужно.
Как пользоваться
- Назовите файл с суффиксом
.module.css, напримерCard.module.css. - Импортируйте его как объект:
import styles from './Card.module.css'. - Используйте классы как свойства объекта:
className={styles.card}.
/* components/Card.module.css */
.card {
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: 12px;
}
.title {
margin: 0 0 8px;
font-size: 20px;
}
.price {
color: #16a34a;
font-weight: 700;
}
// components/Card.jsx
import styles from './Card.module.css';
function Card({ title, price }) {
return (
<div className={styles.card}>
<h3 className={styles.title}>{title}</h3>
<p className={styles.price}>{price} ₸</p>
</div>
);
}
export default Card;
Откройте DevTools — у элемента будет класс вроде _card_1x2ab_1. Vite добавил к имени уникальный «хвост», поэтому класс .title из другого модуля с ним никогда не пересечётся.
Несколько классов и условные классы
className — просто строка, поэтому классы склеивают шаблонной строкой:
import styles from './Button.module.css';
function Button({ text, variant = 'primary', disabled = false }) {
const className = `${styles.button} ${styles[variant]} ${disabled ? styles.disabled : ''}`;
return <button className={className} disabled={disabled}>{text}</button>;
}
/* Button.module.css */
.button { padding: 8px 16px; border: none; border-radius: 8px; cursor: pointer; }
.primary { background: #2563eb; color: white; }
.danger { background: #dc2626; color: white; }
.disabled { opacity: 0.5; cursor: not-allowed; }
styles[variant]— доступ через квадратные скобки: еслиvariant = 'danger', получимstyles.danger. Так же обращаются к классам с дефисом:styles['card-title'](поэтому классы удобнее называть в camelCase).- Тернарник добавляет класс
disabledтолько когда нужно.
Глобальные стили (сброс отступов, шрифт для body) по-прежнему держите в обычном index.css, подключённом в main.jsx.
Обработчики событий
Как подписаться на событие
В DOM мы писали addEventListener. В React обработчик передаётся прямо в JSX атрибутом в camelCase: onClick, onChange, onSubmit, onMouseEnter, onKeyDown.
function AlertButton() {
function handleClick() {
alert('Вы нажали на кнопку!');
}
return <button onClick={handleClick}>Нажми</button>;
}
Обработчики принято называть handleЧтоТо: handleClick, handleSubmit, handleDelete.
Передача аргумента в обработчик
Если нужно передать данные (например, id товара), оберните вызов в стрелочную функцию:
function ProductList({ products }) {
function handleBuy(title) {
alert(`Товар «${title}» добавлен в корзину`);
}
return (
<ul>
{products.map((p) => (
<li key={p.id}>
{p.title}
<button onClick={() => handleBuy(p.title)}>Купить</button>
</li>
))}
</ul>
);
}
() => handleBuy(p.title) — новая функция, которая вызовет handleBuy только при клике.
Объект события
React передаёт в обработчик объект события event (часто пишут e) — почти такой же, как в обычном DOM:
function SearchInput() {
function handleChange(e) {
console.log('Введено:', e.target.value);
}
function handleKeyDown(e) {
if (e.key === 'Enter') {
console.log('Нажат Enter');
}
}
return <input onChange={handleChange} onKeyDown={handleKeyDown} placeholder="Поиск..." />;
}
e.target— элемент, на котором произошло событие;e.target.value— текст в поле.- В React
onChangeсрабатывает на каждый введённый символ (в обычном DOM событиеchange— только при потере фокуса).
Отправка формы
При отправке формы браузер по умолчанию перезагружает страницу. Отменяем это через e.preventDefault():
function SubscribeForm() {
function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
const email = formData.get('email');
alert(`Подписка оформлена на ${email}`);
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required placeholder="Email" />
<button type="submit">Подписаться</button>
</form>
);
}
onSubmit вешается на <form>, а не на кнопку — так форма отправится и по клику, и по Enter.
Обработчик как проп
Функцию можно передать в дочерний компонент через пропсы. Так ребёнок «сообщает» родителю о событии:
function TodoItem({ todo, onDelete }) {
return (
<li>
{todo.text}
<button onClick={() => onDelete(todo.id)}>Удалить</button>
</li>
);
}
function TodoList() {
const todos = [
{ id: 1, text: 'Выучить JSX' },
{ id: 2, text: 'Разобраться с пропсами' },
];
function handleDelete(id) {
console.log('Удалить задачу с id', id);
}
return (
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onDelete={handleDelete} />
))}
</ul>
);
}
Разбор цепочки:
- Родитель
TodoListописывает, что делать при удалении, —handleDelete. - Передаёт функцию ребёнку пропсом
onDelete(пропсы-события принято называтьonЧтоТо). - Ребёнок
TodoItemвызывает её при клике и передаёт свойid.
Пока задача только выводится в консоль: чтобы она действительно исчезала со страницы, нужно состояние — это тема урока useState и useEffect.
Типичные ошибки
- Нет
keyв списке илиkeyстоит не на внешнем элементеmap. key={index}в списке, который меняется, илиkey={Math.random()}.mapс фигурными скобками безreturn:items.map((i) => { <li>{i}</li> })вернёт массивundefined. Используйте() => (...)или явныйreturn.- Импорт
import './Card.module.css'без имени — классы не применятся. Нужноimport styles from .... onClick={handleClick()}вместоonClick={handleClick}.- Забыли
e.preventDefault()вonSubmit— страница перезагружается.
Практика
- Список городов. Выведите массив из 6 городов списком
<ul>. Ожидаемый результат: список без предупреждений о ключах в консоли. - Каталог. Создайте массив из 6 товаров (
id,title,price,category,inStock). Выведите их компонентомProductCard, передавая объект через spread. Товары не в наличии показывайте с серой надписью «Нет в наличии». - CSS Modules. Оформите
ProductCardчерезProductCard.module.css. Добавьте карточкам, у которыхinStock = false, дополнительный классoutOfStockс прозрачностью 0.5. - События. Добавьте в карточку кнопку «В корзину», которая через проп
onAddToCartсообщает родителюidтовара; родитель выводит в консольДобавлен товар: <название>. Ожидаемый результат: в консоли название нажатого товара. - Форма поиска. Сделайте форму с полем и кнопкой «Найти». При отправке (без перезагрузки страницы) выводите в консоль все товары, в названии которых есть введённый текст (без учёта регистра).
Итоги
- Списки выводятся через
array.map(item => <Component />), можно комбинировать сfilter. - Каждому элементу списка нужен уникальный и стабильный
key, лучше всегоid; индекс — только для статичных списков. - В пропсы можно передавать объекты (
user={user}), массивы и разворачивать объект через{...obj}. - CSS Modules (
*.module.css) изолируют классы внутри компонента:className={styles.card}. - Обработчики пишутся в camelCase (
onClick), передаётся сама функция, а для аргументов — стрелка() => fn(id). - Функцию-обработчик можно передать ребёнку пропсом
onЧтоТо, чтобы он сообщал родителю о событиях.