Перейти к основному содержимому

РЕАЛЬНОЕ СОБЕСЕДОВАНИЕ С ИНТЕРЕСНЫМИ ЗАДАЧАМИ НА MIDDLE/SENIOR FRONTEND РАЗРАБОТЧИКА С ЗП 300К!

· 75 мин. чтения

Сегодня мы разберём собеседование на позицию фронтенд-разработчика, в ходе которого кандидат продемонстрировал уверенное владение базовыми концепциями React, типами в TypeScript, принципами работы с состоянием (Redux/MobX) и навыки анализа макетов. Интервьюер последовательно проверял как теоретические знания (жизненный цикл рендеринга, позиционирование CSS, чистые функции, иммутабельность), так и практические навыки — от реализации простых задач до проектирования компонентной архитектуры и взаимодействия с API. Кандидат показал хороший уровень понимания экосистемы React, умение рассуждать об оптимизации и масштабируемости кода, а также осознанный подход к работе с формами и типами.

Вопрос 1. Опиши как можно подробно всё, что происходит в браузере с момента ввода URL в адресную строку и нажатия Enter до момента, когда на странице отрисуется контент.

Таймкод: 00:01:02

Ответ собеседника: Правильный. Сначала парсится URL, затем ищется IP-адрес в кэше браузера, роутера и устройства. Если IP не найден — отправляется DNS-запрос на DNS-сервер, возвращается IP и сохраняется в кэш. Далее происходит трёхстороннее TCP-рукопожатие и устанавливается соединение. Затем отправляется HTTP-запрос, возвращается HTML-страница. После этого строится DOM-дерево, CSSOM-дерево, на их основе — render tree. Затем для каждого элемента рассчитываются ширина, высота и положение (layout), происходит отрисовка элементов (painting), и последний этап — composition, когда слои разделяются и анимации выполняются на GPU.

Правильный ответ:

Ответ собеседника в целом корректен и покрывает основные этапы. Ниже приведено более детальное и структурированное описание всего процесса.


1. Ввод URL и парсинг

Когда пользователь вводит URL (например, https://example.com/page) и нажимает Enter, браузер сначала парсит строку:

  • Определяет протокол (https), хост (example.com), путь (/page), порт (по умолчанию 443 для HTTPS).
  • Если введён просто текст без протокола (например, example.com), браузер может сначала выполнить поисковый запрос, а не навигацию — это зависит от контекста и настроек.
  • Если URL содержит специальные символы, они кодируются (percent-encoding).

2. Проверка кэшей

Прежде чем делать сетевой запрос, браузер проверяет несколько уровней кэширования:

  • Кэш браузера (HTTP Cache) — если ранее запрашивался этот ресурс и ответ ещё свежий (по заголовкам Cache-Control, Expires), браузер использует кэшированную версию.
  • Service Worker — если зарегистрирован service worker, он может перехватить запрос и вернуть ответ из своего кэша.
  • Кэш DNS — браузер проверяет свой DNS-кэш, затем кэш ОС, затем кэш роутера.

3. DNS-резолвинг

Если IP-адрес не найден в кэше:

  • Браузер отправляет DNS-запрос на настроенный DNS-резолвер (обычно рекурсивный DNS-сервер провайдера).
  • Рекурсивный резолвер последовательно опрашивает root-серверы → TLD-серверы (.com) → авторитативные серверы (example.com).
  • Возвращается IP-адрес, который кэшируется на всех уровнях (TTL определяется DNS-записью).
  • Для HTTPS может использоваться DNS-over-HTTPS (DoH) для приватности.

4. Установление TCP-соединения

  • Выполняется трёхстороннее рукопожатие (three-way handshake):
    • Клиент отправляет SYN на сервер.
    • Сервер отвечает SYN-ACK.
    • Клиент подтверждает ACK.
  • После этого TCP-соединение установлено и готово к передаче данных.

5. TLS-рукопожатие (для HTTPS)

Для HTTPS-соединения поверх TCP устанавливается TLS-туннель:

  • Клиент отправляет ClientHello с поддерживаемыми версиями TLS, наборами шифров, случайным числом.
  • Сервер отвечает ServerHello с выбранным набором шифров, сертификатом, своим случайным числом.
  • Клиент проверяет сертификат (цепочка доверия, срок действия, CRL/OCSP).
  • Обе стороны генерируют общий секретный ключ (через ECDHE или аналог).
  • Обмениваются сообщениями Finished — соединение зашифровано.

6. Отправка HTTP-запроса

  • Браузер формирует HTTP-запрос (обычно GET /page HTTP/1.1 или HTTP/2) с заголовками:
    • Host: example.com
    • User-Agent, Accept, Accept-Encoding, Accept-Language
    • Cookie (если есть cookies для этого домена)
    • Connection: keep-alive
  • Запрос отправляется через установленное зашифрованное соединение.

7. Обработка запроса сервером

Сервер получает запрос и:

  • Может выполнить маршрутизацию, аутентификацию, бизнес-логику.
  • Сформировать HTML-ответ (динамически или отдать статику).
  • Установить заголовки ответа: Content-Type, Set-Cookie, Cache-Control, Content-Encoding и др.
  • Вернуть ответ с кодом статуса (200, 301, 304 и т.д.).

8. Получение и парсинг HTML

  • Браузер получает HTML-документ и начинает инкрементальный парсинг.
  • Парсер HTML работает потоково — не ждёт полной загрузки документа.
  • По мере парсинга строится DOM-дерево (Document Object Model) — древовидная структура, представляющая все элементы страницы.

9. Загрузка подресурсов

В процессе парсинга HTML обнаруживаются ссылки на внешние ресурсы:

  • CSS-файлы (<link rel="stylesheet">) — блокируют рендеринг (render-blocking).
  • JavaScript-файлы (<script>) — по умолчанию блокируют парсинг HTML (можно использовать async/defer).
  • Изображения, шрифты, видео — загружаются параллельно, не блокируют парсинг.

Для каждого ресурса повторяется процесс DNS → TCP → TLS → HTTP-запрос (с учётом HTTP/2 мультиплексирования и кэширования).

10. Построение CSSOM

  • Загруженные CSS-файлы парсятся и строится CSSOM (CSS Object Model) — дерево стилей с каскадными правилами.
  • CSSOM блокирует рендеринг, так как без него невозможно определить визуальное представление элементов.

11. Выполнение JavaScript

  • При обнаружении <script> парсинг HTML приостанавливается, скрипт загружается и выполняется.
  • JavaScript может модифицировать DOM и CSSOM через API (document.getElementById, element.style и др.).
  • Скрипты с async загружаются параллельно и выполняются сразу после загрузки.
  • Скрипты с defer загружаются параллельно, но выполняются после полного парсинга HTML.

12. Построение Render Tree

  • На основе DOM и CSSOM строится Render Tree — дерево видимых элементов с вычисленными стилями.
  • В Render Tree не включаются невидимые элементы (display: none, <head>, <meta> и др.).
  • Каждый узел Render Tree содержит информацию о геометрии и стилях.

13. Layout (Reflow)

  • Для каждого узла Render Tree вычисляется точная геометрия: координаты (x, y), размеры (width, height).
  • Этот процесс называется layout или reflow.
  • Он может быть дорогим при сложных макетах или изменениях DOM.

14. Paint (Отрисовка)

  • Каждый узел Render Tree преобразуется в пиксели — заполняются цвета, границы, тени, текст.
  • Отрисовка выполняется в несколько слоёв для оптимизации (например, отдельный слой для элемента с transform).

15. Composition (Композиция)

  • Слои объединяются в финальное изображение экрана.
  • Composition часто выполняется на GPU для ускорения анимаций и скролла.
  • Изменения свойств transform и opacity могут обрабатываться только на этапе composition без reflow и repaint — это самый производительный способ анимации.

16. Отображение контента

  • После composition финальный кадр отправляется на экран.
  • Пользователь видит отрисованную страницу.

Дополнительные важные моменты:

  • Preload Scanner — браузер заранее сканирует HTML и начинает загружать ресурсы до того, как парсер до них доберётся.
  • Critical Rendering Path — это последовательность шагов от HTML до пикселей на экране. Оптимизация этого пути критична для производительности.
  • HTTP/2 и HTTP/3 — мультиплексирование, server push, QUIC-протокол влияют на скорость загрузки ресурсов.
  • Lazy Loading — изображения и компоненты могут загружаться по мере необходимости (атрибут loading="lazy").
  • Prefetch/Preconnect — браузер может заранее устанавливать соединения или загружать ресурсы на основе подсказок (<link rel="preconnect">, <link rel="prefetch">).

Вопрос 2. Расскажи про свойство position в CSS: какие у него есть значения и что происходит с элементам при их использовании.

Таймкод: 00:02:51

Ответ собеседника: Правильный. Static — значение по умолчанию, элемент в потоке. Fixed — позиционирование относительно окна браузера, элемент закрепляется при скролле. Sticky — вначале ведёт себя как static, а при скролле, достигнув границы родителя, прилипает. Absolute — вырывает элемент из потока, позиционируется относительно ближайшего предка с position, отличным от static. Relative — остаётся в потоке, позволяет сдвигать элемент через top/left/right/bottom и служит контейнером для абсолютно позиционированных дочерних элементов.

Правильный ответ:

Ответ собеседника корректен и покрывает основные значения. Ниже приведено более детальное описание с важными нюансами.


1. static (значение по умолчанию)

  • Элемент находится в нормальном потоке документа (normal flow).
  • Свойства top, right, bottom, left, z-index не действуют на элемент со static.
  • Элементы располагаются друг за другом в порядке следования в HTML (для блочных — вертикально, для строчных — горизонтально).
  • Это единственное значение, при котором элемент не создаёт контекст позиционирования для потомков.

2. relative

  • Элемент остаётся в нормальном потоке — его исходное место резервируется (другие элементы не сдвигаются).
  • Свойства top, right, bottom, left сдвигают элемент относительно его исходного положения.
  • z-index начинает работать.
  • Элемент с position: relative создаёт контекст позиционирования для дочерних элементов с position: absolute — они будут позиционироваться относительно этого элемента.
  • Это самый частый способ создать «контейнер» для абсолютного позиционирования без побочных эффектов.

Пример:

.parent {
position: relative;
width: 300px;
height: 200px;
}

.child {
position: absolute;
top: 10px;
right: 10px;
}

Здесь .child будет расположен в правом верхнем углу .parent.

3. absolute

  • Элемент полностью извлекается из нормального потока — его место не резервируется, соседние элементы сдвигаются, как будто элемента нет.
  • Позиционируется относительно ближайшего предка с position, отличным от static (т.е. relative, absolute, fixed, sticky).
  • Если такого предка нет — позиционируется относительно initial containing block (обычно это <html>, viewport).
  • top, right, bottom, left задают смещение от границ контейнера позиционирования.
  • z-index работает.
  • Элемент с absolute также создаёт контекст позиционирования для своих абсолютных потомков.

Важный нюанс: элемент с position: absolute теряет свои обычные размеры — если не заданы явные width/height и все четыре свойства top/right/bottom/left, элемент сожмётся до размеров содержимого (shrink-to-fit).

4. fixed

  • Элемент извлекается из нормального потока (аналогично absolute).
  • Позиционируется относительно viewport (окна браузера), а не какого-либо предка.
  • Остаётся на месте при скролле страницы — «приклеивается» к экрану.
  • Часто используется для фиксированных шапок, навигации, модальных окон, кнопок «наверх».
  • Создаёт контекст позиционирования для потомков.

Важный нюанс: если предок имеет свойства transform, filter, will-change (с определёнными значениями) или contain: paint, то fixed элемент будет позиционироваться относительно этого предка, а не viewport. Это частый источник путаницы.

5. sticky

  • Гибрид relative и fixed.
  • До достижения порогового значения (top, left и т.д.) элемент ведёт себя как relative (в потоке).
  • При скролле, когда элемент достигает заданного смещения от края viewport, он «прилипает» — ведёт себя как fixed.
  • Прилипание происходит в пределах родительского контейнера — когда контейнер прокручивается за пределы viewport, элемент уходит вместе с ним.
  • Обязательно нужно указать хотя бы одно из свойств top, right, bottom, left — иначе работать не будет.

Пример:

.sticky-header {
position: sticky;
top: 0;
z-index: 100;
}

Заголовок будет прилипать к верху экрана при скролле, пока не выйдет за пределы своего родителя.

Важные ограничения sticky:

  • Родительский элемент не должен иметь overflow: hidden или overflow: auto — это ломает прилипание.
  • Работает только в пределах родительского контейнера — не может «прилипнуть» за его границами.

Сравнительная таблица:

ЗначениеВ потоке?Относительно чего позиционируетсяСоздаёт контекст позиционирования
staticДа— (нормальный поток)Нет
relativeДа (место резервируется)Своего исходного положенияДа
absoluteНетБлижайшего позиционированного предкаДа
fixedНетViewport (или предка с transform)Да
stickyДа (до срабатывания)Затем viewport в пределах родителяДа

Дополнительные важные моменты:

  • Stacking context (контекст наложения): элементы с position отличным от static и z-index отличным от auto создают новый контекст наложения, что влияет на порядок отрисовки.
  • Производительность: fixed и sticky элементы часто выносятся в отдельный слой композиции (GPU layer), что хорошо для производительности скролла.
  • Проблема с transform: свойство transform на предке создаёт новый containing block для fixed потомков — это стоит учитывать при проектировании анимаций.

Вопрос 3. Есть абсолютно позиционированный элемент. Как его центрировать внутри родителя?

Таймкод: 00:04:35

Ответ собеседника: Правильный. Родителю задать position: relative, а элементу — position: absolute, top: 50%, left: 50%, transform: translate(-50%, -50%).

Правильный ответ:

Ответ собеседника описывает самый универсальный и часто используемый способ. Ниже приведены все основные методы центрирования абсолютно позиционированного элемента.


1. Метод с transform: translate (самый универсальный)

Это способ, который назвал собеседник. Он работает даже когда размеры элемента неизвестны.

.parent {
position: relative;
width: 400px;
height: 300px;
}

.child {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

Как это работает:

  • top: 50% и left: 50% размещают левый верхний угол элемента в центре родителя (проценты считаются от размеров родителя).
  • transform: translate(-50%, -50%) сдвигают элемент назад на половину его собственной ширины и высоты (проценты считаются от размеров самого элемента).
  • Результат — элемент точно по центру.

Плюсы: работает при любых размерах элемента, не требует знания размеров.


2. Метод с margin: auto

.parent {
position: relative;
width: 400px;
height: 300px;
}

.child {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: auto;
}

Как это работает:

  • Все четыре свойства top, right, bottom, left установлены в 0 — элемент «растягивается» на весь родитель.
  • margin: auto заставляет браузер распределить оставшееся пространство равномерно по всем сторонам, центрируя элемент.
  • Для корректной работы элемент должен иметь явно заданные width и height.

Плюсы: не требует transform, чистый CSS. Минусы: необходимо знать или задавать размеры элемента.


3. Метод с отрицательными margin (классический)

.parent {
position: relative;
width: 400px;
height: 300px;
}

.child {
position: absolute;
top: 50%;
left: 50%;
width: 200px;
height: 100px;
margin-top: -50px; /* половина высоты */
margin-left: -100px; /* половина ширины */
}

Как это работает:

  • Аналогично первому методу, но вместо transform используются отрицательные margin.
  • Значения margin должны быть равны половине размеров элемента (с отрицательным знаком).

Плюсы: поддержка в старых браузерах (IE8+). Минусы: необходимо точно знать размеры элемента, при изменении размеров нужно пересчитывать margin.


4. Flexbox на родителе (современный подход)

Хотя вопрос именно про абсолютно позиционированный элемент, стоит упомянуть, что во многих случаях можно отказаться от position: absolute и использовать flexbox:

.parent {
display: flex;
justify-content: center;
align-items: center;
width: 400px;
height: 300px;
}

Элемент-потомок автоматически центрируется без необходимости задавать ему position: absolute.

Плюсы: самый простой и гибкий способ, не требует знания размеров. Минусы: элемент остаётся в потоке flex-контейнера, что может не подходить для некоторых сценариев (оверлеи, модальные окна).


Сравнение методов:

МетодНужно знать размеры?Поддержка браузерамиСложность
transform: translateНетIE9+Простой
margin: autoДаВсеПростой
Отрицательные marginДаВсеСредний
FlexboxНетIE10+ (с префиксами)Простой

Рекомендация: метод с transform: translate(-50%, -50%) является наиболее универсальным и рекомендуемым для абсолютно позиционированных элементов, так как не требует знания размеров и хорошо поддерживается всеми современными браузерами.

Вопрос 4. Что такое управляемые (контролируемые) и неуправляемые (неконтролируемые) компоненты в React? В чём ключевая разница между ними?

Таймкод: 00:05:39

Ответ собеседника: Правильный. Контролируемый компонент — значение инпута связано со state через value и onChange, состояние хранится в React. Неконтролируемый — состояние хранится в DOM, значение можно получить через ref, без привязки к state. Ключевая разница: контролируемые хранят состояние в React state, неконтролируемые — в DOM-дереве.

Правильный ответ:

Ответ собеседника корректен. Ниже приведено более детальное описание с примерами кода и сравнением подходов.


Управляемые (Controlled) компоненты

В управляемом компоненте состояние формы полностью контролируется React. Значение элемента ввода хранится в состоянии компонента и обновляется через обработчики событий.

Пример управляемого компонента:

import React, { useState } from 'react';

function ControlledInput() {
const [value, setValue] = useState('');

const handleChange = (e) => {
setValue(e.target.value);
};

const handleSubmit = () => {
console.log('Отправленное значение:', value);
};

return (
<div>
<input
type="text"
value={value}
onChange={handleChange}
/>
<button onClick={handleSubmit}>Отправить</button>
</div>
);
}

Ключевые признаки управляемого компонента:

  • Свойство value (или checked для чекбоксов) привязано к состоянию React.
  • Обработчик onChange (или onInput) обновляет состояние при каждом изменении.
  • React является «единственным источником истины» (single source of truth) для значения поля.

Неуправляемые (Uncontrolled) компоненты

В неуправляемом компоненте состояние формы хранится непосредственно в DOM. React не контролирует значение элемента — оно управляется самим DOM-узлом.

Пример неуправляемого компонента:

import React, { useRef } from 'react';

function UncontrolledInput() {
const inputRef = useRef(null);

const handleSubmit = () => {
console.log('Отправленное значение:', inputRef.current.value);
};

return (
<div>
<input
type="text"
ref={inputRef}
defaultValue="начальное значение"
/>
<button onClick={handleSubmit}>Отправить</button>
</div>
);
}

Ключевые признаки неуправляемого компонента:

  • Свойство value не задаётся (или задаётся defaultValue для начального значения).
  • Для получения текущего значения используется useRef или document.getElementById.
  • DOM является «источником истины» для значения поля.

Сравнение подходов:

КритерийУправляемыйНеуправляемый
Где хранится состояниеВ React stateВ DOM
Как получить значениеИз state в любой моментЧерез ref или DOM API
Начальное значениеЧерез useState('начальное')Через defaultValue
Валидация в реальном времениЛегко — state обновляется при каждом вводеСложнее — нужно слушать события
ПроизводительностьРерендер при каждом вводе символаНет лишних ререндеров
Сложность кодаБольше кодаМеньше кода
Интеграция с React экосистемойОтличнаяОграниченная

Когда использовать управляемые компоненты:

  • Нужна мгновенная валидация при вводе.
  • Требуется условное отключение кнопки отправки.
  • Нужно форматировать ввод в реальном времени (маски, ограничения).
  • Значение зависит от других полей формы.
  • Используются библиотеки форм (Formik, React Hook Form в controlled-режиме).

Когда использовать неуправляемые компоненты:

  • Простые формы без сложной валидации.
  • Интеграция с не-React библиотеками (jQuery плагины, нативные формы).
  • Работа с файловыми инпутами (<input type="file"> — всегда неуправляемый).
  • Критична производительность при частых обновлениях.
  • Нужно просто получить значение при отправке формы.

Важный нюанс с React Hook Form:

Библиотека React Hook Form использует гибридный подход — она регистрирует поля через ref, но при этом предоставляет API, похожий на управляемые компоненты. Это позволяет минимизировать ререндеры, сохраняя удобство работы:

import { useForm } from 'react-hook-form';

function Form() {
const { register, handleSubmit } = useForm();

const onSubmit = (data) => {
console.log(data);
};

return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
<button type="submit">Отправить</button>
</form>
);
}

Этот подход сочетает преимущества обоих паттернов и является рекомендуемым в современных React-приложениях.

Вопрос 5. Прилетел макет из Фигмы. С чего начать его анализ, если бэкенда ещё нет и нужно выстроить взаимодействие с бэкенд-командой?

Таймкод: 00:07:14

Ответ собеседника: Неполный. Начал с определения сущностей и методов запросов. Выделил сущности: комплекс камер, камера, видеозапись, пользователь, расчёт. Ответ неполный — не упомянуты UI-компоненты, роутинг, адаптивность, требования к API (пагинация, фильтрация, сортировка).

Правильный ответ:

Ответ собеседника затронул только один аспект — выделение сущностей. Ниже приведён полный структурированный подход к анализу макета.


1. Общий обзор макета

Прежде чем углубляться в детали, необходимо получить общее представление:

  • Определить все страницы/экраны приложения.
  • Понять навигационную структуру — как пользователь перемещается между экранами.
  • Выделить ключевые пользовательские сценарии (user flows): что пользователь может делать в приложении.
  • Определить целевые устройства и адаптивность (desktop, tablet, mobile).

2. Анализ UI-компонентов и переиспользуемых элементов

  • Выделить атомарные компоненты: кнопки, инпуты, чекбоксы, выпадающие списки, карточки, бейджи.
  • Определить составные компоненты: формы, модальные окна, таблицы, списки, навигационные панели.
  • Зафиксировать состояния компонентов: default, hover, active, disabled, loading, error, empty state.
  • Создать предварительный дизайн-системный словарь — типографика, цвета, отступы, тени.

3. Определение сущностей и их атрибутов

Как верно начал собеседник, нужно выделить сущности:

  • Сущности (entities): комплекс камер, камера, видеозапись, пользователь, расчёт.
  • Для каждой сущности определить атрибуты — какие поля отображаются в макете.
  • Определить связи между сущностями: один-ко-многим, многие-ко-многим.

Пример для «Камеры»:

Camera {
id: string
name: string
status: online | offline | error
location: string
lastRecording: datetime
resolution: string
complexId: string // связь с комплексом
}

4. Определение API-контракта

Это ключевой шаг для взаимодействия с бэкенд-командой:

  • Эндпоинты: какие ресурсы и операции нужны (CRUD для каждой сущности).
  • Методы запросов: GET (список, детальная), POST (создание), PUT/PATCH (обновление), DELETE.
  • Пагинация: какие списки могут быть большими — нужна ли пагинация, infinite scroll, cursor-based.
  • Фильтрация и сортировка: какие поля можно фильтровать и сортировать (например, камеры по статусу, записи по дате).
  • Параметры запросов: query-параметры для фильтров, сортировки, пагинации.

Пример API-контракта:

GET /api/cameras
?complex_id=123
&status=online
&sort=-lastRecording
&page=1
&limit=20

Response:
{
"data": [...],
"meta": {
"total": 150,
"page": 1,
"limit": 20
}
}

5. Анализ форм и валидации

  • Выделить все формы в макете.
  • Определить обязательные и необязательные поля.
  • Зафиксировать требования к валидации: формат email, длина пароля, диапазоны чисел.
  • Определить поведение при ошибках: где и как отображаются сообщения об ошибках.

6. Анализ состояний загрузки и ошибок

  • Определить, какие компоненты могут быть в состоянии загрузки (skeleton, spinner).
  • Продумать поведение при ошибках сети: уведомления, retry-механизмы.
  • Определить empty states — что показывать, когда данных нет.

7. Определение клиентского роутинга

  • Составить карту маршрутов приложения.
  • Определить защищённые маршруты (требующие авторизации).
  • Продумать поведение при переходах между страницами (loading states).

8. Анализ адаптивности

  • Проверить, есть ли макеты для разных разрешений.
  • Определить breakpoints и поведение компонентов на разных экранах.
  • Зафиксировать, какие элементы скрываются/трансформируются на мобильных устройствах.

9. Документирование и коммуникация с бэкенд-командой

  • Составить API-спецификацию (можно в формате OpenAPI/Swagger).
  • Подготовить маппинг «экран → эндпоинты» — какие данные нужны для каждого экрана.
  • Определить приоритеты: какие эндпоинты нужны первыми для начала разработки.
  • Договориться о формате ответов (JSON:API, собственный формат), обработке ошибок (коды статусов, структура ошибок).

Резюме — чек-лист анализа макета:

  1. Все экраны и навигация
  2. UI-компоненты и их состояния
  3. Сущности, атрибуты и связи
  4. API-контракт (эндпоинты, методы, пагинация, фильтры)
  5. Формы и валидация
  6. Состояния загрузки, ошибок и пустые состояния
  7. Роутинг и защищённые маршруты
  8. Адаптивность
  9. Документация для бэкенд-команды

Вопрос 6. Какие бывают типы связей между сущностями в базе данных (в контексте ORM)?

Таймкод: 00:12:58

Ответ собеседника: Неполный. Назвал: one-to-one, one-to-many, many-to-many. Не упомянул many-to-one.

Правильный ответ:

Ответ собеседника покрыл основные типы, но пропустил важный нюанс. Ниже приведено полное описание всех типов связей с примерами.


1. One-to-One (один-к-одному)

Одна запись в таблице A связана ровно с одной записью в таблице B, и наоборот.

Пример: Пользователь и его профиль. У каждого пользователя один профиль, и каждый профиль принадлежит одному пользователю.

-- Таблица users
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL
);

