Загрузка данных
# Лабораторная работа: хуки (useState, useEffect)
## Входные файлы
Вам выдаётся приложение «Shelf» — личный трекер чтения:
| Файл | Назначение |
| ---------------- | ---------------------------------------------------------------------- |
| `reference.html` | Вёрстка, стили и логика одним файлом — рабочая реализация на чистом JS |
\_Чтобы поднять страницу - можете использовать `Go live` в кодспейсе или `live-server` пакет из npm (`npm install -g live-server` чтобы поставить на всю среду разработки, а не в проект)
## Референс архитектуры приложения
_Обратите внимание, что это архитектура приложения, а не проекта._
_Работаем также в папках `components`, `pages`, в компонентах под каждый компонент создаем папку и кладем туда `jsx` и `css`_
Минимально ожидаемое дерево компонентов:
```
App
└─ ShelfScreen
└─ BookForm
├─ Checkbox
├─ Input
└─ Button
├─ FilterChip
└─ BookList
└─ BookItem
```
## Часть 1. Настройка проекта
1. Создайте новое React-приложение (Vite+React+JS+ESLint).
## Часть 2. Разбиение на компоненты и хуки
> Для преобразования `HTML` в `JSX` используйте этот [ сервис ](https://transform.tools/html-to-jsx)
Создайте по одному `.jsx`-файлу на каждый компонент из вашего дерева:
2. **`BookForm`** — контролируемый `input` с собственным `useState` для черновика названия. По кнопке «Добавить на полку» или `Enter` вызывает `onAdd(title)` и очищает своё поле. Пустая/пробельная строка не отправляется.
4. **`BookItem`** — одна книга: цветная обложка-заглушка (можно взять цвет по остатку от деления `id` на длину палитры — как в референсе), название, автор, переключатель «Прочитано» (`onToggleRead`) и кнопка удаления (`onDelete`). Не знает, что такое фильтрация — просто рендерит то, что получил.
5. **`BookList`** — принимает уже отфильтрованный массив `books` и рендерит `BookItem` через `.map()` с `key={book.id}`. Сама фильтрацию не делает.
6. **`ShelfScreen`** — держит логику фильтрации (`books.filter(...)` на основе пропа `showOnlyUnread`) и раздаёт колбэки вниз.
10. **`App`** — единственное место, где находятся все `useState`: `books`, `showOnlyUnread`. Передаёт данные и функции-обработчики вниз по дереву, сам ничего не рендерит напрямую, кроме компоновки дочерних компонентов.
**Важно:** обновления `books` должны быть иммутабельными:
- добавление — через спред (`[...books, newBook]`);
- удаление — через `.filter()`;
- переключение `read` у одной книги — через `.map()`, при этом остальные
книги должны остаться **теми же объектами** (не пересоздавать их).
## Требования к оформлению кода
- Имя компонента и имя файла совпадают и начинаются с заглавной буквы
(`BookItem.jsx`, а не `bookItem.jsx`).
- Каждый компонент — отдельный файл, экспортируется по умолчанию
(`export default`).
- Внутри компонентов, принимающих пропсы, используется деструктуризация в
параметрах функции, а не обращение через `props.что-то` по всему коду.
- Никаких прямых мутаций состояния (`books.push(...)`, `book.read = true`,
`arr[i] = ...`) — только через `set...` с новым массивом/объектом.
- У каждого элемента списка есть `key`, построенный на основе `id` книги,
а не на основе индекса массива.
- Значение из `useState` нигде не читается и не пишется напрямую в DOM
(никаких `document.getElementById(...)` внутри компонентов).
# Как сдавать
- Создайте форк репозитория в вашей организации с названием-этого-репозитория-вашафамилия
- Используя ветку wip сделайте задание
- Зафиксируйте изменения в вашем репозитории
- Захостите работу
- Когда документ будет готов - создайте пул реквест из ветки wip (вашей) на ветку main (тоже вашу) и укажите меня (ktkv419) как reviewer
Не мержите сами коммит, это сделаю я после проверки задания
---
# Памятка
## useState
### Синтаксис
```
const [значение, setЗначение] = useState(начальноеЗначение);
```
- `значение` — то, что читается при рендере.
- `setЗначение` — единственный правильный способ его изменить. Прямое
присваивание (`значение = ...`) не вызовет повторный рендер и является
ошибкой.
- Вызов `useState` должен быть на верхнем уровне компонента: не внутри
`if`, `for` или вложенной функции.
### Базовый пример: переключение состояния (не число, а булево значение)
Похоже на счётчик, но здесь только два состояния — вкл/выкл.
```
const [isOn, setIsOn] = useState(false);
<button onClick={() => setIsOn(prev => !prev)}>
{isOn ? 'Выключить свет' : 'Включить свет'}
</button>
```
### Функциональное обновление — когда новое значение зависит от старого
Если внутри одного обработчика вызвать `setX(x + 1)` дважды подряд, оба
вызова "увидят" одно и то же старое `x` из замыкания — в сумме получится
`+1`, а не `+2`. Чтобы React брал самое свежее значение, передавайте в
`set...` функцию, а не готовое число:
```
// Плохо: оба вызова используют одно и то же значение score из рендера
setScore(score + 10);
setScore(score + 10);
// Хорошо: каждый вызов получает актуальное значение на момент применения
setScore(prev => prev + 10);
setScore(prev => prev + 10);
```
Это же правило работает и для `setInterval`/`setTimeout` внутри
`useEffect` — там `x` тоже "застревает" на значении из момента запуска
эффекта, если не использовать функциональную форму.
### Обновление массива — без мутации
```
const [playlist, setPlaylist] = useState([]);
// добавить трек
setPlaylist(prev => [...prev, newTrack]);
// удалить трек по id
setPlaylist(prev => prev.filter(track => track.id !== id));
// пометить один трек избранным, остальные не трогать
setPlaylist(prev =>
prev.map(track => track.id === id ? { ...track, favorite: !track.favorite } : track)
);
```
Правило простое: если структура — массив или объект, `set...` всегда
получает **новый** массив/объект. `push`, `splice`, прямое присваивание
поля (`track.favorite = true`) — так делать нельзя, React не увидит
изменения.
### Обновление объекта-формы
```
const [profile, setProfile] = useState({ name: '', city: '' });
function handleNameChange(e) {
setProfile(prev => ({ ...prev, name: e.target.value }));
}
```
Забытый спред (`setProfile({ name: e.target.value })` без `...prev`) —
самая частая ошибка: он затирает остальные поля объекта.
### Когда НЕ нужен отдельный useState
Если значение можно посчитать прямо при рендере из уже существующего
state — не заводите под него ещё один `useState`.
```
const [cartItems, setCartItems] = useState([]);
// Не нужно: const [totalCount, setTotalCount] = useState(0);
// Просто считаем при рендере:
const totalCount = cartItems.length;
const totalPrice = cartItems.reduce((sum, item) => sum + item.price, 0);
```
---
## useEffect
### Синтаксис
```
useEffect(() => {
// код эффекта — выполняется после рендера
return () => {
// необязательный cleanup — выполняется перед следующим запуском
// эффекта и перед размонтированием компонента
};
}, [зависимости]);
```
### Три формы второго аргумента
| Второй аргумент | Когда срабатывает |
| --------------- | ---------------------------------------------------------------------- |
| не передан | после **каждого** рендера |
| `[]` | один раз, после первого рендера (монтирование) |
| `[a, b]` | после первого рендера и затем каждый раз, когда `a` или `b` меняются |
### Пример: без зависимостей — логирование каждого рендера
```
useEffect(() => {
console.log('Компонент отрендерился');
});
```
Полезно для отладки, но в реальном коде почти всегда нужен второй
аргумент — иначе эффект будет срабатывать без остановки.
### Пример: пустой массив — что-то один раз при монтировании
```
useEffect(() => {
document.title = 'Открыто новое окно чата';
}, []);
```
### Пример: с зависимостью — реакция на конкретное изменение
```
useEffect(() => {
console.log(`Пользователь открыл раздел: ${activeTab}`);
}, [activeTab]);
```
Сработает только когда поменяется именно `activeTab`, а не при любом
другом обновлении state в этом же компоненте.
### Пример: debounce
```
useEffect(() => {
const timer = setTimeout(() => {
...
}, 400);
return () => clearTimeout(timer);
}, [query]);
```
## Пример: Поиск по строке (фильтрация списка)
Часто нужно отфильтровать список карточек по тому, что ввёл пользователь в поле поиска. Пропсами тут не обойтись одними — понадобится **state**, потому что значение поля ввода меняется во время работы приложения. Общая логика:
1. Состояние строки поиска (`searchQuery`) хранится в **родительском** компоненте (том, у которого есть доступ ко всему массиву данных) — например, в `App`.
2. Поле `<input>` — **контролируемый компонент**: его значение берётся из state, а изменения приходят через `onChange`.
3. Перед рендерингом список фильтруется методом `.filter()` по введённой строке.
4. В дочерний компонент (`CourseList`) передаётся **уже отфильтрованный массив**, а не исходный — сам список ничего не знает о поиске.
```jsx
import { useState } from "react";
import courses from "./data/courses.json";
function App() {
const [searchQuery, setSearchQuery] = useState("");
// Фильтруем массив по названию курса, без учёта регистра
const filteredCourses = courses.filter((course) =>
course.title.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<>
<Header />
<SearchBar value={searchQuery} onChange={setSearchQuery} />
<CourseList courses={filteredCourses} />
<Footer />
</>
);
}
```
Компонент поля поиска получает текущее значение и функцию-колбэк через пропсы и вызывает её при каждом вводе — сам он состояние не хранит:
```jsx
function SearchBar({ value, onChange }) {
return (
<input
type="text"
placeholder="Найти курс..."
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
}
```
`CourseList` при этом не меняется вообще — он как получал массив курсов через проп и рендерил его через `.map()`, так и продолжает это делать, не зная, что массив уже отфильтрован:
```jsx
function CourseList({ courses }) {
if (courses.length === 0) {
return <p>Курсы не найдены</p>;
}
return (
<div className="courses__grid">
{courses.map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
);
}
```
---
## Пример: тудушка (add / update / delete на одном массиве)
Классический учебный пример, который собирает воедино все три операции с массивом в state: добавление через спред, изменение через `.map()`, удаление через `.filter()`. По структуре он очень похож на `Shelf` из задания — задачи вместо книг, `done` вместо `read`.
```jsx
import { useState } from "react";
function App() {
const [todos, setTodos] = useState([]);
const [draft, setDraft] = useState("");
function handleAdd() {
const title = draft.trim();
if (title === "") return; // пустую/пробельную строку не добавляем
const newTodo = {
id: Date.now(), // простой способ получить уникальный id
title,
done: false,
};
// ДОБАВЛЕНИЕ — через спред, старые элементы не трогаем
setTodos((prev) => [...prev, newTodo]);
setDraft(""); // очищаем поле после добавления
}
function handleToggle(id) {
// ИЗМЕНЕНИЕ — через .map(), пересоздаём только нужный объект,
// остальные задачи остаются теми же объектами
setTodos((prev) =>
prev.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
);
}
function handleDelete(id) {
// УДАЛЕНИЕ — через .filter(), новый массив без нужного элемента
setTodos((prev) => prev.filter((todo) => todo.id !== id));
}
return (
<div>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
placeholder="Новая задача..."
/>
<button onClick={handleAdd}>Добавить</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.done}
onChange={() => handleToggle(todo.id)}
/>
<span>{todo.title}</span>
<button onClick={() => handleDelete(todo.id)}>Удалить</button>
</li>
))}
</ul>
</div>
);
}
```
Три ключевых момента:
- **Добавление** всегда создаёт новый массив: `[...prev, newTodo]`, а не `prev.push(newTodo)`.
- **Изменение одного элемента** — через `.map()`: находим нужный `id`, для него собираем новый объект (`{ ...todo, done: !todo.done }`), для остальных возвращаем тот же самый объект без изменений (`todo`) — так React может пропустить перерисовку неизменившихся элементов.
- **Удаление** — через `.filter()`: возвращаем массив без элемента с нужным `id`, ничего не мутируя.
## Условный рендер через state
Помимо самих данных, в state часто хранят **что показывать**: пустой список, состояние загрузки, открыта ли модалка и т.д. Рендерить в зависимости от такого state можно несколькими способами.
### Тернарный оператор — выбор одного из двух вариантов
```jsx
{
todos.length === 0 ? (
<p>Задач пока нет</p>
) : (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
```
### `&&` — показать элемент, только если условие истинно
Используется, когда для «иначе» ничего рендерить не нужно.
```jsx
const [showCompleted, setShowCompleted] = useState(false);
<button onClick={() => setShowCompleted((prev) => !prev)}>
{showCompleted ? "Скрыть выполненные" : "Показать выполненные"}
</button>;
{
showCompleted && (
<ul>
{todos
.filter((todo) => todo.done)
.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
```
Осторожно: если слева от `&&` окажется `0` (например, `todos.length && <ul>...`),
React отрендерит на экране `0`, а не «ничего». Поэтому для чисел лучше явно
сравнивать: `todos.length > 0 && ...`.
### Ранний `return` — когда вариантов рендера много
Удобно, когда одно из состояний полностью исключает остальную разметку компонента (например, состояние загрузки).
```jsx
function TodoList({ todos, isLoading }) {
if (isLoading) {
return <p>Загрузка...</p>;
}
if (todos.length === 0) {
return <p>Задач пока нет</p>;
}
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
```
Во всех трёх случаях источник правды один — **state** (`todos`, `showCompleted`, `isLoading`). JSX только читает его и решает, что нарисовать; сам JSX никогда не меняет state напрямую.