Реальное собеседование Senior Frontend 390 gross + 25% премии
Сегодня мы разберём техническое интервью с кандидатом на позицию фронтенд-разработчика, в ходе которого обсуждались ключевые концепции React, принципы программирования и опыт работы в продуктовой команде. Кандидат продемонстрировал уверенное владение хуками, контекстом, а также понимание SOLID и других архитектурных подходов, хотя в некоторых моментах допускал неточности или затруднялся с формулировками. Особое внимание было уделено его реальному опыту участия в разработке решений для импортозамещения в условиях жёстких сроков и высоких требований к производительности.
Вопрос 1. Расскажите о вашем опыте работы и основных задачах на последнем проекте.
Таймкод: 00:00:00
Ответ собеседника: Правильный. Последние 4 года работает в компании, занимающейся транспортной инфраструктурой. Разрабатывал окно для просмотра видеоархива в реальном времени (RTC), окно для выгрузки данных с возможностью формирования таблиц и выбора колонок, а также окно со статистикой для просмотра событий и количества проезжающих машин. Работал с проектами на въездах в Москву с распознаванием транспорта и номеров, а также с аналогичным проектом для железнодорожной инфраструктуры, где распознаются дефекты на колёсах поездов. Использовал стек: React, Redux (классический Redux), Redux-Saga, RTK Query, а также Zustand на последнем проекте.
Правильный ответ:
Ответ собеседника корректно описывает опыт работы в предметной области транспортной инфраструктуры и перечисляет ключевые задачи: работа с видеоархивом в реальном времени, выгрузка данных, визуализация статистики, распознавание объектов. Однако вопрос был задан на позицию Golang-разработчика, а ответ полностью сосредоточен на фронтенд-стеке (React, Redux, Redux-Saga, RTK Query, Zustand). Это вызывает вопросы — кандидат не упомянул ни одного аспекта бэкенд-разработки на Go.
Для позиции Golang-разработчика ожидается, что кандидат расскажет о:
Архитектуре бэкенд-сервисов
Какие микросервисы разрабатывались, как они взаимодействовали между собой (gRPC, REST, message brokers). Какие паттерны проектирования применялись (CQRS, Event Sourcing, Clean Architecture).
Работа с данными
Какие базы данных использовались (PostgreSQL, ClickHouse, Redis, MongoDB). Как организовывалась работа с большими объёмами данных — потоковая обработка видео, агрегация статистики по транспорту.
Производительность и масштабируемость
Как обеспечивалась обработка данных в реальном времени. Использование горутин, каналов, worker pools для параллельной обработки видеопотоков и распознавания объектов.
Интеграции
Взаимодействие с ML-моделями распознавания (инференс, передача данных для анализа). Интеграция с системами видеонаблюдения и IoT-устройствами.
Инфраструктура
Контейнеризация (Docker, Kubernetes), CI/CD пайплайны, мониторинг (Prometheus, Grafana), логирование.
Кандидату стоит дополнить ответ информацией о своей роли как Go-разработчика в этих проектах, даже если он также занимался фронтенд-частью.
Вопрос 2. Какие хуки в React вы знаете, что они принимают и возвращают, для чего нужны?
Таймкод: 00:06:07
Ответ собеседника: Правильный. Рассказал про useState — возвращает массив из переменной состояния и функции для её изменения. React отслеживает вызов функции обновления и перерисовывает компонент. Упомянул, что React называют нереактивным, потому что отслеживается не само изменение состояния, а вызов функции. Также рассказал про useEffect и useLayoutEffect — принимают коллбэк-функцию и массив зависимостей, useEffect срабатывает после отрисовки, useLayoutEffect — до отрисовки.
Правильный ответ:
Ответ собеседника затронул только два хука из обширного списка, поэтому стоит дать полный обзор.
Базовые хуки
useState — управление локальным состоянием компонента. Принимает начальное значение, возвращает массив [state, setState]. При вызове setState React планирует повторный рендер. Собеседник верно отметил, что React не отслеживает мутации напрямую — именно вызов setter-функции триггерит перерисовку.
const [count, setCount] = useState(0);
useEffect — побочные эффекты (запросы к API, подписки, таймеры). Принимает коллбэк и массив зависимостей. Коллбэк может возвращать функцию очистки (cleanup). Выполняется после того, как React обновил DOM и браузер отрисовал изменения.
useEffect(() => {
const subscription = api.subscribe(id, handler);
return () => subscription.unsubscribe(); // cleanup
}, [id]);
useLayoutEffect — аналогичен useEffect, но выполняется синхронно после вычисления изменений в DOM, но до отрисовки браузером. Используется, когда нужно прочитать размеры/позицию элемента из DOM и синхронно применить изменения, чтобы избежать визуального мерцания.
useLayoutEffect(() => {
const rect = ref.current.getBoundingClientRect();
setTooltipPosition(rect.top);
}, []);
useRef — возвращает мутабельный объект { current: value }, который сохраняется между рендерами. Не вызывает перерисовку при изменении. Используется для доступа к DOM-элементам и хранения мутабельных значений.
const inputRef = useRef(null);
const renderCount = useRef(0);
renderCount.current += 1;
useMemo — мемоизирует вычисленное значение. Принимает функцию-вычислитель и массив зависимостей. Возвращает кэшированное значение, пересчитываемое только при изменении зависимостей.
const expensiveValue = useMemo(() => {
return computeExpensive(data);
}, [data]);
useCallback — мемоизирует саму функцию. По сути это useMemo(() => fn, deps). Возвращает ту же ссылку на функцию, если зависимости не изменились. Критически важен при передаче колбэков в дочерние компоненты, обёрнутые в React.memo.
const handleClick = useCallback(() => {
doSomething(id);
}, [id]);
useReducer — управление сложным состоянием через редьюсер. Принимает reducer и initialState, возвращает [state, dispatch. Аналог Redux на уровне компонента.
const [state, dispatch] = useReducer(reducer, initialState);
useContext — доступ к значению контекста без оборачивания в Consumer. Принимает объект контекста, возвращает текущее значение.
const theme = useContext(ThemeContext);
Хуки React 18+
useId — генерация уникальных ID, стабильных между серверным и клиентским рендером (для SSR).
useTransition — позволяет пометить обновление как несрочное, чтобы UI оставался отзывчивым.
useDeferredValue — откладывает обновление значения до менее срочного рендера.
useSyncExternalStore — безопасная подписка на внешние хранилища с поддержкой Concurrent Mode.
Пользовательские хуки (Custom Hooks)
Любая функция, начинающаяся с use, может комбинировать стандартные хуки для переиспользуемой логики. Это основной механизм переиспользования логики в функциональных компонентах.
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
Ключевые правила хуков
Хуки нельзя вызывать внутри условий, циклов или вложенных функций — только на верхнем уровне компонента. Это связано с тем, что React опирается на порядок вызовов хуков для сопоставления их с внутренним состоянием. Также хуки нельзя вызывать из обычных JS-функций — только из компонентов или пользовательских хуков.
Вопрос 3. Можно ли передать функцию в качестве начального значения в useState и как это будет работать?
Таймкод: 00:07:45
Ответ собеседника: Неполный. Ответил, что можно передать любое значение, включая функцию, так как функция тоже является объектом. Предположил, что это может быть нужно для инициализации компонента. Не упомянул концепцию lazy initialization — если передать функцию без вызова, React вызовет её только при первом рендере, и возвращаемое значение станет начальным состоянием.
Правильный ответ:
Да, в useState можно передать функцию, и здесь есть важный нюанс, который кандидат упустил.
Два варианта передачи начального значения
1. Передача значения напрямую (eager initialization)
const [state, setState] = useState(computeExpensiveValue());
Функция computeExpensiveValue() выполнится при каждом рендере компонента. React проигнорирует результат при последующих рендерах (использует только при первом), но сам вызов функции всё равно произойдёт. Это может быть проблемой, если вычисление дорогостоящее.
2. Передача функции-инициализатора (lazy initialization)
const [state, useState(() => computeExpensiveValue());
Если передать ссылку на функцию (без вызова), React вызовет её только один раз — при первом рендере. Возвращаемое значение станет начальным состоянием. При последующих рендерах эта функция не вызывается вообще.
Почему это важно
Lazy initialization критичен, когда начальное состояние требует вычислений: парсинг из localStorage, чтение из URL, сложные вычисления.
// Плохо: JSON.parse выполнится при каждом рендере
const [user, setUser] = useState(JSON.parse(localStorage.getItem('user')));
// Хорошо: JSON.parse выполнится только при первом рендере
const [user, setUser] = useState(() => JSON.parse(localStorage.getItem('user')));
Подводный камушка — функция как значение
Если начальное состояние должно быть именно функцией (например, для хранения обработчика), то нужно использовать двойную обёртку:
// Неправильно: React вызовет handleClick и возвратит undefined
const [handler, setHandler] = useState(handleClick);
// Правильно: передаём функцию, которая возвращает функцию-обработчик
const [handler, setHandler] = useState(() => handleClick);
React проверяет: если аргумент useState является функцией — он вызывает её и использует результат как начальное состояние. Это поведение зафиксировано в документации и является частью API.
Сравнение подходов
| Синтаксис | Вызов функции при каждом рендере | Вызов при первом рендере |
|---|---|---|
useState(expensive()) | Да | Да |
useState(() => expensive()) | Нет | Да |
useState(myFunction) | Нет (вызовется как инициализатор) | Да (вызовется как инициализатор) |
Кандидат верно отметил, что функция — это объект в JavaScript, но не упомянул, что React специально обрабатывает передачу функции как ленивый инициализатор, что является ключевым аспектом этого вопроса.
Вопрос 4. Какие аргументы принимает useEffect, как ведёт себя при пустом массиве зависимостей и при отсутствии второго аргумента?
Таймкод: 00:09:32
Ответ собеседника: Правильный. useEffect принимает коллбэк-функцию и массив зависимостей (второй аргумент необязателен). Если массив зависимостей пустой — эффект выполнится один раз при монтировании. Если второго аргумента нет — эффект выполняется при каждом рендере.
Правильный ответ:
Ответ собеседника в целом верный, но стоит раскрыть тему глубже, включая нюансы, которые часто проверяют на интервью.
Сигнатура useEffect
useEffect(effectCallback, dependencies?)
effectCallback — функция, содержащая побочную логику. Может возвращать функцию очистки (cleanup), которую React вызовет перед следующим выполнением эффекта и при размонтировании компонента.
dependencies — опциональный массив значений, от которых зависит эффект. React сравнивает текущие значения с предыдущими через Object.is.
Три сценария поведения
1. Пустой массив зависимостей []
useEffect(() => {
// Выполняется один раз после монтирования
const subscription = api.subscribe();
return () => {
// Cleanup — выполнится при размонтировании
subscription.unsubscribe();
};
}, []);
Эффект запускается однократно после первого рендера. Cleanup-функция — при размонтировании. Это аналог componentDidMount + componentWillUnmount в классовых компонентах.
2. Массив с зависимостями [dep1, dep2]
useEffect(() => {
fetchData(userId, page);
}, [userId, page]);
Эффект запускается при монтировании, а затем при каждом рендере, где хотя бы одна зависимость изменилась (по сравнению Object.is). Cleanup предыдущего эффекта выполняется перед запуском нового.
3. Отсутствие второго аргумента
useEffect(() => {
// Выполняется после КАЖДОГО рендера
document.title = `Count: ${count}`;
});
Эффект запускается после каждого рендера компонента. Это потенциально дорого и обычно указывает на проблему в дизайне — либо нужны зависимости, либо эффект не нужен.
Важные нюансы
Порядок выполнения: после монтирования → изменение зависимостей → cleanup предыдущего эффекта → новый эффект → размонтирование → cleanup.
Строгий режим (Strict Mode): в development-режиме React 18 монтирует компонент дважды, чтобы выявить проблемы с cleanup-функциями. Эффект выполняется: mount → cleanup → mount.
Ссылочные типы в зависимостях: объекты и функции, создаваемые при каждом рендере, будут вызывать бесконечный цикл эффектов. Решение — useMemo для объектов, useCallback для функций.
// Плохой паттерн: новый объект при каждом рендере
useEffect(() => {
fetchData({ id: userId });
}, [{ id: userId }]); // Object.is всегда вернёт false
// Хороший паттерн
const params = useMemo(() => ({ id: userId }), [userId]);
useEffect(() => {
fetchData(params);
}, [params]);
Правило: все значения из замыкания компонента, используемые внутри эффекта, должны быть в массиве зависимостей. Линтер eslint-plugin-react-hooks с правилом exhaustive-deps помогает отслеживать это.
Вопрос 5. Как отменить подписку внутри useEffect при размонтировании компонента?
Таймкод: 00:10:03
Ответ собеседника: Правильный. Нужно вернуть функцию из коллбэка useEffect — эта cleanup-функция будет вызвана при размонтировании компонента или перед повторным выполнением эффекта при изменении зависимостей.
Правильный ответ:
Ответ собеседника корректен. Вот развёрнутое объяснение с примерами.
Cleanup-функция в useEffect
Коллбэк, переданный в useEffect, может возвращать функцию. React вызывает эту функцию в двух случаях: перед повторным выполнением эффекта (при изменении зависимостей) и при размонтировании компонента.
Примеры отмены подписок
WebSocket-соединение:
useEffect(() => {
const ws = new WebSocket('wss://api.example.com');
ws.onmessage = (event) => {
setMessages(prev => [...prev, JSON.parse(event.data)]);
};
return () => {
ws.close();
};
}, []);
Подписка на внешний сервис:
useEffect(() => {
const subscription = eventBus.subscribe('user:update', handleUpdate);
return () => {
eventBus.unsubscribe(subscription);
};
}, []);
AbortController для fetch-запросов:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') {
setError(err);
}
});
return () => {
controller.abort(); // Отменяет незавершённый запрос
};
}, [userId]);
Таймеры:
useEffect(() => {
const timerId = setInterval(() => {
setTick(t => t + 1);
}, 1000);
return () => {
clearInterval(timerId);
};
}, []);
MutationObserver / IntersectionObserver:
useEffect(() => {
const observer = new IntersectionObserver(handleIntersect);
observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, []);
Порядок выполнения cleanup
Важно понимать последовательность: при изменении зависимостей React сначала выполняет cleanup предыдущего эффекта, затем запускает новый эффект. Это предотвращает утечки памяти и гонки данных.
Рендер 1: эффект A
Рендер 2: cleanup A → эффект B
Рендер 3: cleanup B → эффект C
Размонтирование: cleanup C
Типичная ошибка — отсутствие cleanup
// Утечка памяти: подписка никогда не отменяется
useEffect(() => {
const subscription = store.subscribe(handleChange);
// Нет return — при каждом размонтировании подписка остаётся в памяти
}, []);
Паттерн с флагом для асинхронных операций
Иногда cleanup не может отменить асинхронную операцию (например, уже отправленный fetch без AbortController). В таких случаях используют флаг isMounted:
useEffect(() => {
let cancelled = false;
async function fetchData() {
const data = await api.get(userId);
if (!cancelled) {
setUser(data);
}
}
fetchData();
return () => {
cancelled = true;
};
}, [userId]);
Однако AbortController предпочтительнее, так как он действительно прерывает сетевой запрос, а просто игнорирует результат.
Вопрос 6. Для чего нужен useMemo и как он работает? Приведите пример использования
Таймкод: 00:11:07
Ответ собеседника: Неполный. Рассказал, что useMemo запоминает результат выполнения функции и пересчитывает его только при изменении зависимостей из массива второго параметра. Это нужно для оптимизации дорогих вычислений. Пример использования привёл неконкретный — упомянул передачу функции в дочерний компонент и модификацию каких-то полей, но чёткого практического примера не дал.
Правильный ответ:
Что делает useMemo
useMemo мемоизирует результат вычисления. Он принимает функцию-вычислитель и массив зависимостей, возвращает кэшированное значение, которое пересчитывается только при изменении зависимостей.
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
Когда использовать
1. Дорогостоящие вычисления:
function ProductList({ products, filter }) {
const filteredProducts = useMemo(() => {
console.log('Filtering...');
return products
.filter(p => p.category === filter.category)
.filter(p => p.price >= filter.minPrice && p.price <= filter.maxPrice)
.sort((a, b) => a.price - b.price);
}, [products, filter.category, filter.minPrice, filter.maxPrice]);
return <List items={filteredProducts} />;
}
Без useMemo фильтрация и сортировка выполнялись бы при каждом рендере, даже если список товаров и фильтр не изменились.
2. Сохранение ссылочной идентичности (referential equality):
Это одна из главных причин использования useMemo. Объекты и массивы, создаваемые внутри компонента, при каждом рендере создают новую ссылку. Если такой объект передаётся в дочерний компонент, обёрнутый в React.memo, мемоизация бесполезна — пропсы всегда «новые».
function Parent() {
const [count, setCount] = useState(0);
const [items, setItems] = useState([1, 2, 3]);
// Без useMemo: новый объект при каждом рендере → Child перерисовывается
const config = { items, theme: 'dark' };
// С useMemo: ссылка стабильна, пока items не изменится
const config = useMemo(() => ({
items,
theme: 'dark'
}), [items]);
return <Child config={config} />;
}
const Child = React.memo(({ config }) => {
return <div>{/* рендер на основе config */}</div>;
});
3. Стабильные ссылки на объекты для зависимостей других хуков:
function Search({ defaultQuery }) {
const [query, setQuery] = useState(defaultQuery);
// Стабильный объект параметров для useEffect
const params = useMemo(() => ({
query,
page: 1,
limit: 20
}), [query]);
useEffect(() => {
api.search(params).then(setResults);
}, [params]); // Без useMemo: новый объект при каждом рендере → бесконечный цикл
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
Когда НЕ использовать useMemo
Не стоит оборачивать в useMemo всё подряд. Сам вызов useMemo имеет накладные расходы — выделение памяти для кэша, сравнение зависимостей. Если вычисление дешёвое, useMemo замедлит работу.
// Бессмысленно: вычисление дешевле, чем сама мемоизация
const doubled = useMemo(() => count * 2, [count]);
// Достаточно просто:
const doubled = count * 2;
useMemo vs useCallback
useCallback(fn, deps) — это синтаксический сахар для useMemo(() => fn, deps). Разница: useMemo мемоизирует результат вызова функции, useCallback мемоизирует саму функцию.
const memoizedCallback = useCallback(() => {
doSomething(a, b);
}, [a, b]);
// Эквивалент:
const memoizedCallback = useMemo(() => {
return () => doSomething(a, b);
}, [a, b]);
Важный нюанс
React не гарантирует, что мемоизированное значение никогда не будет пересчитано. Документация прямо указывает: useMemo — это подсказка для оптимизации, а не гарантия поведения. React может сбросить кэш при необходимости (например, для освобождения памяти в фоновом режиме). Поэтому код должен корректно работать и без useMemo.
Вопрос 7. Приведите пример использования useMemo из реального проекта
Таймкод: 00:11:51
Ответ собеседния: Неполный. Привёл расплывчатый пример — передача функции в дочерний компонент, упомянул, что нужно менять какое-то поле. Ответ был неконкретным и не содержал чёткого практического примера использования useMemo.
Правильный ответ:
Вот несколько конкретных примеров из реальных проектов, где useMemo действительно необходим.
Пример 1: Таблица с агрегацией данных (дашборд со статистикой)
Контекст: компонент отображает таблицу событий с транспортом (как в проекте кандидата — подсчёт проезжающих машин). Данные приходят с бэкенда, нужно вычислить агрегированные метрики.
function TransportStats({ events, selectedDate }) {
const [filter, setFilter] = useState({ type: 'all', minSpeed: 0 });
// Без useMemo: при каждом изменении filter пересчитывались бы и summary,
// и filteredEvents, даже если events не менялся
const filteredEvents = useMemo(() => {
return events.filter(event => {
if (filter.type !== 'all' && event.vehicleType !== filter.type) return false;
if (event.speed < filter.minSpeed) return false;
return true;
});
}, [events, filter.type, filter.minSpeed]);
const summary = useMemo(() => {
const total = filteredEvents.length;
const byType = filteredEvents.reduce((acc, e) => {
acc[e.vehicleType] = (acc[e.vehicleType] || 0) + 1;
return acc;
}, {});
const avgSpeed = filteredEvents.reduce((sum, e) => sum + e.speed, 0) / total || 0;
return { total, byType, avgSpeed: avgSpeed.toFixed(1) };
}, [filteredEvents]);
return (
<div>
<FilterPanel filter={filter} onChange={setFilter} />
<SummaryCards summary={summary} />
<EventsTable events={filteredEvents} />
</div>
);
}
Пример 2: Формирование колонок таблицы с настраиваемым отображением
Контекст: окно выгрузки данных, где пользователь выбирает, какие колонки отображать.
function DataExportTable({ data, visibleColumns, columnFormats }) {
const columns = useMemo(() => {
return visibleColumns.map(colKey => ({
key: colKey,
label: columnFormats[colKey]?.label || colKey,
render: columnFormats[colKey]?.render || ((val) => val),
sortable: columnFormats[colKey]?.sortable ?? true,
}));
}, [visibleColumns, columnFormats]);
const tableData = useMemo(() => {
return data.map(row => {
const formatted = {};
visibleColumns.forEach(colKey => {
formatted[colKey] = columnFormats[colKey]?.render
? columnFormats[colKey].render(row[colKey])
: row[colKey];
});
return formatted;
});
}, [data, visibleColumns, columnFormats]);
return <Table columns={columns} rows={tableData} />;
}
Пример 3: Конфигурация для дочерних компонентов (графики/карты)
Контекст: передача конфигурации в тяжёлый компонент визуализации (графики видеоархива, карты).
function VideoArchiveViewer({ cameras, selectedCameraId }) {
const [timeRange, setTimeRange] = useState({ start: null, end: null });
const [playbackSpeed, setPlaybackSpeed] = useState(1);
// VideoPlayer обёрнут в React.memo и тяжёлый — перерисовка дорогая
const playerConfig = useMemo(() => ({
camera: cameras.find(c => c.id === selectedCameraId),
timeRange,
playbackSpeed,
controls: {
showTimeline: true,
showSpeedControl: true,
allowExport: true,
},
}), [cameras, selectedCameraId, timeRange, playbackSpeed]);
return (
<div>
<TimeRangePicker value={timeRange} onChange={setTimeRange} />
<SpeedControl value={playbackSpeed} onChange={setPlaybackSpeed} />
<MemoizedVideoPlayer config={playerConfig} />
</div>
);
}
const MemoizedVideoPlayer = React.memo(VideoPlayer);
Пример 4: Мемоизированные стили и классы
function StatusBadge({ status, size, onClick }) {
const className = useMemo(() => {
const base = 'badge';
const sizeClass = size === 'small' ? 'badge--sm' : 'badge--lg';
const statusClass = {
active: 'badge--green',
pending: 'badge--yellow',
error: 'badge--red',
}[status] || 'badge--gray';
return `${base} ${sizeClass} ${statusClass}`;
}, [status, size]);
return <span className={className} onClick={onClick}>{status}</span>;
}
Как определить, что useMemo нужен
Два вопроса:
- Вычисление дорогое? (фильтрация больших массивов, сложные расчёты, построение структур данных)
- Результат передаётся в мемоизированный дочерний компонент или используется как зависимость другого хука?
Если ответ «да» хотя бы на один — useMemo оправдан.
Вопрос 8. Для чего нужен useCallback и как он работает?
Таймкод: 00:13:15
Ответ собеседника: Правильный. Используется для мемоизации функций, чтобы при каждом рендере родительского компонента не создавалась новая ссылка на функцию. Это важно при передаче функций в дочерние компоненты, чтобы дочерний компонент не думал, что пришел новый пропс, и не перерисовывался зря. Работает аналогично useMemo, но мемоизирует функцию, а не результат вычисления.
ПравПравильный ответ:
Ответ собеседника корректен. Дополним деталями и примерами.
Сигнатура и принцип работы
const memoizedCallback = useCallback(callback, dependencies);
useCallback возвращает мемоизированную версию функции. Ссылка на функцию остаётся стабильной между рендерами, пока не изменятся зависимости. По сути это синтаксический сахар:
useCallback(fn, deps) ≡ useMemo(() => fn, deps)
Зачем это нужно
В JavaScript функции — объекты. Функция, созданная внутри компонента при каждом рендере, получает новую ссылку:
function Parent() {
const [count, setCount] = useState(0);
// Каждый рендер — новая ссылка на функцию
const handleClick = () => console.log('clicked');
// Child будет перерисовываться при каждом рендере Parent,
// даже если обёрнут в React.memo, потому что handleClick — новый объект
return <MemoizedChild onClick={handleClick} />;
}
Практический пример
function TodoList({ todos }) {
const [filter, setFilter] = useState('all');
// Без useCallback: новая функция при каждом рендере
// С useCallback: стабильная ссылка, пока todos не изменится
const handleDelete = useCallback((id) => {
setTodos(prev => prev.filter(t => t.id !== id));
}, [setTodos]);
const handleToggle = useCallback((id) => {
setTodos(prev => prev.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
));
}, [setTodos]);
return (
<div>
<FilterButtons filter={filter} onChange={setFilter} />
{todos.map(todo => (
<MemoizedTodoItem
key={todo.id}
todo={todo}
onDelete={handleDelete}
onToggle={handleToggle}
/>
))}
</div>
);
}
const MemoizedTodoItem = React.memo(({ todo, onDelete, onToggle }) => {
console.log('TodoItem render:', todo.id);
return (
<div>
<span>{todo.text}</span>
<button onClick={() => onToggle(todo.id)}>Toggle</button>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</div>
);
});
Без useMemo/useCallback при переключении фильтра перерисовывались бы все TodoItem, потому что handleDelete и handleToggle получали бы новые ссылки. С useCallback перерисовываются только те элементы, которые реально изменились.
Когда useCallback необходим
Передача в мемоизированные компоненты:
const MemoizedChart = React.memo(Chart);
function Dashboard({ data }) {
const handleZoom = useCallback((range) => {
// логика зума
}, []);
return <MemoizedChart data={data} onZoom={handleZoom} />;
}
Функция как зависимость в других хуках:
function Search() {
const [query, setQuery] = useState('');
const fetchResults = useCallback(async (searchQuery) => {
const res = await api.search(searchQuery);
return res.data;
}, []);
useEffect(() => {
const timer = setTimeout(() => {
fetchResults(query).then(setResults);
}, 300);
return () => clearTimeout(timer);
}, [query, fetchResults]); // fetchResults стабилен благодаря useCallback
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
Передача в кастомные хуки:
function useInfiniteScroll(fetchMore) {
useEffect(() => {
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) fetchMore();
});
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [fetchMore]); // fetchMore должен быть стабилен
}
Подводные камни
Замыкания и устаревшие значения:
function Counter() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
// Без зависимости от count — замыкание захватит count = 0 навсегда
console.log(count);
}, []); // ← ошибка: count не в зависимостях
return <button onClick={handleClick}>{count}</button>;
}
Решение — либо добавить count в зависимости, либо использовать функциональный сеттер / useRef.
useCallback без React.memo бесполезен:
// useCallback не даёт эффекта, если Child не мемоизирован
const handleClick = useCallback(() => {}, []);
return <Child onClick={handleClick} />; // Child перерисовывается в любом случае
Не используйте useCallback преждевременно. Если компонент-получатель не обёрнут в React.memo, не передаётся в дорогие вычисления и не используется как зависимость хуков — useCallback добавляет накладные расходы без пользы.
Вопрос 9. Что такое React.memo и какие аргументы он принимает?
Таймкод: 00:14:13
Ответ собеседника: Неполный. React.memo — это компонент высшего порядка (HOC), который принимает компонент и возвращает оптимизированную версию, предотвращающую повторный рендер при неизменных пропсах. На вопрос о втором аргументе ответил неуверенно, повторив «зависимости». В действительности React.memo может принимать второй аргумент — функцию сравнения (arePropsEqual).
Правильный ответ:
Что такое React.memo
React.memo — это компонент высшего порядка (Higher-Order Component), который мемоизирует результат рендеринга функционального компонента. Если пропсы не изменились, React использует результат предыдущего рендера без вызова функции компонента.
Сигнатура
const MemoizedComponent = ReactMemo(Component, arePropsEqual?)
Первый аргумент — Component
Любой функциональный компонент.
Второй аргумент — arePropsEqual (опционально)
Пользовательская функция сравнения пропсов. Принимает (prevProps, nextProps) и должна вернуть true, если пропсы равны (рендер не нужен), или false, если пропсы изменились (нужен перерендер).
function arePropsEqual(prevProps, nextProps) {
return prevProps.id === nextProps.id && prevProps.name === nextProps.name;
}
const MemoizedUser = React.memo(UserCard, arePropsEqual);
Поведение по умолчанию (без второго аргумента)
Без кастомного сравнения React.memo выполняет поверхностное (shallow) сравнение каждого пропса через Object.is:
// Эквивалент поведения по умолчанию
function shallowEqual(prevProps, nextProps) {
const keysPrev = Object.keys(prevProps);
const keysNext = Object.keys(nextProps);
if (keysPrev.length !== keysNext.length) return false;
for (const key of keysPrev) {
if (!Object.is(prevProps[key], nextProps[key])) {
return false;
}
}
return true;
}
Когда мемоизация работает
const MemoizedButton = React.memo(({ label, onClick, disabled }) => {
console.log('Button render');
return <button onClick={onClick} disabled={disabled}>{label}</button>;
});
function App() {
const [count, setCount] = useState(0);
// Примитивы — сравнение по значению работает
return <MemoizedButton label="Click me" onClick={() => {}} disabled={false} />;
// Button перерисуется только если label, onClick или disabled изменились
}
Когда мемоизация НЕ работает
function App() {
const [count, setCount] = useState(0);
// Новый объект при каждом рендере → Object.is вернёт false → перерендер
return <MemoizedButton style={{ color: 'red' }} />;
// Новая функция при каждом рендере → перерендер
return <MemoizedButton onClick={() => doSomething()} />;
// Новый массив при каждом рендере → перерендер
return <MemoizedButton items={[1, 2, 3]} />;
}
Решение — стабилизировать ссылки через useMemo и useCallback:
function App() {
const [count, setCount] = useState(0);
const style = useMemo(() => ({ color: 'red' }), []);
const handleClick = useCallback(() => doSomething(), []);
const items = useMemo(() => [1, 2, 3], []);
return <MemoizedButton style={style} onClick={handleClick} items={items} />;
}
Кастомное сравнение — когда нужно
const MemoizedEventCard = React.memo(
({ event, onSelect }) => {
return <div onClick={() => onSelect(event.id)}>{event.title}</div>;
},
(prevProps, nextProps) => {
// Перерендер только если изменились значимые поля события
return (
prevProps.event.id === nextProps.event.id &&
prevProps.event.title === nextProps.event.title &&
prevProps.event.status === nextProps.event.status
);
}
);
React.memo vs PureComponent
React.memo — аналог PureComponent для функциональных компонентов. PureComponent делает поверхностное сравнение и пропсов, и состояния (через shouldComponentUpdate). React.memo сравнивает только пропсы, так как функциональные компоненты не имеют this.state в классическом понимании.
Ограничения
React.memo не предотвращает повторный рендер при изменении внутреннего состояния компонента (useState, useReducer) или при изменении контекста, на который компонент подписан. Он защищает только от перерисовки по пропсам от родителя.
const MemoizedCounter = React.memo(({ label }) => {
const [count, setCount] = useState(0); // Внутреннее состояние
return <button onClick={() => setCount(c => c + 1)}>{label}: {count}</button>;
});
// Нажатие на кнопку вызовет перерендер, несмотря на React.memo,
// потому что изменилось внутреннее состояние
Вопрос 10. Что такое useRef и для чего он нужен?
Таймкод: 00:14:47
Ответ собеседника: Правильный. useRef позволяет хранить значение или объект между рендерами без вызова перерисовки. Возвращает объект-обёртку с полем current для получения текущего значения. Используется, когда нужно сохранить какое-то значение без триггера ре-рендера.
Правильный ответ:
Ответ корректен, дополним практическими примерами и ключевыми нюансами.
Сигнатура
const ref = useRef(initialValue);
// Возвращает: { current: initialValue }
Объект ref один и тот же на протяжении всего жизненного цикла компонента. Изменение ref.current не вызывает перерисовку.
Основные сценарии использования
1. Доступ к DOM-элементам
function TextInput() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus();
}, []);
return <input ref={inputRef} type="text" />;
}
function MeasureElement() {
const divRef = useRef(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useEffect(() => {
const observer = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect;
setDimensions({ width, height });
});
observer.observe(divRef.current);
return () => observer.disconnect();
}, []);
return (
<div ref={divRef}>
Size: {dimensions.width} x {dimensions.height}
</div>
);
}
2. Хранение мутабельных значений без перерисовки
function Timer() {
const [count, setCount] = useState(0);
const intervalRef = useRef(null);
const start = () => {
intervalRef.current = setInterval(() => {
setCount(c => c + 1);
}, 1000);
};
const stop = () => {
clearInterval(intervalRef.current);
intervalRef.current = null;
};
useEffect(() => {
return () => clearInterval(intervalRef.current);
}, []);
return (
<div>
<p>{count}</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
3. Сохранение предыдущего значения
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
// Возвращает значение ПРЕДЫДУЩЕГО рендера (до обновления ref)
return ref.current;
}
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>Current: {count}, Previous: {prevCount}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
);
}
4. Счётчик рендеров
function RenderCounter() {
const renderCount = useRef(0);
renderCount.current += 1;
return <div>Render #{renderCount.current}</div>;
}
5. Хранение флага монтирования (для предотвращения setState на размонтированном компоненте)
function useIsMounted() {
const isMounted = useRef(false);
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
return isMounted;
}
function DataFetcher({ id }) {
const [data, setData] = useState(null);
const isMounted = useIsMounted();
useEffect(() => {
api.fetch(id).then(result => {
if (isMounted.current) {
setData(result);
}
});
}, [id]);
return <div>{data ? data.name : 'Loading...'}</div>;
}
6. Хранение экземпляра класса или внешнего объекта
function VideoPlayer({ src }) {
const playerRef = useRef(null);
useEffect(() => {
playerRef.current = new VideoPlayerInstance(src);
playerRef.current.init();
return () => {
playerRef.current.destroy();
};
}, [src]);
const handlePlay = () => {
playerRef.current.play();
};
return <button onClick={handlePlay}>Play</button>;
}
Ключевые отличия от useState
| Характеристика | useRef | useState |
|---|---|---|
| Изменение вызывает перерендер | Нет | Да |
| Значение сохраняется между рендерами | Да | Да |
| Асинхронность обновлений | Синхронно (сразу доступно) | Асинхронно (батчинг) |
| Возвращает | { current: value } | [value, setter] |
Важный нюанс: синхронность обновлений
Изменение ref.current применяется немедленно, в отличие от setState:
function Example() {
const [state, setState] = useState(0);
const ref = useRef(0);
const handleClick = () => {
setState(s => s + 1);
ref.current += 1;
console.log('state:', state); // ← старое значение (ещё не обновилось)
console.log('ref:', ref.current); // ← новое значение (уже обновилось)
};
return <button onClick={handleClick}>Click</button>;
}
Это делает ref удобным для хранения значений, которые нужно читать сразу после записи в том же тике выполнения.
useRef и реактивность
Значение ref.current не является реактивным — React не отслеживает его изменения. Если нужно значение, которое видно в JSX и обновляется при каждом рендере — используйте useState. Если нужно просто сохранить данные между рендерами без влияния на UI — useRef.
Вопрос 11. Что такое Context в React и какую проблему он решает?
Таймкод: 00:15:15
Ответ собеседника: Правильный. Context решает проблему передачи данных между компонентами (аналогично менеджерам состояния). Позволяет создать объект-хранилище и предоставить доступ к нему дочерним компонентам через Provider. Продемонстрировал на практике создание контекста, оборачивание родительского компонента в Provider и получение значений через useContext в дочернем компоненте.
Правильный ответ:
Ответ корректен, но стоит уточнить формулировку и раскрыть тему глубже.
Какую проблему решает Context
Context решает проблему prop drilling — необходимости передавать данные через цепочку промежуточных компонентов, которые эти данные не используют, а просто пробрасывают дальше.
// Без Context: prop drilling
<App user={user} theme={theme}>
<Header user={user} theme={theme}>
<Nav user={user} theme={theme}>
<UserMenu user={user} /> {/* только здесь нужен user */}
</Nav>
</Header>
</App>
// С Context: промежуточные компоненты ничего не знают о user
<App>
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<Header>
<Nav>
<UserMenu /> {/* получает user напрямую из контекста */}
</Nav>
</Header>
</ThemeContext.Provider>
</UserContext.Provider>
</App>
API Context
Создание контекста:
const ThemeContext = React.createContext(defaultValue);
Provider — предоставление значения:
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<MainLayout />
</ThemeContext.Provider>
);
}
Потребление значения — useContext:
function ThemedButton() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button
className={`btn btn--${theme}`}
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
>
Toggle Theme
</button>
);
}
Потребление через Consumer (для классовых компонентов):
class ThemedButton extends React.Component {
static contextType = ThemeContext;
render() {
const { theme } = this.context;
return <button className={`btn btn--${theme}`}>Click</button>;
}
}
Паттерн: раздельные контексты для состояния и dispatch
Частая проблема — при обновлении значения контекста перерисовываются все потребители. Решение — разделить на два контекста:
const UserStateContext = React.createContext(null);
const UserDispatchContext = React.createContext(null);
function UserProvider({ children }) {
const [user, dispatch] = useReducer(userReducer, initialUser);
return (
<UserStateContext.Provider value={user}>
<UserDispatchContext.Provider value={dispatch}>
{children}
</UserDispatchContext.Provider>
</UserStateContext.Provider>
);
}
// Компонент, который только читает данные, не перерисовывается при dispatch
function UserAvatar() {
const user = useContext(UserStateContext);
return <img src={user.avatar} />;
}
// Компонент, который только отправляет действия, не перерисовывается при изменении user
function LogoutButton() {
const dispatch = useContext(UserDispatchContext);
return <button onClick={() => dispatch({ type: 'LOGOUT' })}>Logout</button>;
}
Context vs менеджеры состояния
Context — это механизм внедрения зависимостей, а не полноценный менеджер состояния. Он не предоставляет:
- девтулы (Redux DevTools)
- промежуточное ПО (middleware)
- оптимизированный селекторный механизм
- автоматическое кэширование вычислений
Для сложного глобального состояния (как в проекте кандидата — Redux/RTK) лучше использовать специализированные библиотеки. Context идеален для:
- темизации (theme)
- локализации (i18n)
- аутентификации (текущий пользователь)
- конфигурации приложения
Производительность Context
При каждом изменении value в Provider все компоненты, использующие useContext этого контекста, перерисовываются. Это можно смягчить:
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
// Стабилизируем объект value
const value = useMemo(() => ({
theme,
setTheme,
}), [theme]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
Поведение при отсутствии Provider
Если компонент вызывает useContext без соответствующего Provider выше по дереву, возвращается defaultValue, переданный в createContext(defaultValue).
Вопрос 12. Что такое useReducer, какие аргументы он принимает и что возвращает?
Таймкод: 00:18:00
Ответ собеседника: Правильный. useReducer — хук для управления состоянием, принимает редьюсер и начальное состояние. Редьюсер — функция, которая принимает текущее состояние и экшен, возвращает новое состояние. useReducer возвращает массив из текущего состояния и функции dispatch для отправки экшенов.
Правильный ответ:
Ответ собеседника корректен. Дополним сигнатурой, нюансами и примерами.
Сигнатура
const [state, dispatch] = useReducer(reducer, initialArg, init?);
Аргументы
1. reducer — чистая функция (state, action) => newState. Получает текущее состояние и экшен, возвращает новое состояние. Не должна мутировать текущее состояние.
2. initialArg — начальное значение состояния (или аргумент для функции инициализации).
3. init (опционально) — функция инициализации (initialArg) => initialState. Если передана, начальное состояние вычисляется как init(initialArg). Это аналог lazy initialization в useState.
Возвращаемое значение
Массив [state, dispatch]:
state— текущее состояниеdispatch— функция для отправки экшенов
Базовый пример
const initialState = { count: 0 };
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return { count: 0 };
case 'set':
return { count: action.payload };
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
);
}
Lazy initialization с init-функцией
function init(initialCount) {
// Тяжёлая инициализация — выполнится только один раз
const saved = JSON.parse(localStorage.getItem('counter'));
return { count: saved?.count ?? initialCount };
}
function Counter({ initialCount = 0 }) {
const [state, dispatch] = useReducer(reducer, initialCount, init);
// ...
}
Это полезно, когда начальное состояние требует вычислений (чтение из localStorage, парсинг URL).
Когда useReducer предпочтительнее useState
Сложное состояние с множеством подзначений:
const initialState = {
loading: false,
data: null,
error: null,
filters: { category: '', sort: 'date' },
pagination: { page: 1, total: 0 },
};
function reducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return {
...state,
loading: false,
data: action.payload.items,
pagination: { ...state.pagination, total: action.payload.total },
};
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.payload };
case 'SET_FILTER':
return {
...state,
filters: { ...state.filters, ...action.payload },
pagination: { ...state.pagination, page: 1 },
};
case 'SET_PAGE':
return {
...state,
pagination: { ...state.pagination, page: action.payload },
};
default:
return state;
}
}
Новое состояние зависит от предыдущего:
// С useState: нужно помнить о функциональном обновлении
setItems(prev => [...prev, newItem]);
setTotal(prev => prev + 1);
// С useReducer: атомарное обновление нескольких полей
dispatch({ type: 'ADD_ITEM', payload: newItem });
// В редьюсере: return { ...state, items: [...state.items, action.payload], total: state.total + 1 }
Множество связанных экшенов:
Когда компонент имеет 5+ вариантов изменения состояния, useReducer структурирует логику лучше, чем разрозненные setState.
useReducer + Context для глобального состояния
Часто используются вместе как лёгкая альтернатива Redux:
const StoreContext = createContext(null);
const DispatchContext = createContext(null);
function AppProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, initialState);
return (
<StoreContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StoreContext.Provider>
);
}
Ключевые принципы
Редьюсер должен быть чистой функцией: одни и те же входные данные → одинаковый результат. Никаких побочных эффектов (запросы к API, мутации). Следуйте паттерну Redux: экшены — плоские объекты с полем type, редьюсеры — отдельные функции для каждой ветки состояния.
Вопрос 13. Что такое HOC (Higher-Order Component) и какие примеры HOC в React вы знаете?
Таймкод: 00:19:06
Ответ собеседника: Правильный. HOC — это компонент высшего порядка, который принимает компонент и возвращает другой компонент. Привёл пример React.memo как HOC. На вопрос о других HOC кроме memo затруднился ответить, но вспомнил forwardRef как HOC для прокидывания ref в кастомные компоненты.
Правильный ответ:
Ответ собеседника корректен, но неполон. Дополним.
Определение HOC
HOC — это функция, которая принимает компонент и возвращает новый компонент с дополнительным поведением или пропсами. Это паттерн из функционального программирования (функция высшего порядка — функция, принимающая функцию и возвращающая функцию).
const EnhancedComponent = hoc(BaseComponent);
React.memo как HOC
const MemoizedButton = React.memo(Button);
// React.memo принимает компонент, возвращает мемоизированную версию
React.forwardRef как HOC
const FancyButton = React.forwardRef((props, ref) => (
<button ref={ref} className="fancy" {...props} />
));
// forwardRef принимает компонент (render-функцию), возвращает компонент, способный принимать ref
connect из React-Redux
Классический HOC, который подключает компонент к Redux store:
const mapStateToProps = (state) => ({ user: state.user });
const mapDispatchToProps = { login, logout };
const ConnectedComponent = connect(mapStateToProps, mapDispatchToProps)(UserProfile);
// connect возвращает HOC, который принимает компонент и возвращает обёртку с данными из store
withRouter из React Router (v5)
const WithRouterComponent = withRouter(MyComponent);
// Добавляет пропсы history, location, match в компонент
Пользовательские HOC
withLoading — добавление состояния загрузки:
function withLoading(Component) {
return function WithLoadingComponent({ isLoading, ...props }) {
if (isLoading) {
return <Spinner />;
}
return <Component {...props} />;
};
}
const UserListWithLoading = withLoading(UserList);
// <UserListWithLoading isLoading={loading} users={users} />
withAuth — проверка авторизации:
function withAuth(Component) {
return function WithAuthComponent(props) {
const { user, loading } = useAuth();
if (loading) return <Spinner />;
if (!user) return <Redirect to="/login" />;
return <Component {...props} user={user} />;
};
}
const Dashboard = withAuth(DashboardContent);
withErrorBoundary — обработка ошибок:
function withErrorBoundary(Component, FallbackComponent) {
return function WithErrorBoundaryComponent(props) {
return (
<ErrorBoundary fallback={<FallbackComponent />}>
<Component {...props} />
</ErrorBoundary>
);
};
}
const SafeWidget = withErrorBoundary(Widget, ErrorWidget);
withData — загрузка данных:
function withData(Component, fetchData) {
return function WithDataComponent(props) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetchData(props)
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [props.id]);
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <Component {...props} data={data} />;
};
}
const UserProfileWithData = withData(UserProfile, (props) => api.getUser(props.id));
Проблемы HOC
Конфликт имён пропсов:
const Enhanced = withRouter(withAuth(MyComponent));
// withRouter добавляет location, withAuth добавляет user
// Если оба HOC добавляют проп с одинаковым именем — конфликт
Статические методы теряются:
class MyComponent extends React.Component {
static fetchData() { /* ... */ }
}
const Enhanced = hoc(MyComponent);
// Enhanced.fetchData — undefined
// Решение: hoist-non-react-statics библиотека
«Wrapper hell» в DevTools:
export default withRouter(withAuth(withLoading(withData(MyComponent)));
// В React DevTools: MyComponent → WithData → WithLoading → WithAuth → WithRouter
Современная альтернатива — хуки
Большинство HOC сегодня заменяются кастомными хуками:
// Вместо withAuth HOC:
function useAuth() {
const { user, loading } = useContext(AuthContext);
return { user, loading, isAuthenticated: !!user };
}
// Вместо withData HOC:
function useData(fetchFn, deps) {
const [state, setState] = useState({ data: null, loading: true, error: null });
useEffect(() => { /* ... */ }, deps);
return state;
}
Хуки решают проблему конфликта имён и wrapper hell, поэтому новые HOC создаются редко. Однако существующие HOC из библиотек (connect, withRouter в v5) всё ещё широко используются.
Вопрос 14. Что такое forwardRef и почему для кастомных компонентов нужно оборачивать в forwardRef, а для нативных элементов — нет?
Таймкод: 00:20:44
Ответ собеседника: Правильный. forwardRef нужен для того, чтобы передать ref непосредственно на внутренний DOM-элемент кастомного компонента, а не на сам компонент. Без forwardRef ref будет указывать на экземпляр компонента, а не на нужный внутренный элемент. Нативные элементы (button и т.д.) поддерживают ref из коробки.
Правильный ответ:
Ответ корректен, дополним техническими деталями и примерами.
Проблема: ref не проходит через пропсы
В React ref — это специальный проп, а не обычный. Он не передаётся через props компонента, в отличие от key.
function Parent() {
const ref = useRef(null);
return <FancyInput ref={ref} />;
}
function FancyInput(props) {
// props.ref === undefined — ref не приходит в пропсы!
return <input />;
}
Решение: React.forwardRef
React.forwardRef создаёт компонент, который получает ref как второй аргумент (после пропсов):
const FancyInput = React.forwardRef((props, ref) => {
return (
<div className="fancy-input">
<label>{props.label}</label>
<input ref={ref} {...props} />
</div>
);
});
// Использование:
function Parent() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus(); // ← ссылка на DOM-элемент <input>
}, []);
return <FancyInput ref={inputRef} label="Username" />;
}
Почему нативные элементы работают без forwardRef
Нативные элементы (input, div, button) — это не компоненты React, а инструкции для создания DOM-узлов. React обрабатывает их особым образом: когда видит ref на нативном элементе, он автоматически привязывает ref.current к DOM-узлу.
// React внутренне делает что-то вроде:
const inputRef = useRef(null);
// При монтировании: inputRef.current = document.querySelector('input')
Для кастомных компонентов React не знает, какой именно DOM-элемент нужно привязать к ref — это решает разработчик через forwardRef.
Практические примеры
Кастомный инпут с автокомплитом:
const AutocompleteInput = React.forwardRef(({ suggestions, onSelect, ...props }, ref) => {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="autocomplete">
<input
ref={ref}
onFocus={() => setIsOpen(true)}
{...props}
/>
{isOpen && (
<ul className="suggestions">
{suggestions.map(item => (
<li key={item.id} onClick={() => onSelect(item)}>
{item.label}
</li>
))}
</ul>
)}
</div>
);
});
Обёртка над сторонней библиотекой:
const ReactSelectWrapper = React.forwardRef(({ options, value, onChange, ...props }, ref) => {
// useImperativeHandle позволяет кастомизировать то, что видно через ref
useImperativeHandle(ref, () => ({
focus: () => { /* логика фокуса */ },
clear: () => { onChange(null); },
getValue: () => value,
}));
return (
<ReactSelect
options={options}
value={value}
onChange={onChange}
{...props}
/>
);
});
forwardRef + useImperativeHandle
Часто используются вместе, чтобы контролировать API компонента для родителя:
const Modal = React.forwardRef(({ children, title }, ref) => {
const [isOpen, setIsOpen] = useState(false);
useImperativeHandle(ref, () => ({
open: () => setIsOpen(true),
close: () => setIsOpen(false),
toggle: () => setIsOpen(prev => !prev),
}));
if (!isOpen) return null;
return (
<div className="modal-overlay">
<div className="modal">
<h2>{title}</h2>
{children}
</div>
</div>
);
});
// Использование:
function App() {
const modalRef = useRef(null);
return (
<div>
<button onClick={() => modalRef.current.open()}>Open Modal</button>
<Modal ref={modalRef} title="Confirm">
<p>Are you sure?</p>
<button onClick={() => modalRef.current.close()}>Close</button>
</Modal>
</div>
);
}
forwardRef с TypeScript
interface FancyInputProps {
label: string;
error?: string;
}
const FancyInput = React.forwardRef<HTMLInputElement, FancyInputProps>(
({ label, error, ...props }, ref) => {
return (
<div>
<label>{label}</label>
<input ref={ref} {...props} />
{error && <span className="error">{error}</span>}
</div>
);
}
);
Вложенный forwardRef (ref forwarding)
Можно передать ref ещё глубже — через несколько уровней:
const Outer = React.forwardRef((props, ref) => (
<div className="outer">
<Inner ref={ref} {...props} />
</div>
));
const Inner = React.forwardRef((props, ref) => (
<input ref={ref} {...props} />
));
Ограничения
refне работает с функциональными компонентами безforwardRef— это ограничение Reactrefнельзя использовать как обычный проп внутри компонента- Для классовых компонентов ref автоматически указывает на экземпляр класса,
forwardRefне нужен
Вопрос 15. Что такое Synthetic Events в React и для чего они нужны?
Таймкод: 00:21:30
Ответ собеседника: Правильный. Synthetic Events — это обёртки над нативными событиями браузера, которые React использует для кросс-браузерной совместимости. Раньше браузеры имели разные реализации событий, поэтому React создал унифицированный слой. Объект Synthetic Event имеет набор стандартных методов.
Правильный ответ:
Ответ корректен, дополним важными техническими деталями.
Что такое SyntheticEvent
SyntheticEvent — это кросс-браузерная обёртка над нативным событием браузера. React не привязывает обработчики напрямую к DOM-узлам — вместо этого используется делегирование событий.
Event Delegation (делегирование событий)
React привязывает один обработчик на корневой элемент (в React 17 — на document, в React 18 — на root контейнер), а не на каждый DOM-узел отдельно.
// React НЕ делает так:
button.addEventListener('click', handler);
input.addEventListener('change', handler);
div.addEventListener('mouseover', handler);
// React делает так:
rootElement.addEventListener('click', (nativeEvent) => {
// Находим целевой компонент через внутреннее дерево Fiber
// Создаём SyntheticEvent
// Вызываем обработчик компонента
});
Преимущества делегирования:
- Меньше обработчиков в памяти
- Не нужно добавлять/удалять обработчики при монтировании/размонтировании компонентов
- Единый механизм для всех типов событий
Пул событий и повторное использование
Для производительности React переиспользует объекты SyntheticEvent. После выполнения обработчика все свойства объекта обнуляются.
function handleClick(event) {
console.log(event.type); // 'click' — работает
setTimeout(() => {
console.log(event.type); // null — объект уже переиспользован
}, 100);
}
Если нужно сохранить данные события для асинхронного доступа — сохраните конкретные значения или вызовите event.persist():
function handleClick(event) {
event.persist(); // Убирает объект из пула
setTimeout(() => {
console.log(event.type); // 'click' — работает
}, 100);
}
В React 17+ persist() всё ещё доступен, но используется редко.
Доступ к нативному событию
function handleClick(syntheticEvent) {
const nativeEvent = syntheticEvent.nativeEvent;
console.log(nativeEvent); // оригинальное событие браузера
}
Стандартные методы SyntheticEvent
event.preventDefault() // Отмена действия по умолчанию
event.stopPropagation() // Остановка всплытия
event.isPropagationStopped() // Проверка, остановлено ли всплытие
event.isDefaultPrevented() // Проверка, отменено ли действие
event.type // Тип события
event.target // Элемент, вызвавший событие
event.currentTarget // Элемент, на котором висит обработник
event.nativeEvent // Нативное событие браузера
Кастомные типы событий
React предоставляет специализированные классы для разных типов событий, все наследуются от SyntheticEvent:
SyntheticEvent
├── SyntheticMouseEvent (click, mousedown, mousemove...)
├── SyntheticKeyboardEvent (keydown, keyup, keypress...)
├── SyntheticFocusEvent (focus, blur...)
├── SyntheticInputEvent (change, input...)
├── SyntheticTouchEvent (touchstart, touchmove...)
├── SyntheticAnimationEvent (animationend...)
├── SyntheticTransitionEvent (transitionend...)
├── SyntheticWheelEvent (wheel...)
└── SyntheticClipboardEvent (copy, paste...)
Каждый класс добавляет свои свойства:
function handleKeyPress(event) {
// SyntheticKeyboardEvent
console.log(event.key); // 'Enter'
console.log(event.code); // 'Enter'
console.log(event.ctrlKey); // boolean
console.log(event.shiftKey); // boolean
}
function handleMouseMove(event) {
// SyntheticMouseEvent
console.log(event.clientX); // координата мыши
console.log(event.clientY);
console.log(event.pageX); // координата относительно документа
}
События, которые НЕ всплывают в DOM, но всплывают в React
React эмулирует всплытие для событий, которые в нативном DOM не всплывают:
focus/blur→onFocus/onBlur(нативно не всплывают, React делает черезfocusin/focusout)scroll
Фазы событий в React
// Фаза всплытия (bubbling) — по умолчанию
<button onClick={handler}>Click</button>
// Фаза перехвата (capture)
<button onClickCapture={handler}>Click</button>
Несовместимости с нативными обработчиками
Если добавить нативный обработчик через addEventListener и React-обработик одновременно, они работают независимо:
useEffect(() => {
const button = ref.current;
// Нативный обработчик — срабатывает ДО React-обработчика
button.addEventListener('click', (e) => {
e.stopImmediatePropagation(); // Остановит и нативные, и React-обработчики
});
}, []);
React 17 vs React 18 — изменения в делегировании
В React 17 обработчики вешались на document, в React 18 — на root контейнер. Это важно при использовании нескольких версий React на одной странице или при интеграции с другими библиотеками, которые слушают события на document.
Вопрос 16. Какие методы массива вы знаете? В чём разница между map и reduce? Как проверить, что все элементы массива удовлетворяют условию?
Таймкод: 00:22:49
Ответ собеседника: Неполный. Назвал map и reduce как методы массива. На вопрос о разнице ответил неуверенно: сказал, что map применяет функцию к каждому элементу, а reduce — к аккумулятору и каждому элементу. Не смог чётко сформулировать, что map возвращает новый массив той же длины, а reduce сворачивает массив в одно значение. Упомянул filter, find. На вопрос о проверке всех элементов массива на соответствие условию (every) затруднился с ответом — не вспомнил метод Array.every().
Правильный ответ:
Классификация методов массива
Методы массива делятся на две категории: мутирующие (изменяют исходный массив) и немутирующие (возвращают новый результат, не трогая оригинал).
Немутирующие методы
map — преобразование каждого элемента, возвращает массив той же длины:
const numbers = [1, 2, 3, 4];
const doubled = numbers.map(n => n * 2);
// [2, 4, 6, 8] — длина та же, что и у исходного
filter — отбор элементов по условию, возвращает массив меньшей или равной длины:
const numbers = [1, 2, 3, 4, 5];
const evens = numbers.filter(n => n % 2 === 0);
// [2, 4]
reduce — свёртка массива в одно значение (число, строка, объект, массив — что угодно):
const numbers = [1, 2, 3, 4];
const sum = numbers.reduce((acc, n) => acc + n, 0);
// 10 — одно значение, не массив
find — возвращает первый элемент, удовлетворяющий условию:
const users = [
{ id: 1, name: 'Alice', active: true },
{ id: 2, name: 'Bob', active: false },
{ id: 3, name: 'Charlie', active: true },
];
const firstActive = users.find(u => u.active);
// { id: 1, name: 'Alice', active: true }
findIndex — как find, но возвращает индекс (или -1):
const idx = users.findIndex(u => u.id === 2); // 1
some — проверяет, удовлетворяет ли хотя бы один элемент условию. Возвращает boolean. Прекращает итерацию при первом совпадении (short-circuit):
const numbers = [1, 3, 5, 6, 7];
const hasEven = numbers.some(n => n % 2 === 0);
// true — нашёл 6, дальше не проверял
every — проверяет, удовлетворяют ли все элементы условию. Возвращает boolean. Прекращает итерацию при первом несоответствии (short-circuit):
const ages = [25, 30, 18, 42];
const allAdult = ages.every(age => age >= 18);
// true
const mixed = [25, 30, 16, 42];
const allAdult2 = mixed.every(age => age >= 18);
// false — нашёл 16, дальше не проверял
Это ответ на вопрос кандидата — Array.every() именно для этого и предназначен.
includes — проверяет наличие значения в массиве:
[1, 2, 3].includes(2); // true
slice — возвращает копию части массива:
[1, 2, 3, 4].slice(1, 3); // [2, 3]
concat — объединение массивов:
[1, 2].concat([3, 4]); // [1, 2, 3, 4]
flat / flatMap — разворачивание вложенных массивов:
[[1, 2], [3, [4]]].flat(2); // [1, 2, 3, 4]
[1, 2, 3].flatMap(n => [n, n * 2]); // [1, 2, 2, 4, 3, 6]
at — доступ по индексу, включая отрицательные:
[1, 2, 3].at(-1); // 3
indexOf / lastIndexOf — поиск индекса элемента:
[1, 2, 3, 2].indexOf(2); // 1
[1, 2, 3, 2].lastIndexOf(2); // 3
keys / values / entries — итераторы:
const arr = ['a', 'b', 'c'];
[...arr.keys()]; // [0, 1, 2]
[...arr.values()]; // ['a', 'b', 'c']
[...arr.entries()]; // [[0, 'a'], [1, 'b'], [2, 'c']]
Мутирующие методы
push / pop — добавление/удаление в конец:
arr.push(4); // добавляет, возвращает новую длину
arr.pop(); // удаляет последний, возвращает его
shift / unshift — удаление/добавление в начало:
arr.shift(); // удаляет первый
arr.unshift(0); // добавляет в начало
splice — удаление/вставка элементов на любой позиции:
const arr = [1, 2, 3, 4, 5];
arr.splice(2, 1); // удалить 1 элемент с индекса 2 → [1, 2, 4, 5]
arr.splice(1, 0, 'a'); // вставить 'a' на позицию 1 → [1, 'a', 2, 4, 5]
sort — сортировка на месте:
[3, 1, 2].sort((a, b) => a - b); // [1, 2, 3]
reverse — разворот на месте:
[1, 2, 3].reverse(); // [3, 2, 1]
fill — заполнение значением:
new Array(3).fill(0); // [0, 0, 0]
Ключевое различие map vs reduce
| Характеристика | map | reduce |
|---|---|---|
| Вход | Массив из N элементов | Массив из N элементов |
| Выход | Массив из N элементов | Одно значение |
| Назначение | Преобразование каждого элемента | Свёртка (агрегация) |
| Callback получает | (element, index, array) | (accumulator, element, index, array) |
| Начальное значение | Нет (берётся первый элемент неявно) | Второй аргумент reduce |
// map: каждый элемент → новый элемент
const prices = [100, 200, 300];
const withTax = prices.map(p => p * 1.2);
// [120, 240, 360]
// reduce: все элементы → одно значение
const total = prices.reduce((sum, p) => sum + p, 0);
// 600
// reduce может возвращать что угодно — объект, Map, другой массив
const grouped = users.reduce((acc, user) => {
const key = user.role;
if (!acc[key]) acc[key] = [];
acc[key].push(user);
return acc;
}, {});
// { admin: [...], user: [...] }
Практический пример every из проекта кандидата
// Проверка, что все камеры онлайн перед началом записи
const allCamerasOnline = cameras.every(camera => camera.status === 'online');
if (!allCamerasOnline) {
const offline = cameras.filter(c => c.status !== 'online');
showWarning(`Камеры оффлайн: ${offline.map(c => c.name).join(', ')}`);
}
Вопрос 17. Как проверить, что все элементы массива объектов удовлетворяют определённому условию (например, все люди совершеннолетние)?
Таймкод: 00:25:46
Ответ собеседника: Неполный. Затруднился с ответом. На уточняющий вопрос о методе, который проверит все элементы массива на соответствие условию, не смог дать точного ответа. Подразумевался метод Array.every().
Правильный ответ:
Array.every() — именно тот метод, который проверяет, что все элементы массива удовлетворяют заданному условию. Возвращает true, если для каждого элемента callback вернул истинное значение, и false — если хотя бы один элемент не прошёл проверку.
Базовый пример
const people = [
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 30 },
{ name: 'Charlie', age: 22 },
];
const allAdults = people.every(person => person.age >= 18);
// true — все старше 18
const peopleWithMinor = [
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 16 },
{ name: 'Charlie', age: 22 },
];
const allAdults2 = peopleWithMinor.every(person => person.age >= 18);
// false — Bob младше 18
Как работает под капотом
every использует short-circuit evaluation — прекращает итерацию при первом элементе, не удовлетворяющем условию:
[2, 4, 6, 7, 8].every(n => {
console.log('checking:', n);
return n % 2 === 0;
});
// Лог:
// checking: 2
// checking: 4
// checking: 6
// checking: 7 ← первый нечётный, дальше не проверяет
// Результат: false
Это делает every эффективным для больших массивов — не нужно проверять все элементы, если уже найден первый «плохой».
Практические примеры из реальных проектов
Валидация формы перед отправкой:
const fields = [
{ name: 'email', value: 'user@example.com', required: true },
{ name: 'phone', value: '', required: true },
{ name: 'name', value: 'John', required: true },
];
const allRequiredFilled = fields
.filter(f => f.required)
.every(f => f.value.trim() !== '');
// false — phone пустой
Проверка статусов сервисов:
const services = [
{ name: 'API', status: 'healthy' },
{ name: 'Database', status: 'healthy' },
{ name: 'Cache', status: 'degraded' },
];
const allHealthy = services.every(s => s.status === 'healthy');
// false — Cache в статусе degraded
Проверка прав доступа:
const requiredPermissions = ['read', 'write', 'admin'];
const userPermissions = ['read', 'write'];
const hasAllPermissions = requiredPermissions.every(
perm => userPermissions.includes(perm)
);
// false — нет права admin
Проверка данных перед экспортом:
const rows = [
{ id: 1, camera: 'CAM-01', timestamp: '2024-01-01T10:00:00', plateNumber: 'А123БВ77' },
{ id: 2, camera: 'CAM-02', timestamp: null, plateNumber: 'Х456УК99' },
{ id: 3, camera: 'CAM-03', timestamp: '2024-01-01T12:00:00', plateNumber: '' },
];
const allValid = rows.every(row =>
row.camera &&
row.timestamp &&
row.plateNumber &&
row.plateNumber.length > 0
);
// false — строка 2 без timestamp, строка 3 без plateNumber
every vs some — когда какой использовать
// every — ВСЕ элементы должны удовлетворять условию
passengers.every(p => p.hasTicket); // все имеют билеты?
cameras.every(c => c.status === 'online'); // все камеры онлайн?
// some — ХОТЯ БЫ ОДИН элемент удовлетворяет условию
passengers.some(p => p.isVIP); // есть VIP-пассажиры?
errors.some(e => e.severity === 'fatal'); // есть фатальные ошибки?
Краевые случаи
every на пустом массиве всегда возвращает true (vacuous truth):
[].every(x => x > 100); // true — нет ни одного элемента, нарушающего условие
Это может быть неинтуитивно, поэтому иногда стоит добавить проверку:
const allValid = items.length > 0 && items.every(item => item.isValid);
Производительность
every — O(n) в худшем случае (все элементы проходят проверку), O(1) в лучшем (первый элемент не прошёл). Для очень больших массивов это эффективнее, чем filter + проверка длины, потому что filter всегда проходит весь массив:
// Неэффективно: filter пройдёт весь массив, даже если первый элемент невалидный
const allValid = items.filter(item => item.isValid).length === items.length;
// Эффективно: every остановится на первом невалидном
const allValid = items.every(item => item.isValid);
Вопрос 18. Какие утилитарные типы в TypeScript вы знаете? Что такое Pick, Omit, Record, ReturnType?
Таймкод: 00:28:42
Ответ собеседника: Правильный. Рассказал про Record — создаёт тип объекта, где можно описать, что будет ключом, а что значением. Рассказал про Pick — берёт из типа типа определённое свойство по ключу и возвращает новый тип. На вопрос про Omit не вспомнил само название, но после подсказки понял, что это противоположность Pick — исключает свойства из типа. Рассказал про ReturnType — позволяет извлечь тип возвращаемого значения функции.
Правильный ответ:
Ответ корректен, дополним полным обзором утилитарных типов.
Pick<T, K> — выбирает указанные свойства из типа:
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
}
// Только публичные поля — без password
type PublicUser = Pick<User, 'id' | 'name' | 'email'>;
// { id: number; name: string; email: string }
Практическое применение: публичный профиль пользователя без чувствительных данных.
Omit<T, K> — исключает указанные свойства из типа:
// То же самое, но через исключение
type PublicUser = Omit<User, 'password' | 'createdAt'>;
// { id: number; name: string; email: string }
// Тип для формы создания — без id и дат
type CreateUserDto = Omit<User, 'id' | 'createdAt'>;
Omit часто удобнее Pick, когда исключаемых полей меньше, чем нужных.
Record<K, V> — создаёт тип объекта с ключами K и значениями V:
type Role = 'admin' | 'user' | 'guest';
type RolePermissions = Record<Role, string[]>;
// {
// admin: string[];
// user: string[];
// guest: string[];
// }
const permissions: RolePermissions = {
admin: ['read', 'write', 'delete'],
user: ['read', 'write'],
guest: ['read'],
};
// Пример: статусы камер
type CameraStatus = Record<string, 'online' | 'offline' | 'error'>;
const cameras: CameraStatus = {
'CAM-01': 'online',
'CAM-02': 'offline',
'CAM-03': 'error',
};
ReturnType<T> — извлекает тип возвращаемого значения функции:
function getUser() {
return { id: 1, name: 'Alice', email: 'alice@example.com' };
}
type User = ReturnType<typeof getUser>;
// { id: number; name: string; email: string }
// Пример: типы из асинхронных функций
async function fetchUsers() {
const res = await fetch('/api/users');
return res.json();
}
type Users = Awaited<ReturnType<typeof fetchUsers>>;
Другие важные утилитарные типы
Partial<T> — делает все свойства необязательными:
interface User {
id: number;
name: string;
email: string;
}
type UserUpdate = Partial<User>;
// { id?: number; name?: string; email?: string }
function updateUser(id: number, changes: UserUpdate) { }
updateUser(1, { name: 'Bob' }); // — можно передать только часть полей
Required<T> — делает все свойства обязательными (противоположность Partial):
interface Config {
host?: string;
port?: number;
}
type RequiredConfig = Required<Config>;
// { host: string; port: number }
Readonly<T> — делает все свойства только для чтения:
type ImmutableUser = Readonly<User>;
const user: ImmutableUser = { id: 1, name: 'Alice', email: 'a@b.com' };
user.name = 'Bob'; // Ошибка: Cannot assign to 'name' because it is a read-only property
Parameters<T> — извлекает типы параметров функции как кортеж:
function createUser(name: string, age: number, role: string) {
// ...
}
type CreateUserParams = Parameters<typeof createUser>;
// [string, number, string]
const params: CreateUserParams = ['Alice', 25, 'admin'];
Awaited<T> — извлекает тип из Promise:
type Result = Awaited<Promise<string>>; // string
type Nested = Awaited<Promise<Promise<number>>>; // number
Exclude<T, U> — исключает из объединения типов:
type Status = 'pending' | 'active' | 'done' | 'error';
type FinalStatus = Exclude<Status, 'pending' | 'active'>;
// 'done' | 'error'
Extract<T, U> — извлекает только совместимые типы:
type AllTypes = string | number | boolean | null;
type OnlyPrimitives = Extract<AllTypes, string | number | boolean>;
// string | number | boolean
NonNullable<T> — исключает null и undefined:
type MaybeString = string | null | undefined;
type DefinitelyString = NonNullable<MaybeString>;
// string
Комбинирование утилитарных типов
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
updatedAt: Date;
}
// Публичный профиль: только id и name, оба readonly
type PublicProfile = Readonly<Pick<User, 'id' | 'name'>>;
// DTO для обновления: всё кроме id и дат, все поля необязательные
type UpdateUserDto = Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>;
// Маппинг статусов к компонентам
type StatusComponentMap = Record<'loading' | 'error' | 'success', React.ComponentType>;
Практический пример из проекта
// Типы экшенов Redux
const FETCH_CAMERAS = 'FETCH_CAMERAS' as const;
const FETCH_CAMERAS_SUCCESS = 'FETCH_CAMERAS_SUCCESS' as const;
type FetchCamerasAction = { type: typeof FETCH_CAMERAS };
type FetchCamerasSuccessAction = {
type: typeof FETCH_CAMERAS_SUCCESS;
payload: Camera[];
};
type CameraAction = FetchCamerasAction | FetchCamerasSuccessAction;
// Тип состояния
interface CameraState {
items: Camera[];
loading: boolean;
error: string | null;
}
// Типы для селекторов
type State = { cameras: CameraState };
// Pick для компонента, которому нужны только items
type CameraListProps = Pick<CameraState, 'items'>;
// Omit для формы — без полей, генерируемых сервером
type CreateCameraDto = Omit<Camera, 'id' | 'createdAt'>;
// Record для маппинга ID к объекту
type CameraById = Record<Camera['id'], Camera>;
Вопрос 19. Что такое принцип DRY в программировании? Приведите пример нарушения.
Таймкод: 00:29:36
Ответ собеседника: Правильный. DRY (Don't Repeat Yourself) — принцип, призывающий избегать дублирования кода. Пример нарушения: когда большой участок кода дублируется в двух местах программы вместо того, чтобы вынести его в функцию, класс или отдельный модуль. По мере разрастания это приводит к антипаттерну God Object, когда один компонент или функция делает слишком много вещей.
Правильный ответ:
Ответ корректен, но кандидат перепутал DRY с God Object — это разные понятия. Уточним.
Суть DRY
Каждая часть знания (логики, правила, алгоритм) должна иметь единственное, однозначное представление в системе. Если логика дублируется и её нужно менять в пяти местах одновременно — это нарушение DRY.
Пример нарушения DRY в фронтенде
// Компонент списка камер
function CameraList() {
const [cameras, setCameras] = useState([]);
useEffect(() => {
fetch('/api/cameras')
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => setCameras(data))
.catch(err => {
console.error('Failed to load cameras:', err);
toast.error('Не удалось загрузить камеры');
});
}, []);
return <List items={cameras} />;
}
// Компонент списка событий — та же логика запроса дублируется
function EventList() {
const [events, setEvents] = useState([]);
useEffect(() => {
fetch('/api/events')
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => setEvents(data))
.catch(err => {
console.error('Failed to load events:', err);
toast.error('Не удалось загрузить события');
});
}, []);
return <List items={events} />;
}
Проблема: логика HTTP-запров, обработки ошибок и уведомлений дублируется. Если нужно добавить авторизацию в заголовки — менять придётся в каждом компоненте.
Рефакторинг с соблюдением DRY
// Вынесённый хук
function useApi(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
setData(data);
setError(null);
})
.catch(err => {
setError(err);
toast.error(`Ошибка загрузки: ${err.message}`);
})
.finally(() => setLoading(false));
}, [url]);
return { data, loading, error };
}
// Компоненты стали лаконичными
function CameraList() {
const { data: cameras, loading, error } = useApi('/api/cameras');
if (loading) return <Spinner />;
if (error) return <Error />;
return <List items={cameras} />;
}
function EventList() {
const { data: events, loading, error } = useApi('/api/events');
if (loading) return <Spinner />;
if (error) return <Error />;
return <List items={events} />;
}
DRY ≠ борьба с любым повторением
DRY касается знания и логики, а не случайного совпадения кода. Два компонента могут выглядеть похоже, но представлять разные бизнес-правила. Если объединить их преждевременно, при изменении одного правила пострадает другое.
// Похожий код, но разная логика валидации
function validateEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function validateUsername(username) {
return /^[a-zA-Z0-9_]{3,20}$/.test(username);
}
// Не стоит объединять в validateField(value, type) —
// правила валидации меняются независимо
DRY vs God Object — в чём разница
DRY-нарушение — одна и та же логика написана в нескольких местах. Проблема: при изменении нужно обновить все копии.
God Object — один класс/компонент/модуль берёт на себя слишком много ответственностей. Проблема: модуль становится неподдерживаемым, сложно тестировать, изменения в одной функции влияют на другие.
Это разные антипаттерны, хотя оба связаны с плохой организацией кода.
Уровни применения DRY
- Код: вынос повторяющейся логики в функции, хуки, утилиты
- Типы: переиспользуемые интерфейсы и утилитарные типы в TypeScript
- Конфигурация: общие константы, энвы, конфиг-файлы
- Данные: нормализация, единые источники истины (single source of truth)
- Тесты: общие фикстуры, хелперы для тестов
Вопрос 20. Что такое принцип KISS в программировании? Приведите пример нарушения.
Таймкод: 00:30:21
Ответ собеседника: Правильный. KISS (Keep It Simple, Stupid) — принцип, призывающий упрощать максимально то, что можно упростить. Примеры нарушения: лишние проверки, ненужные сложные классы. Например, если для простой операции создаётся отдельный класс (класс-умножитель), это нарушает KISS. Если есть лишний код, от которого можно избавиться — от него нужно избавляться.
Правильный ответ:
Ответ собеседника корректен, дополним конкретными примерами.
Суть KISS
Проще решение — лучшее решение. Не стоит усложнять код без реальной необходимости. Каждая абстракция, паттерн, уровень косвенности должны быть оправданы.
Пример нарушения KISS в React
// Нарушение: овер-инжиниринг для простой задачи
const withConditionalRendering = (Component) => {
return function ConditionalComponent({ condition, fallback, ...props }) {
if (condition) {
return <Component {...props} />;
}
return fallback || null;
};
};
const withLoadingState = (Component) => {
return function LoadingComponent({ isLoading, ...props }) {
if (isLoading) return <Spinner />;
return <Component {...props} />;
};
};
const withErrorBoundary = (Component) => {
return function ErrorBoundaryComponent(props) {
return (
<ErrorBoundary>
<Component {...props} />
</ErrorBoundary>
);
};
};
// Три HOC для простого списка
const EnhancedCameraList = withConditionalRendering(
withLoadingState(
withErrorBoundary(CameraList)
)
);
Задача — показать список с обработкой загрузки и ошибок. Решение — три HOC, которые можно заменить тремя строками:
// KISS-решение
function CameraList() {
const { data, loading, error } = useApi('/api/cameras');
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return (
<ul>
{data.map(camera => (
<li key={camera.id}>{camera.name}</li>
))}
</ul>
);
}
Пример нарушения KISS в валидации
// Нарушение: абстракция ради абстракции
class ValidationRule {
constructor(field, validator, message) {
this.field = field;
this.validator = validator;
this.message = message;
}
validate(value) {
return this.validator(value) ? null : this.message;
}
}
class ValidationEngine {
constructor(rules) {
this.rules = rules;
}
validate(data) {
const errors = {};
for (const rule of this.rules) {
const error = rule.validate(data[rule.field]);
if (error) errors[rule.field] = error;
}
return errors;
}
}
const engine = new ValidationEngine([
new ValidationRule('email', (v) => v.includes@), 'Email required'),
new ValidationRule('age', (v) => v >= 18), 'Must be 18+'),
]);
Для двух полей это избыточно. KISS-решение:
function validateForm(data) {
const errors = {};
if (!data.email?.includes('@')) errors.email = 'Email required';
if (data.age < 18) errors.age = 'Must be 18+';
return errors;
}
Когда KISS не применяется
KISS не означает «пиши примитивный код». Если задача объективно сложная — сложное решение корректно. Например, для формы с 20 полями, зависимой валидацией и динамическими полями — ValidationEngine оправдан. KISS применяется, когда простое решение уже покрывает требования без потенциального усложнения в будущем.
KISS vs YAGNI
KISS — делай проще (упрощай то, что есть). YAGNI (You Aren't Gonna Need It) — не добавляй функциональность, пока она не нужна (не строй велосипед на будущее).
Оба принципа борются с овер-инжинирингом, но с разных сторон.
Вопрос 21. Расскажите о принципах SOLID. Что означает каждая буква?
Таймкод: 00:31:12
Ответ собеседника: Правильный. S — Single Responsibility Principle: каждый компонент/функция должен иметь только одну причину для изменения, выполнять одну функцию. O — Open/Closed Principle: принцип открытости/закрытости — базовые методы библиотеки не должны переписываться, новые классы должны дополнять существующие. L — Liskov Substitution Principle: подклассы должны сохранять поведение базового класса, чтобы их можно было заменять между собой без нарушения правильности программы. I — Interface Segregation Principle: интерфейсы нужно разделять, чтобы класс не имел лишнего функционала, который ему не нужен. Привёл пример с интерфейсами Work и Sleep для классов Human и Robot. D — Dependency Inversion Principle: зависимости должны строиться на абстракциях (интерфейсах), а не на конкретных реализациях.
Правильный ответ:
Ответ собеседника корректен и полон. Дополним примерами на TypeScript/JavaScript для каждого принципа.
S — Single Responsibility Principle (Принцип единственной ответственности)
Класс/модуль/функция должна иметь только одну причину для изменения.
// Нарушение: класс делает три вещи
class User {
saveToDatabase() { /* ... */ }
sendEmail() { /* ... */ }
generateReport() { /* ... */ }
}
// Решение: разделение ответственностей
class UserRepository {
save(user: User) { /* ... */ }
}
class EmailService {
send(to: string, body: string) { /* ... */ }
}
class ReportGenerator {
generate(user: User) { /* ... */ }
}
O — Open/Closed Principle (Принцип открытости/закрытости)
Модули должны быть открыты для расширения, но закрыты для модификации.
// Нарушение: при добавлении нового типа уведомления нужно менять функцию
function sendNotification(type: string, message: string) {
if (type === 'email') { /* ... */ }
else if (type === 'sms') { /* ... */ }
else if (type === 'push') { /* ... */ } // добавили — изменили функцию
}
// Решение: расширение через интерфейс
interface NotificationSender {
send(message: string): void;
}
class EmailSender implements NotificationSender {
send(message: string) { /* ... */ }
}
class SmsSender implements NotificationSender {
send(message: string) { /* ... */ }
}
class PushSender implements NotificationSender {
send(message: string) { /* ... */ }
}
// Не меняем — просто добавляем новый класс
function sendNotification(sender: NotificationSender, message: string) {
sender.send(message);
}
L — Liskov Substitution Principle (Принцип подстановки Лисков)
Объекты подтипов должны быть заменимы объектами базового типа без нарушения корректности программы.
// Нарушение: квадрат нарушает контракт прямоугольника
class Rectangle {
constructor(protected width: number, protected height: number) {}
setWidth(w: number) { this.width = w; }
setHeight(h: number) { this.height = h; }
getArea() { return this.width * this.height; }
}
class Square extends Rectangle {
setWidth(w: number) {
this.width = w;
this.height = w; // нарушение: меняем и высоту
}
setHeight(h: number) {
this.width = h;
this.height = h; // нарушение: меняем и ширину
}
}
// Клиентский код ожидает, что width и height независимы
function testRectangle(rect: Rectangle) {
rect.setWidth(5);
rect.setHeight(4);
console.log(rect.getArea()); // Ожидаем 20
}
testRectangle(new Rectangle(1, 1)); // 20 — OK
testRectangle(new Square(1, 1)); // 16 — нарушение LSP!
I — Interface Segregation Principle (Принцип разделения интерфейса)
Клиенты не должны зависеть от методов, которые они не используют.
// Нарушение: один массивный интерфейс
interface Worker {
work(): void;
eat(): void;
sleep(): void;
}
class Human implements Worker {
work() { /* ... */ }
eat() { /* ... */ }
sleep() { /* ... */ }
}
class Robot implements Worker {
work() { /* ... */ }
eat() { throw new Error("Robots don't eat"); } // вынужденная реализация
sleep() { throw new Error("Robots don't sleep"); }
}
// Решение: разделённые интерфейсы
interface Workable {
work(): void;
}
interface Eatable {
eat(): void;
}
interface Sleepable {
sleep(): void;
}
class Human implements Workable, Eatable, Sleepable {
work() { /* ... */ }
eat() { /* ... */ }
sleep() { /* ... */ }
}
class Robot implements Workable {
work() { /* ... */ }
// Не реализует eat и sleep — и это нормально
}
D — Dependency Inversion Principle (Принцип инверсии зависимостей)
Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций.
// Нарушение: сервис напрямую зависит от конкретной БД
class UserService {
private db = new PostgreSQLDatabase(); // жёсткая привязка
getUser(id: string) {
return this.db.query('SELECT * FROM users WHERE id = $1', [id]);
}
}
// Решение: зависимость от абстракции
interface Database {
query(sql: string, params: any[]): Promise<any>;
}
class PostgreSQLDatabase implements Database {
query(sql: string, params: any[]) { /* ... */ }
}
class MongoDatabase implements Database {
query(sql: string, params: any[]) { /* ... */ }
}
class UserService {
constructor(private db: Database) {} // инъекция зависимости
getUser(id: string) {
return this.db.query('SELECT * FROM users WHERE id = $1', [id]);
}
}
// Легко подменить реализацию
const service = new UserService(new PostgreSQLDatabase());
const testService = new UserService(new MongoDatabase());
SOLID в React-контексте
- SRP: компонент отвечает за один аспект (отображение, загрузка данных, управление состоянием — разделены)
- OCP: новые типы уведомлений добавляются через новые классы, а не через
if/elseв существующем коде - LSP:
MemoizedComponentведёт себя идентично оригинальному компоненту для потребителя - ISP: пропсы компонента содержат только то, что нужно, без «мусорных» полей
- DIP: компоненты зависят от абстракций (контексты, хуки), а не от конкретных реализаций
