Ниже - пример того, как я обычно представляю (и детально разбираю) один из моих любимых вопросов по фронтенд-разработке на собеседовании. Он основан на моем опыте интервьюирования в крупных IT-компаниях. Этот вопрос посвящён созданию небольшого React-компонента, который асинхронно получает данные на основе пропса username
. Он кажется простым, но на самом деле показывает много нюансов понимания кандидатом хуков React, сайд-эффектов, состояния гонки (race conditions) и компромиссов в дизайне. Приятного чтения!
Как и у любых других вопросов для собеседования, у этого есть недостатки. Собеседование - искусственная ситуация с жёсткими временными ограничениями, и кандидат может нервничать или уставать. Моя цель - не поймать человека на ошибке, а понять, как он рассуждает о реальных проблемах, с которыми может столкнуться в работе.
Суть задачи на интервью
Перед вами один из возможных примеров использования компоненты <Profile>, который получает проп
username
и внутри делает запрос на некий API (например, fetchProfile(username)) – это некая абстракция: может быть GitHub, может быть корпоративный сервис, без разницы.
const App = () => {
return (
<Profile username="john_doe">
{(user) => (user === null ? <Loading /> : <Badge info={user} />)}
</Profile>
);
};
Представьте, что вы разрабатываете библиотеку компонентов внутри большой компании, и этой библиотекой будут пользоваться другие команды (вполне реальный сценарий в больших IT-компаниях). Задача: написать реализацию компонента
Profile
, чтобы им удобно и ожидаемо могли пользоваться в самых разных контекстах.
import React, { useState, useEffect, useRef } from 'react';
import fetchProfile from 'somewhere';
// Это фиктивная функция, которая возвращает Promise,
// резолвящийся в объект пользователя
function Profile() {
// Допишите здесь логику
}
Важный дисклеймер:
user === null ? <Loading /> : <Badge info={user} />
- это упрощённая проверка. В реальном мире сервер может вернутьnull
в ответ, и нам придётся делать дополнительную логику, чтобы корректно обрабатывать “нет данных” vs. “данные ещё загружаются”. Однако мы намеренно оставим такой код, чтобы посмотреть, заметит ли кандидат потенциальную проблему и предложит ли более надёжное решение (например,isLoading
флаг).Наш
fetchProfile
не даёт возможности вызватьabort()
. Это сделано специально, чтобы проверить, знает ли кандидат про аборт запросов (AbortController
) и как он будет рассуждать, если такой возможности нет.
Начинаем решение
Чаще всего кандидаты сначала пишут что-нибудь простое, используя функциональные компоненты и хуки:
import React, { useState, useEffect } from 'react';
import fetchProfile from 'profileApi'; // воображаемый модуль
const Profile = ({ username, children }) => {
const [user, setUser] = useState(null);
useEffect(() => {
fetchProfile(username).then(setUser);
}, []);
return children(user);
};
Как ни странно, но на этом этапе у многих возникают сложности с пониманием того, что в children
может быть функция, и её можно просто вызвать: children(user)
, даже не оборачивая во всякие <div>...</div>
или <></>
. Почему-то кандидатам с ними спокойнее.
Однако уже тут видно несколько типичных моментов:
Отсутствие зависимостей в
useEffect
.Часто люди забывают добавитьusername
в массив зависимостей. Это значит, что еслиusername
поменяется, запрос на новый профиль не произойдёт.Необработанные ошибки. А что если
fetchProfile
завершится ошибкой или вернётnull
?
Уточняем детали
В интервью я обязательно спрашиваю: "А что, если проп username
может динамически меняться? Например, пользователь кликает по списку пользователей?" Тогда кандидат обычно исправляет код, добавляя username
в зависимости эффекта:
useEffect(() => {
fetchProfile(username).then(setUser);
}, [username]);
Теперь, если username
меняется, мы делаем новый запрос. Так понятнее. Но…
Race condition (гонка состояний)
Дальше я описываю сценарий: представьте, что в вашем приложении две панели. Слева - список пользователей, справа - <Profile username={currentUsername} />
. Пользователь начинает быстро кликать то по одному, то по другому пользователю.
Запрос A уходит для
username = 'alice'
.Тут же пользователь кликает на
username = 'bob'
, отправляется запрос B.Запрос B возвращается быстрее, мы записываем в state данные
bob
.Потом запрос A (более медленный) тоже возвращается, и внезапно перезаписывает state данными пользователя Alice!
"Может быть тут какая либо проблема?". К счастью в основном ответ да - при таком кейсе у нас может отображаться неправильная информация. На экране написано “bob”, а по факту в компоненте данные “alice”.
Разбор типичных решений
Приведу несколько реальных подходов, которые я видел от кандидатов. Самые экзотические - типа очереди запросов - опустим :)
Локальная переменная вне компонента
Иногда пытаются сделать что-то вроде:
let lastUsernameFetched = null;
function Profile({ username, children }) {
const [user, setUser] = useState(null);
lastUsernameFetched = username;
useEffect(() => {
fetchProfileManaged(username).then((profile) => {
if (lastUsernameFetched !== username) {
setUser(profile);
}
});
}, [username]);
return children(user);
}
По сути, мы храним состояние (lastUsernameFetched
) на уровне модуля. Но что, если на странице несколько экземпляров <Profile>
? Придётся как-то разделять их по идентификаторам. Это далеко не лучшее решение…
Использование useRef для отслеживания текущего username
Иногда кандидаты придумывают хранить текущий username
в useRef
, чтобы при получении результата сравнивать, совпадает ли он со значением пропса. Кандидат начинает спрашивать про структуру ответа, и в этом месте мы обычно вводим предположение, что username
в объекте профиля всё-таки есть. В результате вижу такое решение:
const Profile = ({ username, children }) => {
const [user, setUser] = useState(null);
const usernameRef = useRef(username);
useEffect(() => {
fetchProfile(username).then((profile) => {
if (usernameRef.current === profile?.username) {
setUser(profile);
}
});
}, [username]);
return children(user);
};
Почему-то часто встречал заблуждение, что useRef(username)
всегда будет передавать в usernameRef
актуальное значение пропса 🤷♂️ (хотя на самом деле это лишь начальное значение). После выяснения этого обстоятельства встречаются исправления в виде:
...
useEffect(() => {
usernameRef.current = username;
}, [username]);
...
Это приводит к лишнему вызову эффекта, но чаще встречается, к счастью, такой ответ:
const Profile = ({ username, children }) => {
const [user, setUser] = useState(null);
const usernameRef = useRef(username);
useEffect(() => {
usernameRef.current = username;
fetchProfile(username).then((profile) => {
if (usernameRef.current === profile?.username) {
setUser(profile);
}
});
}, [username]);
return children(user);
};
Отлично, идем дальше.
А если у нас в приложении две страницы, и пользователь уходит со страницы с
<Profile>
раньше, чем придёт ответ отfetchProfile
будет ли тут какая-либо проблема?
"Да, будет", ведь компонент может быть размонтирован, а асинхронный вызов вернётся. Возникает сценарий, когда React ругается - “Can’t perform a React state update on an unmounted component…”.
Тогда нередко вижу такой решение:
...
useEffect(() => {
return () => {
usernameRef.current = null;
}
}, []);
...
Это, как правило, вовсе не гарантирует, что setUser
никогда не будет вызван (мало ли, если не хороший сервер вернёт null
).
Идеальное решение
Часто самый простой подход (при отсутствии AbortController
) - завести внутри useEffect
переменную-флаг:
const Profile = ({ username, children }) => {
const [user, setUser] = useState(null);
useEffect(() => {
let isLive = true;
setUser(null);
fetchProfile(username)
.then((profile) => {
if (isLive) {
setUser(profile);
}
})
.catch((err) => {
// Здесь можно обсудить дополнительные аспекты обработки ошибок.
// Если интересно, какие именно - пишите вопросы к статье :)
});
return () => {
isLive = false;
};
}, [username]);
return children(user);
};
Пока
isLive = true
, состояние обновляется при поступлении ответаЕсли компонент размонтировался или
username
изменился (а значит, эффект сработает заново), переменнаяisLive
сбрасывается вfalse
. В результате старый запрос, вернувшийся с задержкой, не изменит состояние.Таким образом, удаётся избежать гонки при обновлении состояния и предупредить возникновение ошибки в React при вызове
setState
на размонтированном компоненте.Добавление блока
catch
наглядно показывает возможность обработки ошибок от сервера или сети. При необходимости можно обсудить способы уведомления пользователя и логирования таких ошибок.
Примечание: для упрощения здесь не рассматривается сценарий, когда
username
илиchildren
могут оказаться "пустыми" (например,null
,undefined
или пустая строка), а также ситуация, когдаchildren
не является функцией. Однако здорово, если кандидат обратит внимание и на эти нюансы.
Почему мне нравится этот вопрос
Он небольшой по объёму и наглядно показывает ключевые аспекты работы с React: получение данных, состояние загрузки, корректный рендер и работу с пропами.
Он проверяет базовые знания React: хуки, сайд-эффекты, “cleanup” при размонтировании, изменение пропсов со временем - всё это ключевые концепции во фронтенд-разработке на React.
Он выявляет важные крайние случаи:
Проп
username
может меняться, пока запрос ещё выполняется.При уходе со страницы до завершения запроса может случиться попытка обновить state размонтированного компонента.
Сервер может вернуть
null
или ошибку.Может возникнуть состояние гонки при быстрых переключениях пользователя.
Его можно масштабировать. Джуны могут представить простую рабочую версию, а для синьоров я могу задать дополнительные вопросы про оптимизацию, отмену запросов, работу с несколькими запросами одновременно.
Итог
Моя цель в подобных React-вопросах - не просто услышать готовое решение, а понять, как человек рассуждает:
Задаёт ли он уточняющие вопросы: “Что если
username
меняется?”, “Что если у нас много быстрых кликов?”, “Нужна ли отмена запроса?”Понимает ли он асинхронные эффекты и их подводные камни?
Учитывает ли он необходимость освободить ресурсы при размонтировании компонента?
Думает ли о загрузке / ошибках / логировании - ведь сервер может вернуть
null
, ошибку, или просто долго висеть.
В конце концов, главное - это структура размышлений. Точно так же, как в системном дизайне мы обсуждаем компромиссы по сложности, памяти, пропускной способности, здесь в React-собеседовании смотрим на подход к работе с данными, пропами, асинхронностью, состоянием и реактивным UI.
Удачи на ваших будущих собеседованиях!