-- Таблица profiles
CREATE TABLE profiles (
id SERIAL PRIMARY KEY,
user_id INTEGER UNIQUE REFERENCES users(id),
first_name VARCHAR(100),
last_name VARCHAR(100),
avatar_url TEXT
);

В контексте ORM (например, GORM для Go или Django ORM):

// GORM
type User struct {
ID uint
Email string
Profile Profile
}

type Profile struct {
ID uint
UserID uint `gorm:"uniqueIndex"`
FirstName string
LastName string
}
# Django ORM
class User(models.Model):
email = models.EmailField(unique=True)

class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)

2. One-to-Many / Many-to-One (один-ко-многим / многие-к-одному)

Это два взгляда на одну и ту же связь:

  • One-to-Many: одна запись в таблице A связана с несколькими записями в таблице B.
  • Many-to-One: несколько записей в таблице B ссылаются на одну запись в таблице A.

Пример: Автор и его статьи. Один автор может написать много статей, но каждая статья принадлежит одному автору.

CREATE TABLE authors (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL
);

CREATE TABLE articles (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
author_id INTEGER REFERENCES authors(id)
);
// GORM — с точки зрения автора (One-to-Many)
type Author struct {
ID uint
Name string
Articles []Article
}

// GORM — с точки зрения статьи (Many-to-One)
type Article struct {
ID uint
Title string
AuthorID uint
Author Author
}
# Django ORM
class Author(models.Model):
name = models.CharField(max_length=255)

class Article(models.Model):
title = models.CharField(max_length=255)
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name='articles')

Важно: с точки зрения базы данных это всегда реализуется через внешний ключ (foreign key) в таблице «многих». Разница между One-to-Many и Many-to-One — только в перспективе, с какой стороны вы смотрите на связь.


3. Many-to-Many (многие-ко-многим)

Несколько записей в таблице A могут быть связаны с несколькими записями в таблице B.

Пример: Студенты и курсы. Один студент может посещать много курсов, и на одном курсе может быть много студентов.

CREATE TABLE students (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL
);

CREATE TABLE courses (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL
);

-- Промежуточная таблица (junction table / join table)
CREATE TABLE enrollments (
student_id INTEGER REFERENCES students(id),
course_id INTEGER REFERENCES courses(id),
enrolled_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (student_id, course_id)
);
// GORM
type Student struct {
ID uint
Name string
Courses []Course `gorm:"many2many:enrollments;"`
}

type Course struct {
ID uint
Title string
Students []Student `gorm:"many2many:enrollments;"`
}
# Django ORM
class Student(models.Model):
name = models.CharField(max_length=255)
courses = models.ManyToManyField('Course', through='Enrollment')

class Course(models.Model):
title = models.CharField(max_length=255)

class Enrollment(models.Model):
student = models.ForeignKey(Student, on_delete=models.CASCADE)
course = models.ForeignKey(Course, on_delete=models.CASCADE)
enrolled_at = models.DateTimeField(auto_now_add=True)

Дополнительные поля в промежуточной таблице:

Иногда промежуточная таблица содержит дополнительные данные о связи — дату записи, роль, статус и т.д. В этом случае в ORM нужно явно указать through модель.


4. Self-referencing (самоссылочная связь)

Таблица ссылается сама на себя. Это может быть One-to-Many или Many-to-Many.

Пример One-to-Many: Категории с подкатегориями.

CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
parent_id INTEGER REFERENCES categories(id)
);
type Category struct {
ID uint
Name string
ParentID *uint
Parent *Category
Children []Category
}

Пример Many-to-Many: Подписки между пользователями (пользователь может подписываться на многих пользователей).

CREATE TABLE user_follows (
follower_id INTEGER REFERENCES users(id),
following_id INTEGER REFERENCES users(id),
PRIMARY KEY (follower_id, following_id)
);

5. Polymorphic Associations (полиморфные связи)

Одна модель может принадлежать разным типам моделей через одну связь.

Пример: Комментарии, которые могут относиться и к статьям, и к видео.

CREATE TABLE comments (
id SERIAL PRIMARY KEY,
body TEXT NOT NULL,
commentable_id INTEGER NOT NULL,
commentable_type VARCHAR(50) NOT NULL -- 'article' или 'video'
);
// GORM
type Comment struct {
ID uint
Body string
CommentableID uint
CommentableType string
}
# Django ContentType framework
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType

class Comment(models.Model):
body = models.TextField()
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')

Сводная таблица:

Тип связиОписаниеРеализация в БД
One-to-OneA ↔ B (1:1)FK с UNIQUE в одной из таблиц
One-to-ManyA → B (1:N)FK в таблице «многих»
Many-to-OneB → A (N:1)FK в таблице «многих» (обратная перспектива)
Many-to-ManyA ↔ B (M:N)Промежуточная таблица с двумя FK
Self-referencingA → AFK, ссылающийся на ту же таблицу
PolymorphicA → B или CДва поля: *_id и *_type

Вопрос 7. Между сущностями «видеозапись» и «пользователь» какая связь?

Таймкод: 00:14:06

Ответ собеседника: Правильный. Один пользователь может загрузить много видео — связь one-to-many.

Правильный ответ:

Ответ собеседника корректен. Связь между «пользователем» и «видеозаписью» — это One-to-Many (один-ко-многим) с точки зрения пользователя, или Many-to-One (многие-к-одному) с точки зрения видеозаписи.

Логика:

  • Один пользователь может загрузить (или иметь доступ к) множеству видеозаписей.
  • Каждая видеозапись принадлежит одному пользователю (тому, кто её загрузил, или которому она назначена).

Реализация в базе данных:

CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
name VARCHAR(255)
);

CREATE TABLE video_recordings (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
url TEXT NOT NULL,
duration INTEGER, -- в секундах
recorded_at TIMESTAMP,
user_id INTEGER REFERENCES users(id)
);

Внешний ключ user_id в таблице video_recordings реализует эту связь.

Реализация в ORM (на примере GORM):

type User struct {
ID uint
Email string
Name string
VideoRecordings []VideoRecording
}

type VideoRecording struct {
ID uint
Title string
URL string
Duration int
RecordedAt time.Time
UserID uint
User User
}

Важный нюанс — уточнение бизнес-логики:

В реальном приложении стоит уточнить у заказчика:

  • Может ли видеозапись принадлежать нескольким пользователям (например, общий доступ)? Если да — связь становится Many-to-Many.
  • Может ли видеозапись не иметь владельца (системная запись)? Тогда user_id должен быть nullable.
  • Нужно ли отслеживать историю доступа — кто просматривал запись? Это уже отдельная связь Many-to-Many с промежуточной таблицей.

В контексте данного вопроса стандартный ответ — One-to-Many — является правильным и достаточным.

Вопрос 8. Есть функция, принимающая дату и тайм-аут. Нужно реализовать её так, чтобы она возвращала переданную дату через указанный тайм-аут. Также: можно ли типизировать возвращаемое значение через дженерик?

Таймкод: 00:14:58

Ответ собеседника: Правильный. Вернуть промис, резолвящийся через setTimeout с переданной датой. Для типизации использовать дженерик.

Правильный ответ:

Ответ собеседника корректен. Ниже приведена реализация с примерами на TypeScript и Go.


Реализация на TypeScript

Базовая реализация с дженериком:

function delayReturn<T>(value: T, timeout: number): Promise<T> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(value);
}, timeout);
});
}

// Использование
async function main() {
const date = new Date();
console.log('Начало:', new Date().toISOString());

const result = await delayReturn(date, 3000);
console.log('Результат через 3 секунды:', result.toISOString());
}

main();

Расширенная версия с возможностью отмены:

interface CancellableDelay<T> {
promise: Promise<T>;
cancel: () => void;
}

function delayReturnCancellable<T>(
value: T,
timeout: number
): CancellableDelay<T> {
let timeoutId: ReturnType<typeof setTimeout>;
let rejectFn: (reason: Error) => void;

const promise = new Promise<T>((resolve, reject) => {
rejectFn = reject;
timeoutId = setTimeout(() => {
resolve(value);
}, timeout);
});

const cancel = () => {
clearTimeout(timeoutId);
rejectFn(new Error('Delay was cancelled'));
};

return { promise, cancel };
}

// Использование
async function main() {
const { promise, cancel } = delayReturnCancellable(new Date(), 5000);

// Отменяем через 2 секунды
setTimeout(cancel, 2000);

try {
const result = await promise;
console.log('Результат:', result);
} catch (error) {
console.log('Отменено:', error.message);
}
}

main();

Типизация через дженерик:

Дженерик <T> позволяет функции работать с любым типом возвращаемого значения:

// С датой
const datePromise: Promise<Date> = delayReturn(new Date(), 1000);

// Со строкой
const stringPromise: Promise<string> = delayReturn('hello', 1000);

// С объектом
interface User {
id: number;
name: string;
}
const userPromise: Promise<User> = delayReturn({ id: 1, name: 'John' }, 1000);

// TypeScript автоматически выводит тип
const autoPromise = delayReturn(42, 1000); // Promise<number>

Реализация на Go

В Go нет дженериков в классическом понимании (до Go 1.18), но начиная с Go 1.18 появились параметризованные типы:

package main

import (
"fmt"
"time"
)

// Функция с дженериком (Go 1.18+)
func DelayReturn[T any](value T, timeout time.Duration) <-chan T {
ch := make(chan T, 1)
go func() {
time.Sleep(timeout)
ch <- value
}()
return ch
}

func main() {
date := time.Now()
fmt.Println("Начало:", date.Format(time.RFC3339))

resultCh := DelayReturn(date, 3*time.Second)
result := <-resultCh

fmt.Println("Результат через 3 секунды:", result.Format(time.RFC3339))
}

Версия с контекстом для отмены:

package main

import (
"context"
"fmt"
"time"
)

func DelayReturnWithContext[T any](
ctx context.Context,
value T,
timeout time.Duration,
) (T, error) {
select {
case <-time.After(timeout):
return value, nil
case <-ctx.Done():
var zero T
return zero, ctx.Err()
}
}

func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

date := time.Now()
result, err := DelayReturnWithContext(ctx, date, 3*time.Second)
if err != nil {
fmt.Println("Ошибка:", err)
return
}
fmt.Println("Результат:", result)
}

Без дженериков (классический подход Go):

// Используем interface{} (any)
func DelayReturnAny(value interface{}, timeout time.Duration) <-chan interface{} {
ch := make(chan interface{}, 1)
go func() {
time.Sleep(timeout)
ch <- value
}()
return ch
}

// С type assertion при получении результата
func main() {
ch := DelayReturnAny(time.Now(), 3*time.Second)
result := <-ch
date := result.(time.Time) // type assertion
fmt.Println("Результат:", date)
}

Ответ на вопрос про дженерики:

Да, типизировать возвращаемое значение через дженерик не только можно, но и рекомендуется. Дженерик позволяет:

  • Сохранить тип переданного значения без приведения типов.
  • Избежать использования any/interface{}, что повышает типобезопасность.
  • Компилятор проверит корректность использования на этапе компиляции.

Примеры дженериков в разных языках:

// TypeScript
function delayReturn<T>(value: T, timeout: number): Promise<T>
// Go 1.18+
func DelayReturn[T any](value T, timeout time.Duration) <-chan T
// Java
public <T> CompletableFuture<T> delayReturn(T value, long timeout) { ... }
// C#
public async Task<T> DelayReturn<T>(T value, int timeout) { ... }

Вопрос 9. Есть React-компонент с состоянием (isGoogle) и дочерний компонент Test с рандомным числом в стейте. Что произойдёт с числом при нажатии кнопки? Почему? Можно ли заставить компонент перерендериться без изменения пропсов?

Таймкод: 00:17:42

Ответ собеседника: Правильный. Число останется прежним, так как структура дерева не изменилась, React обновит только текст. Для принудительного перерендера можно использовать динамический key.

Правильный ответ:

Ответ собеседника корректен. Ниже приведено детальное объяснение с примерами кода.


Исходная ситуация:

import React, { useState } from 'react';

function Test() {
const [random] = useState(() => Math.random());
console.log('Test render, random:', random);
return <div>Random: {random}</div>;
}

function Parent() {
const [isGoogle, setIsGoogle] = useState(false);

return (
<div>
<button onClick={() => setIsGoogle(!isGoogle)}>
Toggle: {isGoogle ? 'Google' : 'Not Google'}
</button>
<Test />
</div>
);
}

Что произойдёт при нажатии кнопки:

При нажатии кнопки isGoogle изменится, компонент Parent перерендерится. React рекурсивно вызовет рендер для всех дочерних компонентов, включая Test.

Однако число в компоненте Test останется прежним, потому что:

  • useState(() => Math.random()) инициализирует состояние только при первом монтировании компонента.
  • При последующих рендерах React использует уже сохранённое значение из своего внутреннего хранилища (fiber node).
  • Функция-инициализатор () => Math.random() вызывается только один раз.

Важно понимать: компонент Test перерендерится (функция компонента вызовется), но значение random будет тем же самым, что было при первом рендере.


Почему так работает React:

React хранит состояние каждого компонента в fiber node — внутренней структуре данных. При повторном рендере:

  • React не создаёт компонент заново (не вызывает new Test()).
  • React вызывает функцию компонента, но подставляет сохранённое состояние.
  • Инициализатор useState(initialValue) игнорируется при повторных рендерах — используется только текущее значение из fiber.

Как заставить компонент перерендериться с новым числом:

1. Использование key (рекомендуемый способ)

Изменяя key, мы говорим React, что это новый компонент, и он должен быть смонтирован заново:

function Parent() {
const [isGoogle, setIsGoogle] = useState(false);
const [testKey, setTestKey] = useState(0);

return (
<div>
<button onClick={() => {
setIsGoogle(!isGoogle);
setTestKey(prev => prev + 1); // меняем key
}}>
Toggle: {isGoogle ? 'Google' : 'Not Google'}
</button>
<Test key={testKey} />
</div>
);
}

При изменении key React размонтирует старый компонент Test и смонтирует новый — useState(() => Math.random()) вызовется заново.

2. Использование forceUpdate (антипаттерн)

В классовых компонентах есть this.forceUpdate(). В функциональных можно эмулировать:

function Test() {
const [, forceRender] = useState(0);
const [random] = useState(() => Math.random());

const regenerate = () => {
forceRender(prev => prev + 1);
};

return (
<div>
Random: {random}
<button onClick={regenerate}>New number</button>
</div>
);
}

Но это не поможет — число всё равно останется прежним, потому что useState для random уже инициализирован. Чтобы число изменилось, нужно использовать setState:

function Test() {
const [random, setRandom] = useState(() => Math.random());

return (
<div>
Random: {random}
<button onClick={() => setRandom(Math.random())}>New number</button>
</div>
);
}

3. Перенос состояния в родителя (lifting state up)

function Parent() {
const [isGoogle, setIsGoogle] = useState(false);
const [random, setRandom] = useState(() => Math.random());

return (
<div>
<button onClick={() => {
setIsGoogle(!isGoogle);
setRandom(Math.random());
}}>
Toggle
</button>
<Test random={random} />
</div>
);
}

function Test({ random }) {
return <div>Random: {random}</div>;
}

Способы принудительного перерендера без изменения пропсов:

СпособКогда используетсяПобочные эффекты
Изменение keyПолный сброс состояния компонентаРазмонтирование/монтирование, потеря фокуса, анимаций
forceUpdateРедко, классовые компонентыАнтипаттерн
useReducer с пустым действиемФункциональные компонентыНе сбрасывает состояние, только вызывает ререндер

Резюме:

  • При изменении состояния родителя дочерний компонент перерендерится, но его локальное состояние (useState) сохраняется.
  • Для полного сброса состояния дочернего компонента используется изменение key.
  • Для изменения конкретного значения нужно вызвать соответствующий setState.

Вопрос 10. Можно ли заставить компонент Test перерендериться с новым рандомным числом при нажатии кнопки, не меняя пропсы компонента?

Таймкод: 00:20:22

Ответ собеседника: Правильный. Да, можно использовать динамический key — при изменении ключа React размонтирует и заново смонтирует компонент, что приведёт к генерации нового рандомного числа. Это второй эвристический принцип React: при изменении ключа дерево полностью пересоздаётся.

Правильный ответ:

Ответ собеседника полностью корректен. Ниже приведено дополнительное пояснение с примерами и важными нюансами.


Суть решения через key:

В React есть два эвристических принципа при сравнии элементов:

  1. Разные типы — если тип элемента изменился (например, <div> стал <span>), React полностью пересоздаёт поддерево.
  2. Разные ключи — если ключ изменился, React считает, что это другой элемент, размонтирует старый и монтирует новый.

Изменяя key, мы заставляем React полностью пересоздать компонент — все его состояние будет инициализировано заново.

Реализация:

function Parent() {
const [isGoogle, setIsGoogle] = useState(false);
const [testKey, setTestKey] = useState(0);

const handleClick = () => {
setIsGoogle(!isGoogle);
setTestKey(prev => prev + 1); // меняем key → новый компонент
};

return (
<div>
<button onClick={handleClick}>
Toggle: {isGoogle ? 'Google' : 'Not Google'}
</button>
<Test key={testKey} />
</div>
);
}

function Test() {
const [random] = useState(() => Math.random());
console.log('Test смонтирован с числом:', random);
return <div>Random: {random}</div>;
}

Каждое нажатие кнопки приведёт к:

  • Размонтированию старого Test (вызов useEffect cleanup, если есть).
  • Монтированию нового Test (вызов useState(() => Math.random()) заново).
  • Генерации нового рандомного числа.

Почему другие способы не работают без изменения пропсов:

СпособРаботает?Почему
Изменение keyДаReact создаёт новый компонент с нуля
forceUpdateНетСостояние useState уже инициализировано, число не изменится
Просто ререндер родителяНетЛокальное состояние дочернего компонента сохраняется

Побочные эффекты использования key:

Стоиты учитывать, что изменение key — это полный сброс компонента:

  • Теряется фокус на элементах ввода внутри компонента.
  • Сбрасываются все анимации.
  • Вызываются cleanup-функции useEffect.
  • Теряется любое внутреннее состояние, не только random.

Когда использовать этот подход:

  • Когда действительно нужен полный сброс состояния компонента.
  • Для сброса форм (обнуление всех полей).
  • Для переключения между «экземплярами» одного компонента.
  • Когда другие подходы (lifting state up, useReducer) неприменимы.

Альтернатива — управление из родителя:

Если полный сброс компонента нежелателен, лучше управлять значением из родителя:

function Parent() {
const [isGoogle, setIsGoogle] = useState(false);
const [random, setRandom] = useState(() => Math.random());

const handleClick = () => {
setIsGoogle(!isGoogle);
setRandom(Math.random()); // новое число
};

return (
<div>
<button onClick={handleClick}>Toggle</button>
<Test random={random} />
</div>
);
}

function Test({ random }) {
return <div>Random: {random}</div>;
}

Этот подход более контролируем и не имеет побочных эффектов полного перемонтирования.

Вопрос 11. Реализовать раскрывающийся/сворачивающийся список по нажатию кнопки с отдельным компонентом ListItem.

Таймкод: 00:21:16

Ответ собеседника: Правильный. Реализовал компонент List с моковыми данными, ListItem, условный рендеринг на основе состояния isGoogle. Типизировал пропсы. Реализация рабочая.

Правильный ответ:

Ответ собеседника корректен. Ниже приведена эталонная реализация с полной типизацией и лучшими практиками.


Базовая реализация:

import React, { useState } from 'react';

// Типы
interface ListItemData {
id: number;
name: string;
description?: string;
}

interface ListItemProps {
item: ListItemData;
}

interface ListProps {
items: ListItemData[];
title?: string;
}

// Компонент элемента списка
const ListItem: React.FC<ListItemProps> = ({ item }) => {
return (
<li className="list-item">
<span className="list-item-name">{item.name}</span>
{item.description && (
<span className="list-item-description">{item.description}</span>
)}
</li>
);
};

// Компонент списка
const List: React.FC<ListProps> = ({ items, title = 'Список' }) => {
const [isExpanded, setIsExpanded] = useState(false);

const toggleList = () => {
setIsExpanded(prev => !prev);
};

return (
<div className="list-container">
<button
onClick={toggleList}
aria-expanded={isExpanded}
className="list-toggle"
>
{isExpanded ? 'Свернуть' : 'Развернуть'} {title}
</button>

{isExpanded && (
<ul className="list">
{items.map(item => (
<ListItem key={item.id} item={item} />
))}
</ul>
)}
</div>
);
};

// Использование
const App: React.FC = () => {
const mockData: ListItemData[] = [
{ id: 1, name: 'Элемент 1', description: 'Описание первого элемента' },
{ id: 2, name: 'Элемент 2', description: 'Описание второго элемента' },
{ id: 3, name: 'Элемент 3' },
{ id: 4, name: 'Элемент 4', description: 'Описание четвёртого элемента' },
];

return (
<div>
<List items={mockData} title="Мой список" />
</div>
);
};

export default App;

Расширенная реализация с CSS-анимацией:

import React, { useState, useRef, useEffect } from 'react';
import './ExpandableList.css';

interface ExpandableListProps {
items: ListItemData[];
title?: string;
defaultExpanded?: boolean;
onToggle?: (isExpanded: boolean) => void;
}

