Загрузка данных
Дай пошаговуб инструкцию для выполнения лабораторной работы, я ее делаю в github codespace"# Лабораторная работа: хуки (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 напрямую.". а также вот текст из референса, вот файл app.jsx: "import { useState } from 'react'
import heroImg from './assets/hero.png'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<div className="app">
<div className="app-header">
<div className="brand">
<div className="brand-mark">S</div>
<div className="brand-name">Shelf</div>
</div>
</div>
<section className="screen active" id="screen-shelf">
<p className="greeting">Добрый вечер</p>
<div className="add-book-row">
<input className="input" id="bookInput" placeholder="Название книги..." />
<button className="btn" id="addBtn">
Добавить на полку
</button>
</div>
<div className="list-toolbar">
<span className="toolbar-title">Книги</span>
<div className="filter-chip">
<input type="checkbox" id="filterCheckbox" />
<label htmlFor="filterCheckbox">
<span className="dot" />
Только непрочитанные
</label>
</div>
</div>
<div className="book-list" id="bookList">
<div className="book-row" data-id={1}>
<div className="book-cover" style={{ background: "#4f6b52" }}>
К
</div>
<div className="book-info">
<p className="book-title done">Клара и Солнце</p>
<div className="book-author">Кадзуо Исигуро</div>
</div>
<div className="read-check checked" data-role="toggle">
<span className="check-circle">✓</span>
<span className="read-label">Прочитано</span>
</div>
<button
className="delete-btn"
data-role="delete"
title="Убрать с полки"
>
✕
</button>
</div>
</div>
</section>
</div>
</>
)
}
export default App
" а это код из файла index.css " :root {
--ink: #1c1712;
--surface: #241d17;
--surface-2: #2c231c;
--line: #3a3026;
--paper: #ece4d3;
--muted: #a89a84;
--muted-dim: #6f6353;
--gold: #c9a227;
--gold-dim: rgba(201, 162, 39, 0.14);
--wine: #8a3a3a;
--font-display: 'Lora', Georgia, serif;
--font-body: 'Inter', system-ui, sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
--radius: 12px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--ink);
color: var(--paper);
font-family: var(--font-body);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
display: flex;
justify-content: center;
padding: 48px 20px 80px;
}
.app {
width: 100%;
max-width: 560px;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-mark {
width: 30px;
height: 30px;
border-radius: 8px;
background: linear-gradient(160deg, var(--gold), #9c7a1a);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-weight: 600;
color: #1c1712;
font-size: 15px;
}
.brand-name {
font-family: var(--font-display);
font-size: 19px;
font-weight: 500;
}
.view-switch {
display: inline-flex;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 999px;
padding: 3px;
}
.view-btn {
border: none;
background: none;
color: var(--muted);
font-family: var(--font-body);
font-size: 12.5px;
font-weight: 600;
padding: 7px 14px;
border-radius: 999px;
cursor: pointer;
}
.view-btn.active {
background: var(--gold);
color: #1c1712;
}
.screen {
display: none;
}
.screen.active {
display: block;
}
.greeting {
font-family: var(--font-display);
font-size: 22px;
font-style: italic;
color: var(--paper);
margin: 0 0 4px;
}
.greeting-sub {
color: var(--muted);
font-size: 13px;
margin: 0 0 24px;
}
.add-book-row {
display: flex;
gap: 8px;
margin-bottom: 18px;
}
.input {
flex: 1;
background: var(--surface);
border: 1px solid var(--line);
color: var(--paper);
padding: 11px 14px;
border-radius: var(--radius);
font-size: 13.5px;
font-family: inherit;
}
.input::placeholder {
color: var(--muted-dim);
}
.input:focus {
outline: none;
border-color: var(--gold);
}
.btn {
background: var(--gold);
color: #1c1712;
border: none;
padding: 11px 18px;
border-radius: var(--radius);
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.btn:hover {
filter: brightness(1.08);
}
.btn.ghost {
background: none;
border: 1px solid var(--line);
color: var(--paper);
}
.btn.ghost:hover {
border-color: var(--gold);
color: var(--gold);
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.toolbar-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted-dim);
}
.filter-chip {
position: relative;
}
.filter-chip input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.filter-chip label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
color: var(--muted);
background: var(--surface);
border: 1px solid var(--line);
padding: 6px 12px;
border-radius: 999px;
cursor: pointer;
user-select: none;
}
.filter-chip input:checked + label {
background: var(--gold-dim);
border-color: var(--gold);
color: var(--gold);
}
.filter-chip .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--muted-dim);
}
.filter-chip input:checked + label .dot {
background: var(--gold);
}
.book-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.empty-note {
color: var(--muted-dim);
font-size: 13px;
text-align: center;
padding: 30px 0;
border: 1px dashed var(--line);
border-radius: var(--radius);
}
.book-row {
display: flex;
align-items: center;
gap: 13px;
padding: 11px 12px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.book-cover {
width: 34px;
height: 48px;
border-radius: 4px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-weight: 600;
font-size: 15px;
color: rgba(255, 255, 255, 0.85);
}
.book-info {
flex: 1;
min-width: 0;
}
.book-title {
font-family: var(--font-display);
font-size: 14.5px;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.book-title.done {
color: var(--muted);
}
.book-author {
font-size: 12px;
color: var(--muted-dim);
margin-top: 1px;
}
.read-check {
display: flex;
align-items: center;
gap: 7px;
cursor: pointer;
flex-shrink: 0;
}
.check-circle {
width: 20px;
height: 20px;
border-radius: 50%;
border: 1.5px solid var(--muted-dim);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: transparent;
}
.read-check.checked .check-circle {
background: var(--gold);
border-color: var(--gold);
color: #1c1712;
}
.read-label {
font-size: 11.5px;
color: var(--muted-dim);
}
.delete-btn {
border: none;
background: none;
color: var(--muted-dim);
font-size: 14px;
cursor: pointer;
width: 24px;
height: 24px;
border-radius: 6px;
flex-shrink: 0;
}
.delete-btn:hover {
color: var(--wine);
background: rgba(138, 58, 58, 0.15);
}
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 26px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 16px 10px;
text-align: center;
}
.stat-value {
font-family: var(--font-mono);
font-size: 24px;
font-weight: 500;
color: var(--gold);
}
.stat-label {
font-size: 11.5px;
color: var(--muted);
margin-top: 3px;
}
.session-card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 22px;
}
.session-title {
font-family: var(--font-display);
font-size: 16px;
margin: 0 0 3px;
}
.session-sub {
font-size: 12.5px;
color: var(--muted);
margin: 0 0 18px;
}
.counter-row {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
margin-bottom: 16px;
}
.counter-btn {
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--paper);
font-size: 18px;
cursor: pointer;
}
.counter-btn:hover {
border-color: var(--gold);
color: var(--gold);
}
.counter-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.counter-value {
font-family: var(--font-mono);
font-size: 30px;
min-width: 60px;
text-align: center;
}
.counter-reset {
display: block;
margin: 0 auto;
}"