Демо-отчёт. Проект и код написаны для примера — в них специально оставлены частые ошибки тестовых заданий. Это не работа реального клиента.
CodeCheckupОтчёт по код-ревью

Интернет-магазин — тестовое задание

Каталог с поиском, карточка товара, корзина с сохранением между визитами. React 18, TypeScript, Vite.

Заявка
№DEMO
Стек
Frontend
Что проверяли
Тестовое задание
Уровень
Junior+
Репозиторий
демо-проект
Ревьюер
Frontend-разработчик CodeCheckup
Дата
20 октября 2026

Главное за минуту

4Исправить обязательно
3Желательно поправить
2На будущее

Хорошая основа: компоненты небольшие, типы на месте. До отправки исправьте 4 пункта P1 — это около 2–3 часов.

  1. Почините корзину: состояние меняется на месте, и экран не обновляется — P1-1. Проверяющий нажмёт «В корзину» в первую же минуту.
  2. Отменяйте устаревшие запросы поиска — P1-2. Сейчас старый ответ может затереть новый.
  3. Уберите dangerouslySetInnerHTML из карточки товара — P1-4. Вставка HTML из API — одна из первых вещей, которые ищут в React-коде.
  4. Добавьте состояния загрузки и ошибки в каталог — P1-3. Без сети сейчас просто пустая страница.
  5. Если останется время — ключи списка по id P2-1 и безопасное чтение корзины из localStorage P2-2.

Ответ на ваш вопрос

«Правильно ли я работаю с состоянием? Не уверен, что контекст для корзины — хорошая идея».

Контекст для корзины — нормальное решение для тестового. Корзина нужна в шапке, в каталоге и на странице оформления; Redux или Zustand здесь были бы лишними. Оставьте CartContext, но сделайте два изменения.

Первое — не меняйте состояние на месте P1-1. React сравнивает ссылки: если передать в setItems тот же массив, он решит, что ничего не изменилось.

Второе — вынесите логику в редьюсер. Добавление, удаление и изменение количества — три действия над одним массивом. С useReducer они собраны в одном месте и легко тестируются без рендера:

Как пойдут данные после исправлений
Кнопка «В корзину»dispatch({ type: "add", product })cartReducer
cartReducerновый массив itemsCartContextшапка, корзина, оформление
useEffect([items])localStorage

Сильные стороны

  • Строгий TypeScript: strict: true, типы ответов API описаны, в компонентах нет any.
  • Небольшие компоненты без логики: ProductCard, CartRow, PriceTag получают данные пропсами — их легко читать и переиспользовать.
  • Аккуратная вёрстка: сетка каталога перестраивается на телефоне без горизонтальной прокрутки, у кнопок есть состояния :hover и :focus-visible.
  • Понятный README: как запустить, какие решения приняты и почему. Это стоит упомянуть в сопроводительном письме.

Соответствие требованиям задания

Требование Статус Комментарий
Каталог с поиском частично частично работает, но ответы поиска гоняются — P1-2; нет загрузки и ошибки — P1-3
Карточка товара частично частично описание вставляется как HTML — P1-4
Корзина частично частично счётчик в шапке не обновляется после добавления — P1-1
Сохранение корзины выполнено с оговоркой: испорченные данные роняют приложение — P2-2
Адаптивная вёрстка выполнено от 360 px, без горизонтальной прокрутки
Тесты частично частично есть, но проверяют реализацию, а не поведение — P2-3

Замечания

Состояние и данные

Исправить обязательноP1-1

Корзина не обновляется на экране: состояние меняется на месте

Где
src/cart/CartContext.tsx:21–24
Время
~30 мин

Что не так. addItem добавляет товар в тот же массив и передаёт его в setItems. Ссылка не изменилась — React пропускает перерисовку. Товар появляется в корзине только после следующего несвязанного обновления.

Было · src/cart/CartContext.tsx
function addItem(product: Product) {
  items.push({ ...product, qty: 1 });
  setItems(items);
}

Как исправить. Всегда создавайте новый массив. Удобнее всего собрать действия в редьюсер:

Стало · src/cart/cartReducer.ts
export function cartReducer(items: CartItem[], action: CartAction): CartItem[] {
  switch (action.type) {
    case "add": {
      const existing = items.find((item) => item.id === action.product.id);
      return existing
        ? items.map((item) => (item.id === existing.id ? { ...item, qty: item.qty + 1 } : item))
        : [...items, { ...action.product, qty: 1 }];
    }
    case "remove":
      return items.filter((item) => item.id !== action.id);
  }
}
Исправить обязательноP1-2

Ответы поиска приходят не по порядку

Где
src/catalog/ProductSearch.tsx:18
Время
~30 мин

Что не так. Каждый ввод символа запускает fetch, предыдущие запросы не отменяются. Ответ на «кро» может прийти позже ответа на «кроссовки» и показать не те товары.

Было · src/catalog/ProductSearch.tsx
useEffect(() => {
  fetch(`/api/products?q=${query}`)
    .then((response) => response.json())
    .then(setItems);
}, [query]);

Как исправить. Отменяйте прошлый запрос в функции очистки эффекта и кодируйте параметр:

Стало · src/catalog/ProductSearch.tsx
useEffect(() => {
  const controller = new AbortController();
  searchProducts(query, controller.signal)
    .then(setItems)
    .catch(ignoreAbort);
  return () => controller.abort();
}, [query]);
Стало · src/api/products.ts
export async function searchProducts(query: string, signal: AbortSignal): Promise<Product[]> {
  const response = await fetch(`/api/products?q=${encodeURIComponent(query)}`, { signal });
  if (!response.ok) throw new ApiError(response.status);
  return response.json();
}