const ExpandableList: React.FC<ExpandableListProps> = ({
items,
title = 'Список',
defaultExpanded = false,
onToggle,
}) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const [height, setHeight] = useState<number | undefined>(
defaultExpanded ? undefined : 0
);
const contentRef = useRef<HTMLUListElement>(null);

useEffect(() => {
if (isExpanded && contentRef.current) {
setHeight(contentRef.current.scrollHeight);
} else {
setHeight(0);
}
}, [isExpanded]);

const handleToggle = () => {
const newState = !isExpanded;
setIsExpanded(newState);
onToggle?.(newState);
};

return (
<div className="expandable-list">
<button
onClick={handleToggle}
aria-expanded={isExpanded}
className="expandable-list__toggle"
>
<span className="expandable-list__title">{title}</span>
<span className={`expandable-list__arrow ${isExpanded ? 'expanded' : ''}`}>

</span>
</button>

<ul
ref={contentRef}
className="expandable-list__content"
style={{ height: height }}
aria-hidden={!isExpanded}
>
{items.map(item => (
<ListItem key={item.id} item={item} />
))}
</ul>
</div>
);
};
/* ExpandableList.css */
.expandable-list {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}

.expandable-list__toggle {
width: 100%;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
background: #f5f5f5;
border: none;
cursor: pointer;
font-size: 16px;
}

.expandable-list__toggle:hover {
background: #e0e0e0;
}

.expandable-list__arrow {
transition: transform 0.3s ease;
}

.expandable-list__arrow.expanded {
transform: rotate(180deg);
}

.expandable-list__content {
margin: 0;
padding: 0;
list-style: none;
overflow: hidden;
transition: height 0.3s ease;
}

.list-item {
padding: 12px 16px;
border-top: 1px solid #eee;
}

.list-item:hover {
background: #fafafa;
}

Реализация с использованием отдельного компонента кнопки:

interface ToggleButtonProps {
isExpanded: boolean;
onClick: () => void;
children: React.ReactNode;
}

const ToggleButton: React.FC<ToggleButtonProps> = ({
isExpanded,
onClick,
children,
}) => (
<button
onClick={onClick}
aria-expanded={isExpanded}
className="toggle-button"
>
{children}
<span>{isExpanded ? '▲' : '▼'}</span>
</button>
);

// Использование в List
const List: React.FC<ListProps> = ({ items, title }) => {
const [isExpanded, setIsExpanded] = useState(false);

return (
<div>
<ToggleButton
isExpanded={isExpanded}
onClick={() => setIsExpanded(!isExpanded)}
>
{title}
</ToggleButton>

{isExpanded && (
<ul>
{items.map(item => (
<ListItem key={item.id} item={item} />
))}
</ul>
)}
</div>
);
};

Ключевые моменты реализации:

  • Состояние isExpanded — булево значение, управляющее видимостью списка.
  • Условный рендеринг{isExpanded && <ul>...</ul>} или {isExpanded ? 'Свернуть' : 'Развернуть'}.
  • Типизация — интерфейсы для пропсов и данных обеспечивают типобезопасность.
  • Доступность (a11y) — атрибуты aria-expanded и aria-hidden для screen readers.
  • Анимация — CSS transition для плавного раскрытия/сворачивания.
  • Разделение ответственностиListItem отвечает за отрисовку элемента, List — за логику раскрытия.

Вопрос 12. Какие методы оптимизации в React ты знаешь? Если обернуть компонент в React.memo с колбэками в пропсах, что ещё нужно сделать?

Таймкод: 00:27:02

Ответ собеседника: Неполный. Назвал React.memo, useCallback, useMemo, React.lazy + Suspense. Для React.memo с колбэками нужно обернуть функции в useCallback. Не упомянул виртуализацию списков, code splitting.

Правильный ответ:

Ответ собеседника покрыл основные методы, но не полностью. Ниже приведён исчерпывающий способ оптимизации React-приложений.


1. React.memo — мемоизация компонента

React.memo предотвращает повторный рендер компонента, если его пропсы не изменились. Работает через поверхностное сравнение (shallow comparison) пропсов.

const MyComponent = React.memo(({ name, onClick }: Props) => {
console.log('MyComponent render');
return <button onClick={onClick}>{name}</button>;
});

// С кастомным компаратором
const MyComponent = React.memo(
({ name, age }: Props) => {
return <div>{name}: {age}</div>;
},
(prevProps, nextProps) => {
// Возвращаем true, если ререндер НЕ нужен
return prevProps.name === nextProps.name;
}
);

Важно: если передавать в React.memo компонент колбэк без useCallback, мемоизация не сработает — при каждом рендере родителя создаётся новая функция, и сравнение пропсов покажет, что они «изменились».

2. useCallback — мемоизация функций

useCallback возвращает мемоизированную версию функции, которая не пересоздаётся при каждом рендере (если зависимости не изменились).

const Parent = () => {
const [count, setCount] = useState(0);

// Без useCallback — новая функция при каждом рендере
const handleClickBad = () => {
console.log('clicked');
};

// С useCallback — функция стабильна, пока зависимости не изменились
const handleClickGood = useCallback(() => {
console.log('clicked');
}, []); // пустой массив зависимостей — функция создаётся один раз

return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<Child onClick={handleClickGood} />
</div>
);
};

const Child = React.memo(({ onClick }: { onClick: () => void }) => {
console.log('Child render');
return <button onClick={onClick}>Click me</button>;
});

3. useMemo — мемозация вычислений

useMemo кэширует результат вычисления и пересчитывает его только при изменении зависимостей.

const Parent = ({ items }: { items: Item[] }) => {
const [filter, setFilter] = useState('');

// Без useMemo — фильтрация при каждом рендере
const filteredItems = items.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
);

// С useMemo — фильтрация только при изменении items или filter
const filteredItemsMemo = useMemo(() => {
return items.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
);
}, [items, filter]);

return (
<div>
<input value={filter} onChange={e => setFilter(e.target.value)} />
<List items={filteredItemsMemo} />
</div>
);
};

4. React.lazy + Suspense — ленивая загрузка компонентов

Позволяет загружать компоненты только когда они нужны (code splitting на уровне компонентов).

import React, { Suspense } from 'react';

// Ленивая загрузка
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));

const App = () => {
const [showHeavy, setShowHeavy] = useState(false);

return (
<div>
<button onClick={() => setShowHeavy(true)}>
Показать тяжёлый компонент
</button>

{showHeavy && (
<Suspense fallback={<div>Загрузка...</div>}>
<HeavyComponent />
</Suspense>
)}
</div>
);
};

5. Code Splitting на уровне маршрутов

Разделение бандла на части по маршрутам — самый эффективный способ уменьшить начальный размер загрузки.

const Home = React.lazy(() => import('./pages/Home'));
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Settings = React.lazy(() => import('./pages/Settings'));

const App = () => (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);

6. Виртуализация списков (Windowing)

Рендер только видимых элементов списка вместо всех — критически важно для больших списков.

// react-window
import { FixedSizeList } from 'react-window';

const VirtualizedList = ({ items }: { items: string[] }) => {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style}>
{items[index]}
</div>
);

return (
<FixedSizeList
height={400}
itemCount={items.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
};

// react-virtuoso (более современная альтернатива)
import { Virtuoso } from 'react-virtuoso';

const VirtuosoList = ({ items }: { items: Item[] }) => (
<Virtuoso
style={{ height: 400 }}
data={items}
itemContent={(index, item) => (
<div>{item.name}</div>
)}
/>
);

7. Оптимизация контекста (Context)

Контекст вызывает ререндер всех потребителей при изменении значения. Способы оптимизации:

// Плохо: один большой контекст
const AppContext = createContext({
user: null,
theme: 'light',
notifications: [],
});

// Хорошо: разделение на отдельные контексты
const UserContext = createContext(null);
const ThemeContext = createContext('light');
const NotificationsContext = createContext([]);

// Или использование селекторов (через библиотеки)
import { create } from 'zustand';

const useStore = create((set) => ({
user: null,
theme: 'light',
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
}));

// Компонент перерендерится только при изменении user
const UserProfile = () => {
const user = useStore((state) => state.user);
return <div>{user?.name}</div>;
};

8. Использование состояния по умолчанию (State Colocation)

Размещение состояния как можно ближе к месту использования минимизирует область ререндеров.

// Плохо: состояние в корне — ререндерит всё дерево
const App = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<Header />
<Sidebar />
<Main>
<Toggle isOpen={isOpen} onToggle={setIsOpen} />
{isOpen && <Modal />}
</Main>
</div>
);
};

// Хорошо: состояние рядом с использованием
const ToggleSection = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<Toggle isOpen={isOpen} onToggle={setIsOpen} />
{isOpen && <Modal />}
</div>
);
};

const App = () => (
<div>
<Header />
<Sidebar />
<Main>
<ToggleSection />
</Main>
</div>
);

9. useTransition и useDeferredValue (React 18+)

Для пометки обновлений как не срочных:

const SearchResults = () => {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Срочное обновление — инпут должен отзываться мгновенно
const value = e.target.value;

// Несрочное обновление — результаты поиска могут подождать
startTransition(() => {
setQuery(value);
});
};

return (
<div>
<input onChange={handleChange} />
{isPending && <span>Загрузка...</span>}
<Results query={query} />
</div>
);
};

// useDeferredValue — отложенное значение
const SearchResults = () => {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);

return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<Results query={deferredQuery} />
</div>
);
};

10. Оптимизация рендера через key

Правильное использование key помогает React эффективно обновлять DOM:

// Плохо: индекс как key — React не может правильно отследить элементы
{items.map((item, index) => (
<ListItem key={index} item={item} />
))}

// Хорошо: стабильный уникальный идентификатор
{items.map((item) => (
<ListItem key={item.id} item={item} />
))}

Ответ на вторую часть вопроса:

Если обернуть компонент в React.memo и передавать колбэк в пропсах, обязательно нужно обернуть этот колбэк в useCallback. Иначе при каждом рендере родителя будет создаваться новая функция, React.memo будет видеть «изменившийся» проп и ререндерить дочерний компонент — мемоизация не будет работать.

// Правильная связка React.memo + useCallback
const Child = React.memo(({ onClick }: { onClick: () => void }) => {
return <button onClick={onClick}>Click</button>;
});

const Parent = () => {
const handleClick = useCallback(() => {
console.log('clicked');
}, []);

return <Child onClick={handleClick} />;
};

Вопрос 13. Какие потенциальные проблемы архитектуры App → List → ListItem? Как перестроить для поддержки разных вариаций ListItem?

Таймкод: 00:28:33

Ответ собеседника: Неполный. После подсказки предложил передавать компонент через пропсы или children. Выбрал children. Не знаком с паттерном Compound Components.

Правильный ответ:

Ответ собеседника после подсказки движется в правильном направлении, но не охватывает полный спектр решений. Ниже приведён детальный разбор.


Проблемы текущей архитектуры App → List → ListItem:

1. Жёсткая связанность (tight coupling)

Компонент List напрямую импортирует и использует ListItem. Если нужен другой вид элемента списка, придётся изменять сам List или создавать отдельный компонент.

// Проблема: List жёстко зависит от ListItem
const List = ({ items }) => (
<ul>
{items.map(item => (
<ListItem key={item.id} item={item} /> // всегда один тип
))}
</ul>
);

2. Нарушение принципа открытости/закрытости (Open/Closed Principle)

Чтобы добавить новый вариант отрисовки элемента, нужно модифицировать существующий код List, а не расширять его.

3. Негибкость конфигурации

Невозможно кастомизировать отрисовку элемента извне без изменения внутренней логики List.

4. Проблемы с переиспользованием

Если в другом месте приложения нужен список с другим видом элементов, придётся создавать дублирующий компонент.


Способы решения:

1. Render Props паттерн

Передаём функцию для рендера элемента через пропсы:

interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor?: (item: T, index: number) => string | number;
}

function List<T>({
items,
renderItem,
keyExtractor = (_, index) => index,
}: ListProps<T>) {
return (
<ul className="list">
{items.map((item, index) => (
<li key={keyExtractor(item, index)}>
{renderItem(item, index)}
</li>
))}
</ul>
);
}

// Использование
const App = () => {
const users = [
{ id: 1, name: 'John', role: 'admin' },
{ id: 2, name: 'Jane', role: 'user' },
];

return (
<List
items={users}
keyExtractor={(user) => user.id}
renderItem={(user) => (
<div>
<strong>{user.name}</strong>
<span className={`badge ${user.role}`}>{user.role}</span>
</div>
)}
/>
);
};

2. Передача компонента через пропсы (Component as Prop)

interface ListProps<T> {
items: T[];
itemComponent: React.ComponentType<{ item: T; index: number }>;
}

function List<T extends { id: string | number }>({
items,
itemComponent: ItemComponent,
}: ListProps<T>) {
return (
<ul className="list">
{items.map((item, index) => (
<li key={item.id}>
<ItemComponent item={item} index={index} />
</li>
))}
</ul>
);
}

// Разные варианты ListItem
const UserItem = ({ item }: { item: User }) => (
<div>
<span>{item.name}</span>
<span>{item.email}</span>
</div>
);

const ProductItem = ({ item }: { item: Product }) => (
<div>
<img src={item.image} alt={item.name} />
<span>{item.name}</span>
<span>${item.price}</span>
</div>
);

// Использование
<List items={users} itemComponent={UserItem} />
<List items={products} itemComponent={ProductItem} />

3. Compound Components паттерн (рекомендуемый)

Позволяет создавать декларативный API, где дочерние компоненты «знают» о родителе через контекст:

import React, { createContext, useContext, useState, ReactNode } from 'react';

// Контекст для связи List и ListItem
interface ListContextValue<T> {
items: T[];
renderItem?: (item: T, index: number) => ReactNode;
}

const ListContext = createContext<ListContextValue<any> | null>(null);

// Типы
interface ListProps<T> {
children: ReactNode;
items: T[];
}

interface ListItemProps<T> {
item: T;
index: number;
children?: ReactNode;
}

// Компонент List
function List<T>({ children, items }: ListProps<T>) {
return (
<ListContext.Provider value={{ items }}>
<ul className="list">{children}</ul>
</ListContext.Provider>
);
}

// Компонент ListItem
function ListItem<T>({ item, index, children }: ListItemProps<T>) {
return (
<li className="list-item">
{children || <span>{JSON.stringify(item)}</span>}
</li>
);
}

// Добавляем ListItem как свойство List
type ListComponent = <T>(props: ListProps<T>) => JSX.Element;
interface ListWithComponents extends ListComponent {
Item: typeof ListItem;
}

const CompoundList = List as ListWithComponents;
CompoundList.Item = ListItem;

// Использование
const App = () => {
const users = [
{ id: 1, name: 'John', role: 'admin' },
{ id: 2, name: 'Jane', role: 'user' },
];

return (
<CompoundList items={users}>
{users.map((user, index) => (
<CompoundList.Item key={user.id} item={user} index={index}>
<div>
<strong>{user.name}</strong>
<span className={`badge ${user.role}`}>{user.role}</span>
</div>
</CompoundList.Item>
))}
</CompoundList>
);
};

4. Children-based подход (самый гибкий)

Полностью отдаём контроль над рендеринг элемента потребителю:

interface ListProps<T> {
items: T[];
children: (item: T, index: number) => React.ReactNode;
keyExtractor?: (item: T) => string | number;
emptyComponent?: React.ReactNode;
}

function List<T>({
items,
children,
keyExtractor,
emptyComponent = <div>Нет элементов</div>,
}: ListProps<T>) {
if (items.length === 0) {
return <div className="list-empty">{emptyComponent}</div>;
}

return (
<ul className="list">
{items.map((item, index) => (
<li key={keyExtractor ? keyExtractor(item) : index}>
{children(item, index)}
</li>
))}
</ul>
);
}