export function ignoreAbort(error: unknown) {
  if (!(error instanceof DOMException && error.name === "AbortError")) throw error;
}

Задержку перед запросом (300 мс) добавьте отдельно — это экономит запросы, но гонку не лечит.

Исправить обязательноP1-3

Без сети каталог просто пустой

Где
src/catalog/ProductList.tsx:9
Время
~1 ч

Пока товары грузятся и после ошибки компонент показывает пустую сетку. Пользователь не понимает, что происходит, и не может повторить. Храните три состояния явно:

Было · src/catalog/ProductList.tsx
const [products, setProducts] = useState<Product[]>([]);

useEffect(() => {
  fetchProducts().then(setProducts);
}, []);

return <ProductGrid products={products} />;
Стало · src/catalog/ProductList.tsx
type State =
  | { status: "loading" }
  | { status: "error"; retry: () => void }
  | { status: "ready"; products: Product[] };

if (state.status === "loading") return <ProductGridSkeleton />;
if (state.status === "error") return <ErrorMessage onRetry={state.retry} />;
return <ProductGrid products={state.products} />;

Если готовы взять библиотеку, TanStack Query даёт эти состояния, повтор и кэш из коробки — для тестового это уместно.

Безопасность

Исправить обязательноP1-4

Описание товара вставляется как HTML

Где
src/catalog/ProductCard.tsx:27
Время
~20 мин

Что не так. Описание из API выводится через dangerouslySetInnerHTML. Если в описание попадёт <img src=x onerror=…>, скрипт выполнится у каждого, кто откроет карточку.

Было · src/catalog/ProductCard.tsx
<p className="card__text" dangerouslySetInnerHTML={{ __html: product.description }} />

Как исправить. Описание — обычный текст, выводите его как текст. Если разметка действительно нужна, очищайте её через DOMPurify:

Стало · src/catalog/ProductCard.tsx
<p className="card__text">{product.description}</p>

Списки и хранение

Желательно поправитьP2-1

Индекс массива вместо ключа

Где
src/cart/CartList.tsx:12
Время
~10 мин

У CartRow есть своё состояние — поле количества. С key={index} после удаления товара React сопоставляет строки по позиции, и введённое количество «переезжает» к соседнему товару.

Было · src/cart/CartList.tsx
{items.map((item, index) => (
  <CartRow key={index} item={item} />
))}
Стало · src/cart/CartList.tsx
{items.map((item) => (
  <CartRow key={item.id} item={item} />
))}
Желательно поправитьP2-2

Испорченная корзина в localStorage роняет приложение

Где
src/cart/storage.ts:4
Время
~20 мин

JSON.parse без проверки: если в localStorage окажутся старые или чужие данные, приложение упадёт на старте. Читайте с защитой и проверяйте форму данных:

Было · src/cart/storage.ts
export function loadCart(): CartItem[] {
  return JSON.parse(localStorage.getItem("cart") ?? "[]");
}
Стало · src/cart/storage.ts
export function loadCart(): CartItem[] {
  try {
    const parsed: unknown = JSON.parse(localStorage.getItem("cart") ?? "[]");
    return Array.isArray(parsed) ? parsed.filter(isCartItem) : [];
  } catch {
    return [];
  }
}

Тесты

Желательно поправитьP2-3

Тесты проверяют реализацию, а не поведение

Где
src/cart/CartContext.test.tsx
Время
~1 ч

Тесты сверяют снапшоты и вызывают методы контекста напрямую. Они ломаются от любого рефакторинга и при этом не поймали P1-1: массив в снапшоте уже содержит товар, хотя экран не обновился. Проверяйте то, что видит пользователь, через Testing Library:

Было · src/cart/CartContext.test.tsx
test("addItem", () => {
  const { result } = renderHook(() => useCart(), { wrapper: CartProvider });
  act(() => result.current.addItem(sneakers));
  expect(result.current.items).toMatchSnapshot();
});
Стало · src/cart/Cart.test.tsx
test("товар появляется в корзине после добавления", async () => {
  render(<App />, { wrapper: TestProviders });
  await userEvent.click(await screen.findByRole("button", { name: "В корзину: Кроссовки" }));
  expect(screen.getByRole("status", { name: "Корзина" })).toHaveTextContent("1");
});

Отдельно протестируйте cartReducer — это чистая функция, тесты на неё пишутся за минуты.

На будущееP3-1

ESLint с правилами хуков и проверка в CI

Подключите eslint-plugin-react-hooks: правило exhaustive-deps подсказало бы часть проблем с эффектами. Простой GitHub Actions с npm ci && npm run lint && npm test показывает, что проект собирается не только у вас.

На будущееP3-2

Ленивая загрузка страницы оформления

Страница оформления заказа тянет форму и валидацию, которые не нужны в каталоге. React.lazy и Suspense уменьшат первый бандл — хороший пункт для раздела «Что можно улучшить» в README.

Что дальше

  1. P1-1 Корзина через новый массив и редьюсер — ~30 мин
  2. P1-2 Отмена устаревших запросов поиска — ~30 мин
  3. P1-4 Описание товара как текст — ~20 мин
  4. P1-3 Загрузка, ошибка и повтор в каталоге — ~1 ч
  5. P2-1, P2-2 и P2-3 — если останется время, ~1,5 ч

Вопросы по отчёту — 1 раунд в течение 7 дней, пишите в тот же чат. Доступ к репозиторию можно отозвать уже сейчас.

Что упомянуть в сопроводительном письме: строгий TypeScript и небольшие компоненты без логики, а после исправлений — редьюсер корзины с тестами.