// Использование — максимальная гибкость
const App = () => {
const users = [
{ id: 1, name: 'John', role: 'admin', avatar: '/john.jpg' },
{ id: 2, name: 'Jane', role: 'user', avatar: '/jane.jpg' },
];

return (
<List items={users} keyExtractor={(u) => u.id}>
{(user, index) => (
<div className="user-card" style={{ animationDelay: `${index * 100}ms` }}>
<img src={user.avatar} alt={user.name} />
<div>
<h3>{user.name}</h3>
<span className={`badge ${user.role}`}>{user.role}</span>
</div>
</div>
)}
</List>
);
};

Сравнение подходов:

ПодходГибкостьСложностьТипизацияКогда использовать
Render PropsВысокаяНизкаяХорошаяПростые случаи
Component as PropСредняяНизкаяОтличнаяКогда нужен конкретный компонент
Compound ComponentsВысокаяВысокаяОтличнаяСложные UI-библиотеки
Children-basedМаксимальнаяНизкаяХорошаяМаксимальная гибкость

Рекомендация:

Для большинства случаев children-based подход является оптимальным — он максимально гибкий, простой в реализации и хорошо типизируется. Compound Components стоит использовать при создании переиспользуемых UI-библиотек с богатым API (как в Radix UI, Headless UI).

Вопрос 14. Зачем нужны менеджеры состояния (Redux, MobX)? Почему нельзя просто создать глобальный объект?

Таймкод: 00:49:04

Ответ собеседника: Правильный. Менеджеры состояния решают проблему props drilling. Глобальный объект не вызовет перерисовку, потому что React не отслеживает изменения произвольных объектов — рендер происходит только при изменении state, пропсов, контекста или рендере родителя.

Правильный ответ:

Ответ собеседника корректен и покрывает ключевые моменты. Ниже приведено более детальное объяснение.


Почему глобальный объект не работает с React:

// Глобальный объект
const globalState = {
user: null,
theme: 'light',
};

const UserProfile = () => {
// React НЕ знает об изменениях globalState
return <div>{globalState.user?.name ?? 'Guest'}</div>;
};

const LoginButton = () => {
const handleLogin = () => {
globalState.user = { name: 'John' };
// Компонент UserProfile НЕ перерендерится!
// React не отслеживает мутации произвольных объектов
};
return <button onClick={handleLogin}>Login</button>;
};

Как работает рендеринг в React:

React перерисовывает компонент только когда:

  • Изменился локальный state (через useState, useReducer).
  • Изменились props от родителя.
  • Изменился context, на который подписан компонент.
  • Родительский компонент перерендерился.

Произвольные мутации объектов React не отслеживает — для этого ему нужна система подписок (subscription).


Что дают менеджеры состояния:

1. Система подписок (Subscription Model)

Менеджеры состояния реализуют паттерн «наблюдатель» (Observer). Компоненты подписываются на изменения и получают уведомления:

// Redux
import { useSelector, useDispatch } from 'react-redux';

const UserProfile = () => {
// Компонент ПОДПИСАН на часть состояния
// Перерендерится только при изменении user
const user = useSelector((state) => state.user);
return <div>{user?.name ?? 'Guest'></div>;
};

const LoginButton = () => {
const dispatch = useDispatch();
const handleLogin = () => {
// dispatch ВЫЗЫВАЕТ перерендер подписанных компонентов
dispatch({ type: 'SET_USER', payload: { name: 'John' } });
};
return <button onClick={handleLogin}>Login</button>;
};

2. Предсказуемость изменений

// Redux: состояние изменяется только через редьюсеры (чистые функции)
const userReducer = (state = null, action) => {
switch (action.type) {
case 'SET_USER':
return action.payload;
case 'CLEAR_USER':
return null;
default:
return state;
}
};

// MobX: изменения отслеживаются автоматически через прокси
import { makeAutoObservable } from 'mobx';

class UserStore {
user = null;

constructor() {
makeAutoObservable(this);
}

setUser(user) {
this.user = user; // MobX автоматически уведомит подписчиков
}
}

3. Селективные подписки (мемоизация)

Компоненты перерисовываются только когда изменилась нужная им часть состояния:

// Компонент перерендерится только при изменении user.name
const UserName = () => {
const name = useSelector((state) => state.user?.name);
return <span>{name}</span>;
};

// Компонент перерендерится только при изменении theme
const ThemedButton = () => {
const theme = useSelector((state) => state.theme);
return <button className={theme}>Click</button>;
};

4. DevTools и отладка

// Redux DevTools:
// - Time-travel debugging (откат к предыдущим состояниям)
// - Лог всех действий
// - Инспекция состояния
// - Экспорт/импорт состояния (для воспроизведения багов)

// MobX DevTools:
// - Отслеживание реакций
// - Граф зависимостей
// - Инспекция observable

5. Middleware для побочных эффектов

// Redux Thunk — простые асинхронные действия
const fetchUser = (userId) => async (dispatch) => {
dispatch({ type: 'FETCH_USER_START' });
try {
const user = await api.getUser(userId);
dispatch({ type: 'FETCH_USER_SUCCESS', payload: user });
} catch (error) {
dispatch({ type: 'FETCH_USER_ERROR', payload: error.message });
}
};

// Redux Saga — сложные асинкронные потоки
import { call, put, takeEvery } from 'redux-saga/effects';

function* fetchUserSaga(action) {
try {
const user = yield call(api.getUser, action.payload);
yield put({ type: 'FETCH_USER_SUCCESS', payload: user });
} catch (error) {
yield put({ type: 'FETCH_USER_ERROR', payload: error.message });
}
}

function* watchFetchUser() {
yield takeEvery('FETCH_USER_START', fetchUserSaga);
}

Сравнение подходов:

КритерийГлобальный объектContext APIReduxMobX
Ререндер при измененияхНетДа (все потребители)Да (селективно)Да (селективно)
DevToolsНетОграниченныеПолныеПолные
MiddlewareНетНетДаДа
Кривая обученияНетНизкаяВысокаяСредняя
Размер бандла00 (встроен)~1KB~4KB

Когда нужны менеджеры состояния:

  • Состояние используется во многих компонентах на разных уровнях дерева.
  • Сложная логика обновления состояния.
  • Нужна отладка и воспроизводимость состояния.
  • Асинхронные побочные эффекты (запросы к API, веб-сокеты).
  • Кэширование серверного состояния.

Когда достаточно Context API:

  • Простое состояние без частых обновлений.
  • Передача темы, локали, авторизации.
  • Нет сложной логики обновления.

Когда можно обойтись без менеджера состояния:

  • Локальное состояние компонента (useState).
  • Подъём состояния (lifting state up) до ближайшего общего предка.
  • Серверное состояние через React Query / SWR / RTK Query.

Вопрос 15. Какие оптимизации есть в Redux/RTK? Что такое мемоизированные селекторы и нормализация через entity adapters?

Таймкод: 00:51:44

Ответ собеседника: Неполный. Назвал мемоизированные селекторы и entity adapters. Объяснил, что нормализация даёт O(1) вместо O(n). Селектор извлекает данные из store и отслеживает изменения. Мемоизация — сохранение результатов вычислений. Не раскрыл подробно механизм подписки и createSelector.

Правильный ответ:

Ответ собеседника затронул ключевые понятия, но недостаточно глубоко. Ниже приведено полное объяснение.


1. Мемоизированные селекторы (createSelector из Reselect/RTK)

Проблема обычных селекторов:

// Без мемоизации — новый массив при каждом вызове
const selectExpensiveItems = (state) => {
return state.items.filter(item => item.price > 100);
// Возвращает НОВЫЙ массив каждый раз → компонент перерендерится
};

// В компоненте
const expensiveItems = useSelector(selectExpensiveItems);
// Даже если items не изменился, массив будет новым объектом

Решение — createSelector из Reselect (встроен в RTK):

import { createSelector } from '@reduxjs/toolkit';

// Базовые селекторы (input selectors)
const selectItems = (state) => state.items;
const selectFilter = (state) => state.filter;

// Мемоизированный селектор (output selector)
const selectFilteredItems = createSelector(
[selectItems, selectFilter], // input selectors
(items, filter) => { // combiner
console.log('Пересчёт фильтрации');
return items.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
);
}
);

// Как это работает:
// 1. При первом вызове — вычисляет результат и кэширует.
// 2. При повторном вызове — сравнивает входные значения (items, filter).
// 3. Если входные значения не изменились — возвращает кэш.
// 4. Если хотя бы одно изменилось — пересчитывает.

Вложенные селекторы (композиция):

const selectItems = (state) => state.items;
const selectFilter = (state) => state.filter;

// Первый мемоизированный селектор
const selectFilteredItems = createSelector(
[selectItems, selectFilter],
(items, filter) => items.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
)
);

// Второй использует результат первого
const selectFilteredItemCount = createSelector(
[selectFilteredItems],
(filteredItems) => filteredItems.length
);

// Третий — результат первого
const selectFilteredItemNames = createSelector(
[selectFilteredItems],
(filteredItems) => filteredItems.map(item => item.name)
);

Использование в компоненте:

const ItemList = () => {
const items = useSelector(selectFilteredItems);
const count = useSelector(selectFilteredItemCount);
const names = useSelector(selectFilteredItemNames);

return (
<div>
<p>Найдено: {count}</p>
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
};

2. Нормализация состояния через Entity Adapters

Проблема хранения данных массивом:

// Не нормализованное состояние
{
users: [
{ id: 1, name: 'John', posts: [...] },
{ id: 2, name: 'Jane', posts: [...] },
// ... 1000 пользователей
]
}

// Проблемы:
// - Поиск пользователя: O(n)
// - Обновление пользователя: O(n) + нужно найти индекс
// - Дублирование данных при вложенных связях

Решение — нормализация через createEntityAdapter:

import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';

// Создаём адаптер
const usersAdapter = createEntityAdapter({
// Опционально: функция сортировки
sortComparer: (a, b) => a.name.localeCompare(b.name),
// Опционально: какое поле использовать как ID (по умолчанию 'id')
// selectId: (user) => user.userId,
});

// Начальное состояние
const initialState = usersAdapter.getInitialState({
loading: false,
error: null,
});

// Slice с адаптером
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {
// Добавить одного
userAdded: usersAdapter.addOne,
// Добавить многих
usersReceived: usersAdapter.addMany,
// Установить всех (заменить)
usersLoaded: usersAdapter.setAll,
// Обновить одного
userUpdated: usersAdapter.updateOne,
// Удалить одного
userRemoved: usersAdapter.removeOne,
// Upsert (добавить или обновить)
userUpserted: usersAdapter.upsertOne,
},
});

// Селекторы, генерируемые адаптером
export const {
selectAll: selectAllUsers, // все сущности как массив
selectById: selectUserById, // по ID
selectIds: selectUserIds, // только ID
selectEntities: selectUserEntities, // объект { id: entity }
selectTotal: selectTotalUsers, // количество
} = usersAdapter.getSelectors((state) => state.users);

Нормализованная структура состояния:

// Состояние после usersLoaded
{
users: {
ids: [1, 2, 3],
entities: {
1: { id: 1, name: 'John', email: 'john@example.com' },
2: { id: 2, name: 'Jane', email: 'jane@example.com' },
3: { id: 3, name: 'Bob', email: 'bob@example.com' },
},
loading: false,
error: null,
}
}

// Поиск по ID: O(1) вместо O(n)
const user = selectUserById(state, 2); // мгновенно

Использование в компоненте:

const UserProfile = ({ userId }) => {
// O(1) — прямой доступ по ID
const user = useSelector((state) => selectUserById(state, userId));

return (
<div>
<h2>{user?.name}</h2>
<p>{user?.email}</p>
</div>
);
};

const UserList = () => {
// Получить всех как массив (мемоизированно)
const users = useSelector(selectAllUsers);
const total = useSelector(selectTotalUsers);

return (
<div>
<p>Всего пользователей: {total}</p>
{users.map(user => (
<UserCard key={user.id} userId={user.id} />
))}
</div>
);
};

3. Другие оптимизации Redux/RTK:

createAsyncThunk — стандартизированные асинхронные действия:

import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';

export const fetchUsers = createAsyncThunk(
'users/fetch',
async (_, { rejectWithValue }) => {
try {
const response = await api.getUsers();
return response.data;
} catch (error) {
return rejectWithValue(error.message);
}
}
);

const usersSlice = createSlice({
name: 'users',
initialState: usersAdapter.getInitialState({
loading: 'idle',
error: null,
}),
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = 'pending';
})
.addCase(fetchUsers.fulfilled, (state, action) => {
usersAdapter.setAll(state, action.payload);
state.loading = 'idle';
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = 'idle';
state.error = action.payload;
});
},
});

RTK Query — кэширование серверных данных:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['User', 'Post'],
endpoints: (builder) => ({
getUsers: builder.query({
query: () => '/users',
providesTags: ['User'], // для инвалидации кэша
}),
getUser: builder.query({
query: (id) => `/users/${id}`,
providesTags: (result, error, id) => [{ type: 'User', id }],
}),
updateUser: builder.mutation({
query: ({ id, ...patch }) => ({
url: `/users/${id}`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (result, error, { id }) => [
{ type: 'User', id },
],
}),
}),
});

export const {
useGetUsersQuery,
useGetUserQuery,
useUpdateUserMutation,
} = apiSlice;

Автоматическая инвалидация кэша при мутациях:

const UserEditor = ({ userId }) => {
const { data: user } = useGetUserQuery(userId);
const [updateUser] = useUpdateUserMutation();

const handleSave = async (changes) => {
await updateUser({ id: userId, ...changes });
// RTK Query автоматически обновит useGetUserQuery(userId)
// и все остальные запросы, зависящие от тега { type: 'User', id: userId }
};
};

4. Механизм подписки в Redux:

// Упрощённая реализация подписки
class Store {
constructor(reducer) {
this.state = reducer(undefined, { type: '@@INIT' });
this.listeners = [];
this.reducer = reducer;
}

getState() {
return this.state;
}

dispatch(action) {
this.state = this.reducer(this.state, action);
// Уведомляем ВСЕХ подписчиков
this.listeners.forEach(listener => listener());
return action;
}

subscribe(listener) {
this.listeners.push(listener);
// Возвращаем функцию отписки
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
}

// React-Redux использует useSyncExternalStore для подписки
// При изменении store → вызывается listener → useSelector проверяет изменение → ререндер

Сводная таблица оптимизаций:

ОптимизацияЧто решаетКак работает
createSelectorЛишние ререндеры от новых ссылокМемоизация результата по входным данным
createEntityAdapterO(n) поиск и обновлениеНормализация: { ids: [], entities: {} }
createSliceМногословность редьюсеровАвтоматическая генерация action creators
createAsyncThunkДублирование pending/fulfilled/rejectedСтандартизированные асинхронные действия
RTK QueryРучное кэширование серверных данныхАвтоматическое кэширование, инвалидация по тегам

Вопрос 16. Опиши основные сущности Redux (action, reducer, dispatch, slice) и как они взаимодействуют. Что такое чистая функция и почему редюсер должен быть чистой функцией?

Таймкод: 00:56:38

Ответ собеседника: Неполный. Action — объект с type и payload. Dispatch — метод для отправки action. Редюсер — чистая функция (state, action) => newState. Slice — объединение редюсеров и actions. Чистая функция при одинаковых аргументах возвращает тот же результат без сайд-эффектов. Кандидат не смог объяснить зачем именно нужна чистота (предсказуемость, time-travel debugging).

Правильный ответ:

Ответ собеседника корректен в определениях, но не раскрыл важный вопрос о причинах требования чистоты. Ниже приведено полное объяснение.


Основные сущности Redux:

1. Action — описание события

Action — это обычный JavaScript-объект, описывающий что произошло. Обязательно содержит поле type, может содержать любые дополнительные данные.

// Простой action
const incrementAction = {
type: 'counter/increment',
};

// Action с данными (payload)
const addTodoAction = {
type: 'todos/addTodo',
payload: {
id: 1,
text: 'Изучать Redux',
completed: false,
},
};

// Action creator — функция для создания action
const addTodo = (text) => ({
type: 'todos/addTodo',
payload: {
id: Date.now(),
text,
completed: false,
},
});

2. Reducer — обработчик изменений

Reducer — чистая функция, которая принимает текущее состояние и action, и возвращает новое состояние.

// Простой редюсер
const counterReducer = (state = 0, action) => {
switch (action.type) {
case 'counter/increment':
return state + 1;
case 'counter/decrement':
return state - 1;
case 'counter/add':
return state + action.payload;
default:
return state;
}
};

// Редюсер для массива (важно: не мутируем!)
const todosReducer = (state = [], action) => {
switch (action.type) {
case 'todos/addTodo':
return [...state, action.payload]; // новый массив
case 'todos/toggleTodo':
return state.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
);
case 'todos/removeTodo':
return state.filter(todo => todo.id !== action.payload);
default:
return state;
}
};

3. Store — хранилище состояния

Store объединяет редюсеры и предоставляет API для работы с состоянием.

import { configureStore } from '@reduxjs/toolkit';
import { combineReducers } from 'redux';

const rootReducer = combineReducers({
counter: counterReducer,
todos: todosReducer,
});

const store = configureStore({
reducer: rootReducer,
});

// API store:
store.getState(); // получить текущее состояние
store.dispatch(action); // отправить action
store.subscribe(listener); // подписаться на изменения

4. Dispatch — отправка изменений

dispatch — единственный способ изменить состояние в Redux. Принимает action и передаёт его в редюсер.

// Отправка простого action
store.dispatch({ type: 'counter/increment' });

// Через action creator
store.dispatch(addTodo('Новое задание'));

// В React-компоненте
const Counter = () => {
const count = useSelector((state) => state.counter);
const dispatch = useDispatch();

return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch({ type: 'counter/increment' })}>
+
</button>
</div>
);
};

5. Slice — объединение логики (RTK)

createSlice автоматически генерирует action creators и action types из редюсеров.

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

const todosSlice = createSlice({
name: 'todos',
initialState: [] as Todo[],
reducers: {
// Immer позволяет писать «мутабельный» код
addTodo: (state, action: PayloadAction<string>) => {
state.push({
id: Date.now(),
text: action.payload,
completed: false,
});
},
toggleTodo: (state, action: PayloadAction<number>) => {
const todo = state.find(t => t.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
removeTodo: (state, action: PayloadAction<number>) => {
return state.filter(t => t.id !== action.payload);
},
},
});

// Автоматически сгенерированные action creators
export const { addTodo, toggleTodo, removeTodo } = todosSlice.actions;

// Редюсер для подключения к store
export default todosSlice.reducer;

Взаимодействие сущностей (поток данных):

Компонент → dispatch(action) → Store → reducer(state, action) → newState → уведомление подписчиков → ререндер компонентов
// Полный цикл:
// 1. Пользователь нажимает кнопку
const handleAddTodo = () => {
// 2. dispatch отправляет action
dispatch(addTodo('Изучать Redux'));
};

// 3. Store вызывает редюсер
// todosReducer(currentState, { type: 'todos/addTodo', payload: '...' })

// 4. Редюсер возвращает НОВОЕ состояние (не мутирует старое!)

// 5. Store обновляет состояние и уведомляет подписчиков

// 6. useSelector в компонентах получает новое значение
// 7. Компоненты с изменившимися данными перерендеряются

Чистая функция (Pure Function):

Функция называется чистой, если:

  • При одинаковых аргументах всегда возвращает один и тот же результат (детерминированность).
  • Не вызывает побочных эффектов (side effects): не мутирует входные данные, не делает сетевые запросы, не пишет в файлы, не использует глобальные переменные.
// Чистая функция
const add = (a, b) => a + b;
add(2, 3); // всегда 5

const sum = (numbers) => numbers.reduce((acc, n) => acc + n, 0);
sum([1, 2, 3]); // всегда 6

// Нечистая функция — зависит от внешнего состояния
let total = 0;
const addToTotal = (n) => {
total += n; // мутирует внешнюю переменную
return total;
};
addToTotal(5); // 5
addToTotal(5); // 10 — другой результат при тех же аргументах!

// Нечистая функция — сайд-эффект
const saveToServer = (data) => {
fetch('/api/save', { body: JSON.stringify(data) }); // сетевой запрос
return data;
};

Почему редюсер должен быть чистой функцией:

1. Time-travel debugging (путешествие во времени)

Redux DevTools сохраняет историю всех actions. Если редюсер чистый, можно «отмотать» назад — применить те же actions к начальному состоянию и получить точно такое же состояние.

// Если редюсер чистый:
const state1 = reducer(initialState, action1);
const state2 = reducer(state1, action2);
const state3 = reducer(state2, action3);

// Можно воспроизвести любое состояние:
const replayed = reducer(reducer(reducer(initialState, action1), action2), action3);
// state3 === replayed ✓

// Если редюсер нечистый (использует Math.random()):
const state1 = reducer(initialState, action1); // содержит Math.random()
const replayed = reducer(initialState, action1); // ДРУГОЙ random!
// state1 !== replayed ✗ — time-travel сломан

2. Предсказуемость

Чистый редюсер гарантирует: одинаковый action + одинаковое state = одинаковый результат. Это делает поведение приложения предсказуемым и тестируемым.

// Тест чистого редьюсера — простой и надёжный
test('increment adds 1', () => {
const result = counterReducer(5, { type: 'counter/increment' });
expect(result).toBe(6); // всегда 6, всегда предсказуемо
});

3. Возможность отмены/повтора действий

Если каждое действие детерминированно, можно:

  • Отменить действие (применить «обратный» action).
  • Повторить действие в другом контексте.
  • Сериализовать историю действий для отладки.

4. Мемоизация и оптимизация

Чистые функции можно мемоизировать — кешировать результат для конкретных аргументов.

5. Параллельное выполнение

Чистые функции безопасны для параллельного выполнения (в Web Workers, например), так как не зависят от общего мутабельного состояния.


Что нельзя делать в редьюсере:

// ❌ Мутация состояния
const badReducer = (state, action) => {
state.value = action.payload; // мутация!
return state;
};

// ❌ Сайд-эффекты
const badReducer = (state, action) => {
fetch('/api/log', { body: JSON.stringify(action) }); // сетевой запрос!
return state;
};

// ❌ Недетерминированность
const badReducer = (state, action) => {
return {
...state,
timestamp: Date.now(), // каждый раз другое значение!
random: Math.random(),
};
};

// ❌ Использование глобальных переменных
let counter = 0;
const badReducer = (state, action) => {
counter++; // зависимость от внешнего состояния!
return { ...state, value: counter };
};

Как правильно:

// ✅ Возвращаем новое объект/массив
const goodReducer = (state, action) => {
return {
...state,
value: action.payload, // новый объект
};
};

// ✅ Используем Immer (встроен в RTK) для удобной записи
const goodReducer = createSlice({
name: 'example',
initialState: { items: [] },
reducers: {
addItem: (state, action) => {
state.items.push(action.payload); // Immer создаст новый объект
},
},
});

Куда выносить сайд-эффекты:

Сайд-эффекты (запросы к API, таймеры, работа с DOM) выносятся в:

  • Middleware (Redux Thunk, Redux Saga, Redux Observable).
  • Listener middleware (RTK).
  • Компоненты (useEffect).
// Redux Thunk — сайд-эффекты в action creators
const fetchUser = (userId) => async (dispatch) => {
dispatch({ type: 'user/fetchStart' });
try {
const user = await api.getUser(userId); // сайд-эффект здесь!
dispatch({ type: 'user/fetchSuccess', payload: user });
} catch (error) {
dispatch({ type: 'user/fetchError', payload: error.message });
}
};

// Редюсер остаётся чистым — только обновляет состояние
const userReducer = (state = { data: null, loading: false }, action) => {
switch (action.type) {
case 'user/fetchStart':
return { ...state, loading: true };
case 'user/fetchSuccess':
return { ...state, loading: false, data: action.payload };
case 'user/fetchError':
return { ...state, loading: false, error: action.payload };
default:
return state;
}
};

Вопрос 17. Насколько можно доверять бэкенду? Как гарантировать корректность данных от бэкенда?

Таймкод: 00:59:52

Ответ собеседника: Правильный. Документация описывается в Swagger или Confluence. Для гарантии можно использовать кодогенерацию типов из Swagger, валидаторы (Zod, Yup) для проверки данных от бэкенда, а также type guards.

Правильный ответ:

Ответ собеседника корректен и покрывает основные подходы. Ниже приведено более детальное объяснение с примерами.


Краткий ответ на первый вопрос:

Бэкенду нельзя доверять полностью. Даже если бэкенд-команда надёжная, могут возникать:

  • Ошибки в коде сервера.
  • Изменения API без обновления документации (breaking changes).
  • Проблемы с базой данных или внешними сервисами.
  • Сетевые сбои, приводящие к обрезанным или повреждённым ответам.
  • Различия между окружениями (dev, staging, production).
  • Человеческий фактор — неверные данные, опечатки, несогласованность форматов.

Поэтому фронтенд должен валидировать данные на границе системы — при получении ответа от API.


1. Кодогенерация типов из OpenAPI/Swagger

Автоматическая генерация TypeScript-типов из спецификации API гарантирует, что типы на фронтенде всегда соответствуют контракту.

# Утилиты для генерации:
# openapi-typescript
npx openapi-typescript https://api.example.com/swagger.json -o ./src/types/api.ts

# openapi-generator
openapi-generator generate -i swagger.json -g typescript-fetch -o ./src/api

# Orval — генерация с React Query хуками
npx orval --config orval.config.ts

Пример сгенерированных типов:

// Сгенерировано из OpenAPI
export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
created_at: string;
}

export interface ApiResponse<T> {
data: T;
meta?: {
total: number;
page: number;
};
}

Orval — полная интеграция с React Query:

// orval.config.ts
export default {
petstore: {
input: {
target: './petstore.yaml',
},
output: {
target: './src/api/petstore.ts',
client: 'react-query',
override: {
mutator: {
path: './src/api/instance.ts',
name: 'customInstance',
},
},
},
},
};
// Сгенерированный код
export const getPet = (
petId: number,
options?: AxiosRequestConfig
) => {
return customInstance<Pet>({
url: `/pets/${petId}`,
method: 'GET',
...options,
});
};

export const getGetPetQueryKey = (petId: number) => [`/pets/${petId}`];

export const useGetPet = <TData = Pet>(
petId: number,
options?: UseQueryOptions<Pet, TData>
) => {
return useQuery<Pet, TData>(
getGetPetQueryKey(petId),
() => getPet(petId),
options
);
};

2. Runtime-валидация с Zod

TypeScript проверяет типы только на этапе компиляции. В runtime данные могут не соответствовать типам. Zod решает эту проблему.

import { z } from 'zod';

// Определяем схему
const UserSchema = z.object({
id: z.number().positive(),
name: z.string().min(1).max(255),
email: z.string().email(),
role: z.enum(['admin', 'user']),
created_at: z.string().datetime(),
avatar_url: z.string().url().nullable().optional(),
});

// Тип выводится из схемы
type User = z.infer<typeof UserSchema>;

// Валидация при получении данных
const validateUser = (data: unknown): User => {
return UserSchema.parse(data); // бросит ZodError при невалидных данных
};

// Безопасная валидация
const safeValidateUser = (data: unknown) => {
const result = UserSchema.safeParse(data);
if (result.success) {
return { data: result.data, error: null };
} else {
return { data: null, error: result.error };
}
};

Интеграция с API-слоем:

import axios from 'axios';
import { z } from 'zod';

const ApiResponseSchema = <T extends z.ZodType>(dataSchema: T) =>
z.object({
data: dataSchema,
meta: z.object({
total: z.number(),
page: z.number(),
}).optional(),
});

const UserListSchema = ApiResponseSchema(z.array(UserSchema));

// API-клиент с валидацией
export const fetchUsers = async (page: number) => {
const response = await axios.get(`/api/users?page=${page}`);

const result = UserListSchema.safeParse(response.data);
if (!result.success) {
console.error('Невалидные данные от сервера:', result.error);
throw new Error('Получены невалидные данные от сервера');
}

return result.data;
};

3. Type Guards

Ручные проверки типов для простых случаев:

// Простой type guard
const isString = (value: unknown): value is string => {
return typeof value === 'string';
};

// Проверка объекта
interface User {
id: number;
name: string;
email: string;
}

const isUser = (value: unknown): value is User => {
if (typeof value !== 'object' || value === null) {
return false;
}

const obj = value as Record<string, unknown>;

return (
typeof obj.id === 'number' &&
typeof obj.name === 'string' &&
typeof obj.email === 'string'
);
};

// Проверка массива
const isUserArray = (value: unknown): value is User[] => {
return Array.isArray(value) && value.every(isUser);
};

// Использование
const processResponse = (data: unknown) => {
if (isUserArray(data)) {
// TypeScript знает, что data — это User[]
data.forEach(user => console.log(user.name));
} else {
console.error('Неожиданный формат данных');
}
};

4. Схема валидации с Yup

Альтернатива Zod, популярная в экосистеме Formik:

import * as yup from 'yup';

const UserSchema = yup.object({
id: yup.number().positive().required(),
name: yup.string().min(1).max(255).required(),
email: yup.string().email().required(),
role: yup.string().oneOf(['admin', 'user']).required(),
created_at: yup.string().required(),
avatar_url: yup.string().url().nullable(),
});

// Валидация
const validateUser = async (data: unknown) => {
try {
const validated = await UserSchema.validate(data, {
abortEarly: false, // собрать все ошибки, не останавливаться на первой
stripUnknown: true, // удалить поля, не описанные в схеме
});
return { data: validated, error: null };
} catch (error) {
return { data: null, error };
}
};

5. Стратегия обработки невалидных данных

enum ValidationStrategy {
STRICT = 'strict', // бросить ошибку
FALLBACK = 'fallback', // использовать значение по умолчанию
FILTER = 'filter', // отфильтровать невалидные элементы
LOG = 'log', // залогировать и использовать как есть
}

const processUsers = (
data: unknown,
strategy: ValidationStrategy = ValidationStrategy.STRICT
) => {
const result = UserArraySchema.safeParse(data);

if (result.success) {
return result.data;
}

switch (strategy) {
case ValidationStrategy.STRICT:
throw new Error('Невалидные данные');

case ValidationStrategy.FALLBACK:
console.warn('Невалидные данные, используем fallback');
return [];

case ValidationStrategy.FILTER:
// Валидируем каждый элемент отдельно
const dataArray = data as unknown[];
return dataArray
.map(item => UserSchema.safeParse(item))
.filter((r): r is z.SafeParseSuccess<User> => r.success)
.map(r => r.data);

case ValidationStrategy.LOG:
console.error('Ошибки валидации:', result.error);
return data as User[]; // используем как есть с риском
}
};

6. Мониторинг и алертинг

// Отправка ошибок валидации в систему мониторинга
import * as Sentry from '@sentry/react';

const fetchWithValidation = async <T>(
url: string,
schema: z.ZodType<T>
): Promise<T> => {
const response = await fetch(url);
const data = await response.json();

const result = schema.safeParse(data);

if (!result.success) {
// Отправляем в Sentry для анализа
Sentry.captureException(new Error('API validation failed'), {
extra: {
url,
errors: result.error.errors,
receivedData: data,
},
});

throw new Error('Данные не соответствуют ожидаемой схеме');
}

return result.data;
};

Сводная таблица подходов:

ПодходКогда использоватьПлюсыМинусы
Кодогенерация из OpenAPIСтабильный API с документациейАвтоматическая синхронизация типовТолько compile-time проверка
ZodЛюбой APIRuntime-валидация + вывод типовДополнительный размер бандла
YupФормы (Formik)Интеграция с FormikМенее строгая типизация
Type GuardsПростые проверкиНет зависимостейМногословно, легко ошибиться
OrvalReact Query проектыПолная автоматизацияПривязка к конкретному клиенту

Рекомендуемый стек:

Для современного React-приложения рекомендуется:

  1. OpenAPI-спецификация на бэкенде (единый источник истины).
  2. Orval для генерации API-клиента и React Query хуков.
  3. Zod для runtime-валидации ответов.
  4. Sentry для мониторинга ошибок валидации в production.

Вопрос 18. В чём разница между unknown и any в TypeScript? Что такое type guard?

Таймкод: 01:03:49

Ответ собеседника: Правильный. unknown — безопасный тип, не позволяет обращаться к свойствам без предварительного определения типа. any полностью отключает типизацию. Type guard — функция с предикатом типа (arg is Type), которая проверяет тип в runtime и позволяет TypeScript сузить тип.

Правильный ответ:

Ответ собеседника полностью корректен. Ниже приведено более детальное объяснение с примерами.


any — полное отключение типизации

any говорит TypeScript: «доверяй мне, я знаю, что делаю». Компилятор не проверяет ничего для значений типа any.

let value: any = 'hello';

// Всё разрешено — никакой провекции
value.foo.bar.baz; // нет ошибки компиляции
value[123]; // нет ошибки
value(); // нет ошибки
new value(); // нет ошибки
value.toUpperCase(); // нет ошибки

// Можно присвоить куда угодно
const num: number = value; // нет ошибки
const str: string = value; // нет ошибки

// any «заражает» окружающий код
const result = value + 1; // result тоже any

Проблема any: ошибки обнаруживаются только в runtime, а не на этапе компиляции.


unknown — безопасная альтернатива any

unknown говорит TypeScript: «я не знаю, что это, но буду осторожен». Перед использованием значение нужно проверить.

let value: unknown = 'hello';

// Ничего не разрешено без проверки
value.toUpperCase(); // ❌ Ошибка: Object is of type 'unknown'
value.foo; // ❌ Ошибка
value(); // ❌ Ошибка

// Нужно сузить тип перед использованием
if (typeof value === 'string') {
value.toUpperCase(); // ✅ Теперь TypeScript знает, что это string
}

// Нельзя присвоить без проверки
const str: string = value; // ❌ Ошибка

// Нужно приведение или проверка
const str: string = value as string; // ✅ но это наш выбор и ответственность

Сравнительная таблица:

Операцияanyunknown
Обращение к свойствамРазрешеноЗапрещено
Вызов как функцииРазрешеноЗапрещено
Использование как конструкторРазрешеноЗапрещено
Присвоение в переменную с типомРазрешеноЗапрещено
Арифметические операцииРазрешеноЗапрещено
Проверка typeof, instanceofРазрешеноРазрешено

Когда использовать:

// any — только как крайняя мера
// Миграция с JS, сторонние библиотеки без типов, прототипы
const legacyCode: any = getLegacyData();

// unknown — при работе с неизвестными данными
// API-ответы, данные из localStorage, event handlers
const apiResponse: unknown = await fetchData();
const userInput: unknown = getUserInput();

Type Guard (защитник типа)

Type guard — функция, которая проверяет тип значения в runtime и сообщает TypeScript о результате через предикат типа value is Type.

Простые type guards с typeof:

const processValue = (value: string | number) => {
if (typeof value === 'string') {
// TypeScript знает: value — string
console.log(value.toUpperCase());
} else {
// TypeScript знает: value — number
console.log(value.toFixed(2));
}
};

Пользовательский type guard:

interface User {
type: 'user';
id: number;
name: string;
email: string;
}

interface Guest {
type: 'guest';
sessionId: string;
}

type AppUser = User | Guest;

// Type guard с предикатом типа
const isUser = (value: AppUser): value is User => {
return value.type === 'user';
};

const isGuest = (value: AppUser): value is Guest => {
return value.type === 'guest';
};

// Использование
const greet = (user: AppUser) => {
if (isUser(user)) {
// TypeScript знает: user — User
console.log(`Hello, ${user.name}! (${user.email})`);
} else {
// TypeScript знает: user — Guest
console.log(`Hello, guest ${user.sessionId}!`);
}
};

Type guard для проверки объектов:

interface ApiResponse {
status: 'success';
data: User[];
}

interface ApiError {
status: 'error';
message: string;
code: number;
}

type ApiResult = ApiResponse | ApiError;

// Type guard
const isApiError = (result: ApiResult): result is ApiError => {
return result.status === 'error';
};

const handleResponse = (result: ApiResult) => {
if (isApiError(result)) {
// result — ApiError
console.error(`Error ${result.code}: ${result.message}`);
} else {
// result — ApiResponse
console.log(`Got ${result.data.length} users`);
}
};

Type guard для проверки на null/undefined:

const isDefined = <T>(value: T | null | undefined): value is T => {
return value !== null && value !== undefined;
};

const processItems = (items: (string | null | undefined)[]) => {
// Фильтруем и одновременно сужаем тип
const validItems = items.filter(isDefined);
// validItems: string[] — TypeScript убрал null и undefined

validItems.forEach(item => {
console.log(item.toUpperCase()); // безопасно
});
};

Type guard с instanceof:

class NetworkError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
}
}

class ValidationError extends Error {
constructor(public field: string, message: string) {
super(message);
}
}

const handleError = (error: unknown) => {
if (error instanceof NetworkError) {
// error — NetworkError
console.error(`HTTP ${error.statusCode}: ${error.message}`);
} else if (error instanceof ValidationError) {
// error — ValidationError
console.error(`Validation error on ${error.field}: ${error.message}`);
} else if (error instanceof Error) {
// error — Error
console.error(`Error: ${error.message}`);
} else {
// error — unknown
console.error('Unknown error:', error);
}
};

Type guard для проверки наличия свойств (in operator):

interface Admin {
id: number;
name: string;
permissions: string[];
}

interface RegularUser {
id: number;
name: string;
}

type AppUser = Admin | RegularUser;

const isAdmin = (user: AppUser): user is Admin => {
return 'permissions' in user;
};

const checkAccess = (user: AppUser) => {
if (isAdmin(user)) {
// user — Admin
console.log(`Permissions: ${user.permissions.join(', ')}`);
} else {
// user — RegularUser
console.log('No special permissions');
}
};

Discriminated Union (рекомендуемый паттерн):

Самый удобный способ работы с type guards — использовать дискриминированные союзы:

// Общий дискриминант — поле 'type' или 'kind'
interface LoadingState {
status: 'loading';
}

interface SuccessState {
status: 'success';
data: User[];
}

interface ErrorState {
status: 'error';
error: Error;
}

type AsyncState = LoadingState | SuccessState | ErrorState;

// Type guards генерируестся автоматически
const render = (state: AsyncState) => {
switch (state.status) {
case 'loading':
return <Spinner />;
case 'success':
// state — SuccessState, state.data доступен
return <UserList users={state.data} />;
case 'error':
// state — ErrorState, state.error доступен
return <ErrorMessage error={state.error} />;
}
};

Резюме:

  • any — отключает все проверки. Использовать только как крайнюю меру.
  • unknown — требует проверки перед использованием. Безопасная альтернатива any.
  • Type guard — функция с предикатом value is Type, которая проверяет тип в runtime и позволяет TypeScript сузить тип.

Вопрос 19. В чём отличие типов в JavaScript от типов в TypeScript? Что такое статическая и динамическая типизация? Зачем нужна статическая типизация?

Таймкод: 01:06:20

Ответ собеседника: Правильный. JavaScript — динамически типизированный язык (тип определяется в runtime), TypeScript — статически типизированный (типы задаются явно на этапе компиляции). Статическая типизация позволяет отлавливать ошибки на этапе разработки, делает код более документируемым и понятным для новых разработчиков.

Правильный ответ:

Ответ собеседника корректен. Ниже приведено более детальное объяснение с примерами.


Типы в JavaScript:

JavaScript — язык с динамической слабой типизацией. У каждого значения есть тип, но тип определяется во время выполнения и может меняться.

// Динамическая типизация — тип определяется в runtime
let value = 42; // number
value = 'hello'; // теперь string
value = true; // теперь boolean
// Всё валидно — переменная может хранить любой тип

// 7 примитивных типов JavaScript:
const num: number = 42;
const str: string = 'hello';
const bool: boolean = true;
const nil: null = null;
const undef: undefined = undefined;
const sym: symbol = Symbol('id');
const big: bigint = 9007199254740991n;

// + Object (включая массивы, функции, даты и т.д.)
const obj = { name: 'John' };
const arr = [1, 2, 3];
const fn = () => {};

Слабая типизация — автоматические приведения:

// JavaScript автоматически приводит типы
'5' + 3; // '53' (string)
'5' - 3; // 2 (number)
true + 1; // 2 (number)
[] + []; // '' (string)
[] + {}; // '[object Object]'
{} + []; // 0 (в зависимости от контекста!)

// Это источник багов:
if ('false') {
// выполнится — непустая строка truthy
}

if (0 == '') {
// true — нестрогое сравнение с приведением
}

if (0 === '') {
// false — строгое сравнение без приведения
}

Типы в TypeScript:

TypeScript — язык со статической сильной типизацией. Типы проверяются на этапе компиляции (compile-time), до выполнения кода.

// Статическая типизация — типы проверяются при компиляции
let value: number = 42;
value = 'hello'; // ❌ Ошибка компиляции: Type 'string' is not assignable to type 'number'

// TypeScript расширяет систему типов JavaScript:
const num: number = 42;
const str: string = 'hello';
const bool: boolean = true;
const arr: number[] = [1, 2, 3];
const tuple: [string, number] = ['age', 25];
const obj: { name: string; age: number } = { name: 'John', age: 25 };

// Дополнительные типы, которых нет в JavaScript:
const nothing: void = undefined;
const anything: unknown = fetchData();
const neverReturns: never = (() => { throw new Error(); })();
const nullable: string | null = null;

Сильная типизация — нет автоматических приведений:

// TypeScript не позволяет неявные приведения
const num: number = 42;
const str: string = num; // ❌ Ошибка

// Нужно явное приведение
const str1: string = String(num); // ✅
const str2: string = num.toString(); // ✅

// Или type guard
const value: unknown = 'hello';
if (typeof value === 'string') {
const upper = value.toUpperCase(); // ✅ TypeScript знает, что это string
}

Статическая vs Динамическая типизация:

Динамическая типизация — типы проверяются во время выполнения (runtime).

# Python — динамическая типизация
def add(a, b):
return a + b

add(1, 2) # 3
add('a', 'b') # 'ab'
add(1, 'b') # ❌ TypeError — ошибка только в runtime

Статическая типизация — типы проверяются до выполнения (compile-time).

// Go — статическая типизация
func Add(a int, b int) int {
return a + b
}

Add(1, 2) // 3
Add("a", "b") // ❌ Ошибка компиляции — ещё до запуска

Зачем нужна статическая типизация:

1. Обнаружение ошибок на этапе разработки

// Без типов — ошибка только в runtime
function getUserName(user) {
return user.name.toUpperCase(); // 💥 если user = null
}

// С типами — ошибка на этапе компиляции
function getUserName(user: User): string {
return user.name.toUpperCase(); // ✅ гарантировано, что user не null
}

// Ещё лучше — TypeScript подскажет о nullable
function getUserName(user: User | null): string {
return user.name.toUpperCase(); // ❌ Ошибка: Object is possibly 'null'
}

2. Автодополнение и IntelliSense

interface User {
id: number;
name: string;
email: string;
address: {
city: string;
street: string;
};
}

const user: User = getUser();
// IDE подскажет все свойства:
user. // → id, name, email, address
user.address. // → city, street

3. Рефакторинг с гарантиями

// Изменили интерфейс
interface User {
id: number;
fullName: string; // было name
email: string;
}

// TypeScript покажет ВСЕ места, где использовалось старое поле
const greeting = `Hello, ${user.name}`; // ❌ Ошибка: Property 'name' does not exist
const greeting = `Hello, ${user.fullName}`; // ✅

4. Документирование кода

// Типы — это живая документация, которая не устаревает
function processPayment(
amount: number,
currency: 'USD' | 'EUR' | 'GBP',
options?: {
description?: string;
metadata?: Record<string, string>;
}
): Promise<PaymentResult> {
// ...
}

// По сигнатуре функции понятно:
// - какие параметры принимает
// - какие значения допустимы для currency
// - какие опции доступны
// - что возвращает

5. Безопасность при работе с внешними данными

// API может вернуть что угодно
const response = await fetch('/api/user');
const data: unknown = await response.json();

// Без типов — верим на слово
const name = data.name.toUpperCase(); // 💥 если name не строка

// С типами — проверяем на границе
const userResult = UserSchema.safeParse(data);
if (userResult.success) {
const name = userResult.data.name.toUpperCase(); // ✅ безопасно
}

Сравнительная таблица:

ХарактеристикаJavaScript (динамическая)TypeScript (статическая)
Когда проверяются типыRuntimeCompile-time
Ошибки типовПри выполненииПри компиляции
АвтодополнениеОграниченноеПолное
РефакторингРискованныйБезопасный
ДокументацияКомментарии (могут устаревать)Типы (всегда актуальны)
Время разработкиБыстрее начатьМедленнее начать, быстрее дальше
Порог входаНижеВыше

Компромисс — TypeScript с постепенной типизацией:

TypeScript позволяет вводить типы постепенно:

// Можно начать с any и постепенно уточнять
function processData(data: any) { ... } // этап 1 — миграция
function processData(data: unknown) { ... } // этап 2 — безопасный
function processData(data: ApiData) { ... } // этап 3 — полностью типизированный

// strict mode включает максимальные проверки
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true
}
}

Вопрос 20. Что такое дженерики в TypeScript и зачем они нужны? Как они сочетаются со статической типизацией?

Таймкод: 01:08:38

**Ответ собеседова

Вопрос 21. Чем отличается императивный стиль программирования от функционального? Как это применимо к React?

Таймкод: 01:09:52

Ответ собеседника: Правильный. Императивный стиль — пошаговые инструкции (createElement, addEventListener, appendChild). Функциональный/декларативный стиль — описание результата (JSX с кнопкой и обработчиком сразу). React использует декларативный функциональный подход. Из паттернов функционального программирования в React: компоненты высшего порядка (HOC), чистые функции-редюсеры, React.memo.

Правильный ответ:

Ответ собеседника корректен. Ниже приведено более детальное объяснение с примерами.


Императивный стиль — «как сделать»

Императивное программирование описывает пошаговый алгоритм: какие действия выполнить, в каком порядке, как изменить состояние.

// Императивный подход — манипуляция DOM напрямую
const button = document.createElement('button');
button.textContent = 'Click me';
button.classList.add('btn', 'btn-primary');
button.addEventListener('click', function() {
const counter = document.getElementById('counter');
const currentValue = parseInt(counter.textContent);
counter.textContent = currentValue + 1;
});
document.getElementById('app').appendChild(button);

// Императивный подход — цикл с мутацией
const numbers = [1, 2, 3, 4, 5];
const doubled = [];
for (let i = 0; i < numbers.length; i++) {
doubled.push(numbers[i] * 2);
}

// Императивный подход — пошаговое изменение
let result = '';
for (const user of users) {
if (user.age >= 18) {
result += user.name + ', ';
}
}
result = result.slice(0, -2);

Характерные черты императивного стиля:

  • Пошаговые инструкции.
  • Мутация переменных.
  • Явное управление потоком выполнения (if/else, for, while).
  • Состояние изменяется напрямую.

Декларативный/функциональный стиль — «что получить»

Декларативное программирование описывает желаемый результат, а не способ его достижения.

// Декларативный подход — React
const Counter = () => {
const [count, setCount] = useState(0);

return (
<div>
<p id="counter">{count}</p>
<button className="btn btn-primary" onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
};

// Функциональный подход — преобразование данных
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(n => n * 2);

// Функциональный подход — цепочка преобразований
const result = users
.filter(user => user.age >= 18)
.map(user => user.name)
.join(', ');

Характерные черты функционального стиля:

  • Описание результата, а не процесса.
  • Неизменяемость (immutability) — данные не мутируются, создаются новые.
  • Чистые функции — без побочных эффектов, детерминированные.
  • Функции как значения — передача функций в качестве аргументов.

Ключевые концепции функционального программирования в React:

1. Чистые функции (Pure Functions)

Компоненты React должны быть чистыми — при одинаковых пропсах всегда возвращают одинаковый JSX.

// Чистый компонент — зависит только от пропсов
const Greeting = ({ name }) => {
return <h1>Hello, {name}!</h1>;
};

// Нечистый компонент — зависит от внешнего состояния
let externalCounter = 0;
const ImpureGreeting = ({ name }) => {
externalCounter++; // побочный эффект!
return (
<div>
<h1>Hello, {name}!</h1>
<p>Rendered {externalCounter} times</p>
</div>
);
};

2. Неизменяемость (Immutability)

// ❌ Мутация — не вызовет ререндер
const handleAddItemBad = () => {
items.push(newItem);
setItems(items); // та же ссылка — React не видит изменение
};

// ✅ Неизменяемость — создаём новый массив
const handleAddItemGood = () => {
setItems([...items, newItem]); // новый массив — React видит изменение
};

// ✅ Обновление объекта
const handleUpdateUser = () => {
setUser({ ...user, name: 'New Name' }); // новый объект
};

// ✅ Обновление вложенного объекта
const handleUpdateAddress = () => {
setUser({
...user,
address: { ...user.address, city: 'New City' },
});
};

3. Функции высшего порядка (Higher-Order Functions)

// HOC — функция, принимающая компонент и возвращающая новый
const withLoading = (Component) => {
return function WithLoadingComponent({ isLoading, ...props }) {
if (isLoading) {
return <Spinner />;
}
return <Component {...props} />;
};
};

const UserListWithLoading = withLoading(UserList);
// <UserListWithLoading isLoading={true} users={[]} />

// Композиция HOC
const EnhancedUserList = withErrorBoundary(withLoading(UserList));

4. Композиция функций

// Вместо одной большой функции — композиция маленьких
const withAuth = (Component) => (props) => {
const { user } = useAuth();
if (!user) return <Redirect to="/login" />;
return <Component {...props} user={user} />;
};

const withPermissions = (requiredRole) => (Component) => (props) => {
const { user } = props;
if (user.role !== requiredRole) return <Forbidden />;
return <Component {...props} />;
};

// Композиция
const AdminPanel = withAuth(withPermissions('admin')(AdminPanelContent));

// Или через compose
const compose = (...fns) => (x) => fns.reduceRight((acc, fn) => fn(acc), x);
const enhance = compose(withAuth, withPermissions('admin'));
const AdminPanel = enhance(AdminPanelContent);

5. Редюсеры — чистые функции

// Редюсер — чистая функция: (state, action) => newState
const todosReducer = (state: Todo[], action: TodoAction): Todo[] => {
switch (action.type) {
case 'ADD_TODO':
return [...state, action.payload]; // новый массив
case 'TOGGLE_TODO':
return state.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed } // новый объект
: todo
);
case 'REMOVE_TODO':
return state.filter(todo => todo.id !== action.payload); // новый массив
default:
return state;
}
};

// useReducer в компоненте
const TodoApp = () => {
const [todos, dispatch] = useReducer(todosReducer, []);

const addTodo = (text: string) => {
dispatch({ type: 'ADD_TODO', payload: { id: Date.now(), text, completed: false } });
};

return (
<div>
<TodoList todos={todos} onToggle={(id) => dispatch({ type: 'TOGGLE_TODO', payload: id })} />
<AddTodoForm onAdd={addTodo} />
</div>
);
};

6. React.memo — мемоизация

// React.memo — мемоизация компонента (аналог мемоизации функции)
const ExpensiveComponent = React.memo(({ data }: { data: DataItem[] }) => {
console.log('ExpensiveComponent render');
return (
<ul>
{data.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
});

// Передача функций — useCallback для стабильной ссылки
const Parent = () => {
const [count, setCount] = useState(0);

// Без useCallback — новая функция при каждом рендере
const handleClickBad = () => console.log('clicked');

// С useCallback — стабильная ссылка
const handleClickGood = useCallback(() => console.log('clicked'), []);

return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<ExpensiveComponent data={data} onClick={handleClickGood} />
</div>
);
};

Сравнение подходов на примере:

// Задача: отрисовать список активных пользователей

// Императивный подход
const renderActiveUsers = (containerId, users) => {
const container = document.getElementById(containerId);
container.innerHTML = '';

const ul = document.createElement('ul');
ul.className = 'user-list';

for (let i = 0; i < users.length; i++) {
if (users[i].isActive) {
const li = document.createElement('li');
li.textContent = users[i].name;
li.className = 'user-item';
li.addEventListener('click', function() {
alert('Clicked: ' + users[i].name);
});
ul.appendChild(li);
}
}

container.appendChild(ul);
};

// Декларативный/функциональный подход (React)
const ActiveUserList = ({ users, onUserClick }) => {
const activeUsers = users.filter(user => user.isActive);

return (
<ul className="user-list">
{activeUsers.map(user => (
<li key={user.id} className="user-item" onClick={() => onUserClick(user)}>
{user.name}
</li>
))}
</ul>
);
};

Сводная таблица:

ХарактеристикаИмперативныйФункциональный/Декларативный
ФокусКак сделатьЧто получить
СостояниеМутацияНеизменяемость
Управление потокомЦиклы, условияmap, filter, reduce
Побочные эффектыВездеИзолированы (useEffect)
ПереиспользованиеКопипастаКомпозиция функций
ТестированиеСложнее (зависимость от состояния)Проще (чистые функции)

Почему React использует функциональный подход:

  • Предсказуемость: одинаковые пропсы → одинаковый результат.
  • Тестируемость: чистые функции легко тестировать.
  • Композиция: компоненты комбинируются как функции.
  • Оптимизация: React.memo, useMemo, useCallback основаны на чистоте функций.
  • Параллелизм: React Concurrent Mode безопасен благодаря неизменяемости.

Вопрос 22. Что такое иммутабельность? Каковы её плюсы и минусы? Зачем иммутабельность нужна в Redux?

Таймкод: 01:13:14

Ответ собеседника: Правильный. Иммутабельность — работа с копией данных вместо изменения оригинала. Плюсы: предсказуемость поведения, отсутствие неожиданных побочных эффектов при изменении данных в разных частях кода. Минусы: дополнительное потребление памяти для создания копий. В Redux иммутабельность нужна, чтобы редюсер возвращал новую ссылку на state — иначе Redux не сможет отследить изменение (при мутации ссылка остаётся прежней).

Правильный ответ:

Ответ собеседника полностью корректен. Ниже приведено более детальное объяснение с дополнительными нюансами.


Иммутабельность (Immutability) — принцип, при котором данные не изменяются после создания. Вместо мутации оригинала создаётся новая копия с изменениями.

// Мутация (изменение оригинала)
const user = { name: 'John', age: 30 };
user.age = 31; // мутируем объект
console.log(user); // { name: 'John', age: 31 }

// Иммутабельность (создание нового объекта)
const user = { name: 'John', age: 30 };
const updatedUser = { ...user, age: 31 }; // новый объект
console.log(user); // { name: 'John', age: 30 } — оригинал не изменился
console.log(updatedUser); // { name: 'John', age: 31 }

Плюсы иммутабельности:

1. Предсказуемость и отсутствие побочных эффектов

// Проблема мутации
const config = { theme: 'dark', language: 'en' };

const applyLightTheme = (cfg) => {
cfg.theme = 'light'; // мутация!
return cfg;
};

const lightConfig = applyLightTheme(config);
console.log(config); // { theme: 'light', language: 'en' } — оригинал изменился!
console.log(lightConfig); // { theme: 'light', language: 'en' } — та же ссылка!

// С иммутабельностью
const config = { theme: 'dark', language: 'en' };

const applyLightTheme = (cfg) => {
return { ...cfg, theme: 'light' }; // новый объект
};

const lightConfig = applyLightTheme(config);
console.log(config); // { theme: 'dark', language: 'en' } — оригинал не тронут
console.log(lightConfig); // { theme: 'light', language: 'en' } — новый объект

2. Простое сравнение по ссылке (Referential Equality)

// С иммутабельностью — быстрое сравнение
const state1 = { count: 0 };
const state2 = state1;

state2.count = 1; // мутация
console.log(state1 === state2); // true — та же ссылка, непонятно, изменилось ли что-то

// С иммутабельностью
const state1 = { count: 0 };
const state2 = { ...state1, count: 1 }; // новый объект
console.log(state1 === state2); // false — разные ссылки, значит данные изменились

Это критически важно для React и Redux — они используют поверхностное сравнение ссылок для определения изменений.

3. Time-travel debugging

// История состояний сохраняется корректно
const history = [
{ count: 0 }, // state1 — оригинал
{ count: 1 }, // state2 — новый объект
{ count: 2 }, // state3 — новый объект
];

// Можно откатиться к любому моменту
const previousState = history[0]; // { count: 0 } — гарантированно не изменился

// При мутации это невозможно:
const state = { count: 0 };
const history = [state];
state.count = 1; // мутируем
history.push(state);
console.log(history); // [{ count: 1 }, { count: 1 }] — оба элемента изменились!

4. Безопасность при параллельном доступе

// Иммутабельные данные безопасны для чтения из разных потоков/контекстов
const sharedData = Object.freeze({ items: [1, 2, 3] });

// Любой код может читать sharedData, не опасаясь что другой код его изменит
const processData = (data) => {
// data.items гарантированно не изменится пока мы работаем
return data.items.map(x => x * 2);
};

Минусы иммутабельности:

1. Потребление памяти

// Каждое изменение создаёт новый объект
const largeArray = new Array(100000).fill(0);

// Добавление элемента — копирование всего массива
const newArray = [...largeArray, 1]; // +100000 элементов в памяти

// Решение: использовать структуры данных с structural sharing (Immutable.js, Immer)

2. Производительность при глубоких изменениях

// Глубокое копирование дорого
const deepObject = {
level1: {
level2: {
level3: {
value: 42
}
}
}
};

// Изменение глубоко вложенного значения
const updated = {
...deepObject,
level1: {
...deepObject.level1,
level2: {
...deepObject.level1.level2,
level3: {
...deepObject.level1.level2.level3,
value: 43
}
}
}
};

// Решение: Immer — выглядит как мутация, но создаёт новый объект
import { produce } from 'immer';

const updated = produce(deepObject, draft => {
draft.level1.level2.level3.value = 43; // выглядит как мутация!
});

3. Кривая обучения

// Начинающие разработчики часто делают поверхностное копирование
const state = {
user: { name: 'John', address: { city: 'NYC' } }
};

// Поверхностное копирование — ошибка!
const updated = { ...state, user: { name: 'Jane' } };
// state.user.address потерялся!

// Нужно копировать все уровни
const updated = {
...state,
user: {
...state.user,
name: 'Jane'
}
};

Зачем иммутабельность нужна в Redux:

Redux использует поверхностное сравнение ссылок (shallow equality check) для определения изменилось ли состояние.

// Как Redux проверяет изменения:
const isStateChanged = (oldState, newState) => {
return oldState !== newState; // простое сравнение по ссылке!
};

// Если мутировали — Redux не видит изменение
const oldState = { count: 0 };
const newState = oldState;
newState.count = 1; // мутация

console.log(oldState === newState); // true — Redux решит, что ничего не изменилось!
// Компоненты НЕ перерендерятся — баг

// Если создали новый объект — Redux видит изменение
const oldState = { count: 0 };
const newState = { ...oldState, count: 1 }; // новый объект

console.log(oldState === newState); // false — Redux видит изменение
// Компоненты перерендерятся — корректное поведение

Механизм работы:

dispatch(action) → reducer(oldState, action) → newState

oldState === newState?
↓ ↓
false true
↓ ↓
Уведомить Ничего не
подписчиков делать

Пример редьюсера с иммутабельностью:

const initialState = {
users: [],
loading: false,
error: null,
};

const usersReducer = (state = initialState, action) => {
switch (action.type) {
case 'FETCH_USERS_START':
return { ...state, loading: true, error: null };

case 'FETCH_USERS_SUCCESS':
return { ...state, loading: false, users: action.payload };

case 'FETCH_USERS_ERROR':
return { ...state, loading: false, error: action.payload };

case 'ADD_USER':
return { ...state, users: [...state.users, action.payload] };

case 'REMOVE_USER':
return { ...state, users: state.users.filter(u => u.id !== action.payload) };

case 'UPDATE_USER':
return {
...state,
users: state.users.map(u =>
u.id === action.payload.id ? { ...u, ...action.payload } : u
),
};

default:
return state;
}
};

Immer — удобная иммутабельность в Redux:

Immer позволяет писать «мутабельный» код, который автоматически создаёт новый объект:

import { createSlice } from '@reduxjs/toolkit'; // Immer встроен в RTK

const usersSlice = createSlice({
name: 'users',
initialState: {
items: [],
loading: false,
},
reducers: {
// Выглядит как мутация, но Immer создаёт новый объект
addUser: (state, action) => {
state.items.push(action.payload); // «мутация» — безопасна благодаря Immer
},
removeUser: (state, action) => {
const index = state.items.findIndex(u => u.id === action.payload);
if (index !== -1) {
state.items.splice(index, 1); // «мутация» — безопасна
}
},
updateUser: (state, action) => {
const user = state.items.find(u => u.id === action.payload.id);
if (user) {
user.name = action.payload.name; // «мутация» — безопасна
}
},
setLoading: (state, action) => {
state.loading = action.payload; // «мутация» — безопасна
},
},
});

Как Immer работает:

import { produce } from 'immer';

const state = {
users: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
],
};

// produce создаёт новый объект, позволяя «мутировать» draft
const newState = produce(state, draft => {
draft.users[0].name = 'John Updated'; // выглядит как мутация
draft.users.push({ id: 3, name: 'Bob' });
});

console.log(state === newState); // false — новый объект
console.log(state.users[0].name); // 'John' — оригинал не изменился
console.log(newState.users[0].name); // 'John Updated'

Структурное разделение (Structural Sharing):

Immer и Immutable.js используют structural sharing — неизменённые части объекта разделяют память:

Original: { a: { b: 1 }, c: { d: 2 } }

Updated: { a: { b: 3 }, c: { d: 2 } }

Память: { a: { b: 3 } } + { c: { d: 2 } } ← эта часть та же самая

Это решает проблему потребления памяти — копируются только изменённые пути.


Резюме:

АспектБез иммутабельностиС иммутабельностью
Сравнение измененийГлубокое (дорого)По ссылке (дёшево)
ОтладкаСложно (mutations не отследить)Легко (time-travel)
Побочные эффектыВозможныИсключены
ПараллелизмНебезопасенБезопасен
ПамятьЭкономичнееБольше (решается structural sharing)