Рассуждения о React Hooks через снимки отрисовки и компромиссы
Высокоуровневая модель мышления для React и React Native Hooks: снимки отрисовки, эффекты, ссылки, мемоизация, пользовательские Hooks и способы объяснения компромиссов на собеседованиях.
Источники: React & React Native Hooks: The Complete Senior Interview Guide
На собеседованиях на высокие должности с использованием React и React Native редко ограничиваются вопросом «Что делает этот Hook?». Вопрос о useState мгновенно приводит к обсуждению группировки операций и функциональных обновлений; вопрос о useEffect переходит к обсуждению бесконечных циклов, устаревших замыканий, отмены операций и ситуаций, когда такой эффект вообще не должен существовать; useMemo и useCallback приводят к вопросу «Действительно ли это ускоряет работу?». Зазубренные определения не выдерживают такого углубленного анализа. Что остается — это целостная модель, и этот гид создает именно такую: отрисовка, затем состояние, замыкания, эффекты, ссылки, мемоизация, пользовательские Hooks, производительность и, наконец, архитектура, с примерами React Native там, где платформа влияет на ситуацию.
Модель отрисовки, от которой зависит всё остальное
Начните с одной идеи и придерживайтесь её: когда React отрисовывает компонент, он снова вызывает функцию компонента с самого начала. Используйте как можно более маленький счётчик.
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
);
}
Естественно предположить, что нажатие кнопки влияет на компонент и изменяет переменную count. Но на самом деле это не так. Функция-сеттер просит React выполнить новую отрисовку, и следующий вызов Counter получает новое значение в виде отдельной константы.
Render 1
count = 0
↓
User clicks
↓
setCount(1)
↓
React schedules another render
↓
Render 2
count = 1
Таким образом, каждая рендеринг-операция содержит копию своих параметров и состояния. Ничто внутри этой копии не меняется позже. Это простое наблюдение объясняет использование замыканий для хранения старых значений, проблему устаревшего состояния в таймерах, причину существования зависимостей эффектов, поведение обратных вызовов и обработчиков событий, а также то, почему мемоизация связана с идентичностью данных. Специалисты, которые углубляются за пределы официального API, обычно проверяют, понимаете ли вы эту модель.
useState: состояние, принадлежащее рендеру
useState предоставляет функциональному компоненту фрагмент состояния, который используется для формирования его вывода.
const [count, setCount] = useState(0);
Возвращаемая пара имеет две разные роли: значение для текущей рендеринг-операции и функция, запрашивающая обновление.
count
↓
current state
setCount
↓
request a state update
Вызов функции-установщика не изменяет данные на месте; он запланирует новую рендеринг-операцию, в ходе которой компонент увидит новое значение.
Как усложняются вопросы
Типичная последовательность обучения выглядит так:
- Базовый уровень: какую проблему решает
useState? - Средний уровень: в чем разница между состоянием и обычной локальной переменной?
- Продвинутый уровень: когда функция-обновитель является правильным выбором?
- Глубокий уровень: что на самом деле происходит, когда
setCount(count + 1)вызывается три раза в одном обработчике?
Фраза «Он хранит состояние» отвечает только на первый вопрос. Более полный ответ касается всей модели:
useState хранит значение, которое влияет на то, что отображает компонент. Вызов функции-установщика запускает процесс новой отрисовки с обновленным значением, поэтому состояние лучше рассматривать как часть «снимка» этой отрисовки, а не как переменную, которую можно изменять. Когда следующее значение зависит от предыдущего, функция-обновитель является безопасным выбором, поскольку React группирует обновления, и зафиксированное значение уже может быть устаревшим.
В отличие от этого, обычная переменная создаётся заново с её начальным значением при каждой отрисовке, и изменение её никогда не заставляет React отрисовывать интерфейс заново, поэтому она не может управлять пользовательским интерфейсом.
Почему важны функциональные обновления
Вот три операции увеличения, выполненные с использованием зафиксированного значения.
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
Все три операции читают одно и то же значение count из текущего снимка, поэтому после группировки результата получается одно увеличение, а не три. Формат обновлителя исправляет эту проблему:
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
React передаёт каждому обновлителю самое последнее значение, ожидающее обработки, поэтому три вызова выполняются корректно. Используйте этот формат, когда несколько обновлений происходят одновременно или находятся в очереди, когда обратный вызов может быть выполнен с устаревшим снимком, и в целом каждый раз, когда новое значение вычисляется на основе старого.
Корзина покупок в приложении React Native — это реалистичный пример. Количество товаров отображается, поэтому оно должно находиться в состоянии:
const [quantity, setQuantity] = useState(1);
Обработчики получают новое значение из предыдущего, причем операция уменьшения ограничивается значением один, чтобы в корзине никогда не отображалось ноль или отрицательное количество.
const increaseQuantity = () => {
setQuantity(prev => prev + 1);
};const decreaseQuantity = () => {
setQuantity(prev => Math.max(1, prev - 1));
};
Ничего не меняется между веб-версией и нативной
Хук одинаков в обеих платформах. В веб-версии выпадающий список может отслеживать свое состояние «открыт/закрыт»:
const [isOpen, setIsOpen] = useState(false);
Экран в React Native может отслеживать видимость модального окна аналогичным образом:
const [isModalVisible, setModalVisible] = useState(false);
Механизм состояния совпадает; отличаются только UI-элементы, которые им управляются.
useEffect: синхронизация с внешним миром
Ни один Hook не вызывает столько обсуждений во время интервью. Распространённое описание «он выполняет код после отрисовки» верно, но не учитывает его цель. Лучше сказать, что эффект обеспечивает синхронизацию компонента с системой, которую React не контролирует.
В веб-среде такой системой могут быть события DOM, API браузера, WebSockets, подписки, таймеры или сетевые запросы. В React Native это часто AppState, модуль Keyboard, нативные модули, слушатели уведомлений, состояние сети, API устройства, WebSockets и таймеры.
Как развиваются вопросы
- Базовый уровень: для чего используется
useEffect? - Средний уровень: объясните, как работает массив зависимостей.
- Продвинутый уровень: что заставляет эффект работать бесконечно?
- Высокий уровень: в каких ситуациях эффект является неподходящим инструментом?
- Уровень производства: как отменить подписку на WebSocket?
Сравните утверждение «useEffect предназначен для вызовов API» с этим:
useEffect существует в основном для синхронизации компонента с внешней системой. Массив зависимостей содержит список реактивных значений, которые считывает эффект, и он должен быть точным; правило проверки Hooks выявляет пропуски. Когда эффект открывает подписку, запускает таймер или привязывает слушатель, он возвращает функцию очистки, чтобы старые ресурсы были освобождены до повторного выполнения эффекта или при демонтировании компонента.
Чтение массива зависимостей
При наличии зависимости эффект выполняется после первой отрисовки и снова каждый раз, когда это значение меняется между отрисовками.
useEffect(() => {
fetchUser(userId);
}, [userId]);
При полном отсутствии массива он выполняется после каждой отрисовки:
useEffect(() => {
// ...
});
При пустом массиве он выполняется после монтирования компонента:
useEffect(() => {
// ...
}, []);
Будьте осторожны с фразой «запускается один раз». В режиме строгой проверки во время разработки компонент намеренно монтируется, очищается и снова монтируется, чтобы выявить проблемы, связанные с неправильным очищением. Точнее говоря, эффект с пустым массивом выполняется после монтирования, а режим строгой проверки во время разработки может добавить дополнительный цикл настройки и очистки. Код, который не работает при таком двойном запуске, обычно содержит реальный баг.
Очистка предотвращает утечку ресурсов
Подписка, зависящая от userId, открывает соединение внутри тела эффекта:
useEffect(() => {
const subscription = subscribeToUpdates(userId);
и возвращает функцию, которая его закрывает:
return () => {
subscription.unsubscribe();
};
}, [userId]);
Когда меняется userId, React сначала выполняет предыдущую операцию очистки, прежде чем запустить новый эффект, поэтому всегда существует не более одной активной подписки:
Old effect
↓
cleanup old subscription
↓
new effect
↓
subscribe using new userId
При демонтировании выполняется последняя операция очистки, и ничего не остается позади:
component unmounts
↓
cleanup
↓
subscription removed
Тот же механизм используется для отмены асинхронных операций. Для запроса данных можно прервать его с помощью AbortController в процедуре очистки или установить флаг ignore, чтобы ответ от старого userId, пришедший с опозданием, не перезаписывал более свежие данные.
Пример в React Native: реакция на AppState
Более убедительным примером, чем загрузка данных, является связь компонента с жизненным циклом нативного приложения. Этот слушатель обновляет данные, когда приложение возвращается на передний план, и удаляется при демонтировании компонента.
useEffect(() => {
const subscription = AppState.addEventListener(
"change",
nextState => {
if (nextState === "active") {
refreshData();
}
}
);
return () => {
subscription.remove();
};
}, []);
Это самая ясная форма синхронизации с внешней системой, и она показывает интервьюеру, что вы понимаете цель использования эффектов.
Ошибка: синхронизация производных значений через эффект
Предположим, форма хранит имя и фамилию:
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
Часто встречающийся антипаттерн предусматривает добавление третьего элемента состояния и эффекта для его обновления:
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
Это приводит к избыточному состоянию и дополнительной отрисовке каждый раз, когда меняется имя, при этом во время одной отрисовки полное имя устаревает. Поскольку значение полностью определяется существующим состоянием, его следует вычислять во время отрисовки:
const fullName = `${firstName} ${lastName}`;
Принцип, который стоит запомнить: если значение можно вычислить синхронно на основе пропсов или состояния, следует получать его во время отрисовки, а не хранить и корректировать позже с помощью эффекта. Этот паттерн рассматривается более подробно в статье о прекращении синхронизации состояния с useEffect.
Ошибка: нестабильные зависимости от объектов
Здесь внутри компонента создается литерал объекта, который затем указывается как зависимость.
const filters = {
status: "active",
};
useEffect(() => {
fetchUsers(filters);
}, [filters]);
При каждой отрисовке создается новый объект filters, а зависимости сравниваются по ссылке, поэтому эффект каждый раз воспринимает это как «изменение». Если эффект обновляет состояние, цикл продолжает себя подпитывать:
Render
↓
new filters object
↓
dependency changed
↓
effect
↓
setState
↓
render
↓
new filters object
↓
effect again
Рефлексивным решением является обертка объекта с помощью мемоизации:
useMemo(...)
Прежде чем это делать, стоит задуматься, действительно ли объект необходимо создавать внутри компонента. Константу можно вынести за пределы компонента; значение, сгенерированное из пропсов, часто можно заменить путем использования его примитивных полей (например status) или путем его создания внутри эффекта. Исправление потока данных — это более продвинутый подход; добавление мемоизации для его скрытия — нет.
useRef: память, которая не отрисовывается
useRef возвращает изменяемый контейнер, который сохраняется между отрисовками, и изменения в нем никогда не запускают новую отрисовку.
const valueRef = useRef(0);
Вы читаете и записываете его содержимое через свойство current:
valueRef.current
Присвоение нового значения, как показано ниже, обновляет контейнер, но экран остается прежним до тех пор, пока что-то ещё не вызовет перерисовку.
valueRef.current = 10;
На собеседованиях спрашивают обычно сначала «в чем разница между ref и state?», затем «приведите реальный пример», а потом «почему здесь лучше использовать ref вместо state?». Хороший ответ:
State — это данные, которые используются для перерисовки, поэтому их изменение запускает процесс перерисовки. Ref сохраняет изменяемое значение между перерисовками без необходимости перерисовки при изменении
.current. Ref подходят для императивных хэндлов, идентификаторов таймеров, предыдущих значений и других изменяемых данных, которые не отображаются в интерфейсе.
В веб-технологиях ref обеспечивает императивный доступ к узлу DOM, например для фокусировки поля ввода:
const inputRef = useRef<HTMLInputElement>(null);
inputRef.current?.focus();
В React Native refs также широко используются, поскольку мобильные интерфейсы часто требуют императивных действий, таких как возврат списка обратно в верхнюю часть:
const scrollRef = useRef<ScrollView>(null);
scrollRef.current?.scrollTo({
y: 0,
animated: true,
});
Выбор между состоянием и ref
Вопрос для принятия решения прост: требуется ли изменение этого значения к изменению того, что видит пользователь? Если да, выбирайте
useState
а если нет, выбирайте
useRef
Активная вкладка явно влияет на интерфейс, поэтому это состояние:
const [selectedTab, setSelectedTab] = useState("home");
Объект таймера существует только для того, чтобы его можно было позже остановить; ничто не отображает его, поэтому ref — подходящее решение:
const timerRef = useRef<number | null>(null);
useMemo: кэширование вычисленного значения
useMemo хранит результат вычислений и пересчитывает его только тогда, когда меняются зависимости. Классическим примером является отфильтрованный и отсортированный список пользователей:
const filteredUsers = useMemo(() => {
return users
.filter(user => user.active)
.sort(sortUsers);
}, [users]);
Пока переменная users сохраняет тот же самый ссылочный объект, фильтрация и сортировка пропускаются при последующих отрисовках. Следует обратить внимание на скрытое требование: функция sortUsers также должна быть стабильной, либо она должна указываться как зависимость; к тому же функция sort модифицирует массив на месте, что здесь безопасно только потому, что функция filter уже сгенерировала новый массив.
Часто возникает вопрос: «Стоит ли использовать её везде?». Ответ, основанный на здравом смысле:
Нет. Функция useMemo не является бесплатной: она добавляет отслеживание зависимостей, увеличивает объём памяти и требует дополнительного кода для обработки. Она оправдывает себя тогда, когда дорогостоящие вычисления выполняются зря, или когда стабильный ссылочный объект важен для последующих операций; прежде чем её использовать, следует провести профилирование.
Чрезмерное использование функции Memoize для объединения строк — это типичный пример злоупотребления:
const fullName = useMemo(
() => `${firstName} ${lastName}`,
[firstName, lastName]
);
Задача простая, поэтому прямое выражение одновременно быстрее для восприятия и как минимум так же быстро выполняется:
const fullName = `${firstName} ${lastName}`;
Теперь рассмотрим приложение для торговли в React Native, отображающее тысячи сделок, отфильтрованных по символу и временному интервалу, сортированных от новых к старым.
const visibleTrades = useMemo(() => {
return trades
.filter(trade => trade.symbol === selectedSymbol)
.filter(trade => trade.timestamp >= startTime)
.sort((a, b) => b.timestamp - a.timestamp);
}, [trades, selectedSymbol, startTime]);
Когда такая обработка становится значительно затратной, мемоизация оправдывает себя. Даже тогда необходимо подтвердить это с помощью профилятора, а не полагаться на предположения.
useCallback: сохранение идентичности функции
useCallback возвращает тот же объект функции между перерисовками, пока не изменятся его зависимости.
const handlePress = useCallback(() => {
submitOrder();
}, [submitOrder]);
Разница с useMemo заключается только в том, что кэшируется. Один из них кэширует значение:
useMemo
→ memoises a value
Другой кэширует ссылку на функцию:
useCallback
→ memoises a function reference
На самом деле useCallback(fn, deps) ведет себя так же, как useMemo(() => fn, deps). Ответ «чтобы улучшить производительность» — это слабый ответ на вопрос «зачем его использовать?». Лучший ответ:
useCallback сохраняет неизменность функции, пока её зависимости остаются прежними. Его стоит использовать там, где действительно важна равенство ссылок, например, когда функция-колбэк передаётся мемизированному компоненту или когда другой хук указывает на неё как на зависимость. Его не следует добавлять по умолчанию, потому что мемизация имеет свои издержки и затрудняет понимание кода.
Как он связан с React.memo
React.memo пропускает перерисовку компонента, если все его параметры поверхностно равны предыдущим.
const UserRow = React.memo(function UserRow({
user,
onPress,
}) {
return (
// ...
);
});
Если родительский компонент передаёт новую функцию-стрелку при каждой перерисовке, один из параметров всегда будет отличаться:
<UserRow
user={user}
onPress={() => openUser(user.id)}
/>
Поскольку ссылка меняется, поверхностное сравнение терпит неудачу, и элемент всё равно отрисовывается из кэша, что может незаметно свести на нет преимущества React.memo. Стабильный обработчик выглядит так:
const handlePress = useCallback(() => {
openUser(user.id);
}, [user.id]);
и передаётся вместо функции, определённой непосредственно в компоненте:
<UserRow
user={user}
onPress={handlePress}
/>
Помните о важном моменте: делайте это, когда у вас есть доказательства того, что перерисовка дочернего компонента происходит дорогостояще, а не из привычки.
Закрытия и проблема устаревших значений
Закрытия — это особенность JavaScript, а не хук, но без них трудно работать с хуками. Закрытие — это функция, которая сохраняет доступ к переменным того области видимости, в которой она была создана.
function createLogger(name: string) {
return () => {
console.log(name);
};
}
Внутренняя функция по-прежнему имеет доступ к переменной name даже после возврата функции createLogger.
Как проявляется устаревшее закрытие в React
Рассмотрим компонент с счётчиком:
function Counter() {
const [count, setCount] = useState(0);
и две кнопки: одна для увеличения значения, а другая — для записи его значения после истечения односекундного таймаута:
const handleClick = () => {
setTimeout(() => {
console.log(count);
}, 1000);
}; return (
<>
<button onClick={handleClick}>
Log Count
</button> <button onClick={() => setCount(c => c + 1)}>
Increment
</button>
</>
);
}
Предположим, что обработчик логирования был создан во время отрисовки:
count = 0
Если вы нажмете «Записать значение» и затем несколько раз увеличите его в течение этой секунды, таймаут всё равно будет выводить значение:
0
несмотря на то, что последующие отрисовки уже продолжились:
count = 1
count = 2
count = 3
Колбэк таймаута сохраняет ссылку на переменную count из момента её создания, и эта ссылка никогда не меняется.
Объяснение во время собеседования
Компоненты на основе функций работают в режиме кадров отрисовки: каждая отрисовка имеет собственные значения и собственные замыкания. Колбэк, созданный в ходе одной отрисовки, сохраняет значения этой отрисовки, поэтому асинхронные процессы, такие как таймеры, могут наблюдать старое состояние. Решение зависит от необходимости: функциональное обновление, корректная настройка зависимостей эффектов или использование ref, когда действительно требуется последнее изменяемое значение без вызова отрисовки.
В этом ответе указывается причина проблемы и предлагаются три решения с условиями их применения, что именно ищет опытный интервьюер.
Пользовательские хуки: упаковка поведения
Пользовательские хуки — это место, где сочетаются предыдущие идеи. Пользовательский хук — это функция, название которой начинается с use, и которая вызывает другие хуки, позволяя извлекать состоянийно-зависимое поведение и использовать его заново. Загрузчик профилей начинает с объявления своего состояния:
function useUserProfile(userId: string) {
const [data, setData] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
Затем выполняется запрос с использованием эффекта, связанного с userId, и возвращаются три значения:
useEffect(() => {
// fetch user
}, [userId]); return {
data,
loading,
error,
};
}
Экран использует их, не понимая принципа загрузки:
function ProfileScreen({ userId }) {
const {
data,
loading,
error,
} = useUserProfile(userId);
и отображает необходимый контент на основе полученных результатов:
// UI
}
Обратите внимание, что каждый компонент, вызывающий пользовательский хук, имеет собственное независимое состояние; хук делится логикой, а не данными.
Что на самом деле проверяет интервьюер
Вопрос «Можете ли вы написать пользовательский хук?» на самом деле касается способности распознавать повторно используемую абстракцию поведения. Хорошие кандидаты ведут себя следующим образом:
useAuth()
useUserProfile()
useNetworkStatus()
usePagination()
useDebounce()
useKeyboard()
useAppState()
usePermissions()
useInfiniteScroll()
В React Native хорошим примером является хук для отслеживания состояния сети. Он подписывается на изменения соединения, сохраняет флаг в состоянии и возвращает функцию отписки в качестве операции очистки.
function useNetworkStatus() {
const [isConnected, setIsConnected] = useState(true);
useEffect(() => {
const unsubscribe = subscribeToNetworkChanges(
state => {
setIsConnected(state.isConnected);
}
);
return unsubscribe;
}, []);
return {
isConnected,
};
}
Экран оформления заказа затем принимает решение на основе полученного результата:
function CheckoutScreen() {
const { isConnected } = useNetworkStatus();
if (!isConnected) {
return <OfflineMessage />;
}
return <Checkout />;
}
Система отображения не имеет представления о том, как настроены сетевые подписки, и именно это незнание является сутью абстракции.
Остальные инструменты из набора Hook
На собеседованиях для старших разработчиков обычно рассматриваются не только пять распространенных Hook, поэтому важно знать более широкий набор.
useContext
Он считывает значение, предоставленное на более высоком уровне иерархии.
const theme = useContext(ThemeContext);
Он подходит для решения общих, междисциплинарных задач: темы, аутентификации, локали и конфигурации. Он не является библиотекой управления состоянием автоматически; каждый потребитель перерисовывается при изменении предоставленного значения, что важно для данных, которые часто обновляются.
useReducer
Когда несколько элементов состояния меняются одновременно, отдельные функции-установщики разбрасывают логику перехода:
setLoading(...)
setData(...)
setError(...)
Редьюсер централизует её:
const [state, dispatch] = useReducer(reducer, initialState);
Компоненты описывают, что произошло, а редьюсер определяет следующее состояние:
dispatch({
type: "FETCH_SUCCESS",
payload: data,
});
Моделирование переходов в виде именованных действий позволяет легче прогнозировать и тестировать сложные состояния.
useLayoutEffect
Он имеет ту же структуру, что и useEffect, но выполняется синхронно после обновления дерева React и до отрисовки браузером, что необходимо, когда нужно измерить макет и внести коррективы до того, как пользователь заметит мерцание. В React Native существуют аналогичные случаи, когда важен момент отрисовки. Это не более быстрый useEffect; поскольку он блокирует отрисовку, чрезмерное использование снижает отзывчивость.
useId
Он генерирует стабильные уникальные идентификаторы, в основном для обеспечения доступности, например для связывания метки с полем ввода. Он особенно полезен в веб-среде; в React Native, у которого нет браузерного DOM, сфера применения ограниченнее.
useImperativeHandle
Это позволяет компоненту самостоятельно определять, что именно будет обнаруживаться через привязанный референс, вместо того чтобы раскрывать информацию о базовом узле. Переиспользуемый компонент может обеспечивать функцию фокусировки:
ref.current?.focus();
или прокрутку до верха:
ref.current?.scrollToTop();
Такой контролируемый способ работы особенно удобен для общедоступных компонентов React Native.
useSyncExternalStore
Это поддерживаемый способ чтения данных из хранилища, находящегося вне React, что обеспечивает единообразные кадры даже при одновременной отрисовке. Этот инструмент является сложным, но часто упоминается в обсуждениях архитектуры и лежит в основе библиотек управления состоянием.
useTransition
Он помечает некоторые обновления как незначимые. Концептуально, важные операции обеспечивают отзывчивость интерфейса:
Urgent update
↓
Keep UI responsive
в то время как незначимые операции могут выполняться с более низким приоритетом:
Non-urgent update
↓
Can be scheduled with lower priority
Это важно в крупных приложениях, где дорогостоящая перерисовка конкурирует с вводом текста или нажатиями.
useDeferredValue
Он откладывает обработку значения, чтобы оперативные действия пользователя оставались быстрыми. Классический пример:
User typing
↓
Keep input responsive
↓
Defer expensive filtering
Ввод обновляется мгновенно, в то время как сложная фильтрация обрабатывает отложенную копию запроса.
Правила использования хуков
Два правила обеспечивают корректную работу хуков, поскольку React идентифицирует каждый хук по порядку его вызова.
Вызывайте хуки на верхнем уровне
Никогда не оборачивайте хук в условие, цикл или операцию раннего возврата:
if (isLoggedIn) {
useEffect(() => {});
}
Вместо этого поместите условие внутрь хука:
useEffect(() => {
if (!isLoggedIn) {
return;
}
и укажите значение, от которого он зависит:
// logic
}, [isLoggedIn]);
Вызывайте хуки только из функций React
Хуки предназначены для использования в функциональных компонентах и пользовательских хуках, а не в произвольных вспомогательных функциях или методах классов.
Разговор о производительности
«Этот экран работает медленно, что бы вы сделали?» — это распространенный вопрос. Ответ «добавить useMemo и useCallback» указывает на неверный подход. Лучший ответ:
Первым шагом является выяснение того, куда на самом деле уходит время: как часто рендерятся компоненты, какие вычисления занимают много ресурсов, где находятся границы компонентов, как отображаются списки, как распространяются обновления состояния и какие задачи связаны с платформой. Затем необходимо проанализировать производительность и устранить настоящий узкое место.
Производительность React Native включает не только рендеринг
Затраты на работу мобильного экрана распределяются по нескольким уровням:
React rendering
↓
JavaScript execution
↓
Native platform work
↓
Layout
↓
Images
↓
Animations
↓
Lists
↓
Network
Поэтому когда экран React Native работает с трудностями, причиной может оказаться не
useState
или
useEffect
Это может быть связано с тяжелой работой JavaScript, чрезмерным количеством отрисовок, большими списками, обработкой изображений, загрузкой анимаций, операциями на уровне операционной системы, задержками в сети или подписками на состояние, которые пробуждают слишком много компонентов. Умение определять, где искать проблему среди всех этих факторов, — вот что отличает опытных специалистов по разработке мобильных приложений.
Большой список транзакций
Представьте приложение для финансов, которое отрисовывает тысячи транзакций. В примитивной версии весь процесс пересчитывается при каждой отрисовке:
const visibleTransactions = transactions
.filter(...)
.sort(...)
.map(...);
Хранение результатов фильтрации и сортировки в кэше — это разумный шаг:
const visibleTransactions = useMemo(() => {
return transactions
.filter(...)
.sort(...);
}, [transactions]);
Это редко является полным решением. Также необходимы виртуализация списка, эффективная отрисовка элементов, стабильные ключи, компоненты строк с кэшированием, узкие подписки на состояние, разумная загрузка изображений и адаптивные интеракции. В React Native выбор FlatList или FlashList и их правильная настройка обычно имеют гораздо большее значение, чем один useMemo. Важно спросить себя, где находится узкое место, а не какой Hook добавить.
Владение состоянием и архитектура
Context против библиотек хранилищ
На вопрос «Когда использовать Context вместо Redux или Zustand?» нет однозначного ответа.
Всё зависит от состояния. Подход с контекстом хорошо подходит для довольно стабильных, универсальных значений, таких как тема или настройки. Для сложного состояния приложения, которое часто обновляется и требует селекторов, предсказуемых переходов или тонкой настройки подписок, обычно более уместна специализированная библиотека для работы со состоянием.
Важными являются понятия принадлежности и частоты обновления.
Не всё должно храниться в глобальном хранилище
Распространённой архитектурной ошибкой является сбор всех значений в одно хранилище:
Everything
↓
Global store
Вместо этого следует определить, кто отвечает за каждую часть состояния. Локальное состояние компонента используется для кратковременных деталей интерфейса:
isModalOpen
inputValue
selectedTab
Состояние сервера — это данные, полученные из API; его обычно лучше обрабатывать с помощью специализированной библиотеки для работы со состоянием сервера, которая управляет кэшированием и повторным загрузкой:
user profile
products
orders
transactions
Глобальное состояние клиента — это небольшой набор значений, которыми действительно делится множество экранов:
theme
authentication state
application preferences
В веб-приложениях часть состояния должна находиться в URL, чтобы оно сохранялось при перезагрузке и могло быть передано по ссылке:
filters
search
pagination
Правило, которое следует помнить: состояние должно находиться в самом узком возможном диапазоне.
Применение на экране инвестиций
Представьте экран инвестиций в React Native, который требует данных рынка, выбранного символа, данных графика, информации о портфеле, обработки состояния AppState, возможности прокрутки и сложной обработки графика. Упрощённая структура:
InvestmentScreen
│
├── useMarketData()
│
├── usePortfolio()
│
├── useAppState()
│
├── useRef()
│
├── useMemo()
│
└── useCallback()
│
▼
UI
Ниже приведен набросок компонента. Стоит обратить внимание на два момента: фрагмент заканчивается несколькими абзацами текста и разделителями, которые не являются валидным кодом, поэтому его следует рассматривать как псевдокод; кроме того, функция setSymbol из компонента useState уже является стабильной, поэтому использование обертки useCallback здесь необязательно, и можно передавать сеттер напрямую.
function InvestmentScreen() {
const [symbol, setSymbol] = useState("AAPL");
const scrollRef = useRef<ScrollView>(null);
const { data: marketData } = useMarketData(symbol);
const { portfolio } = usePortfolio();
const chartData = useMemo(() => {
return transformMarketData(marketData);
}, [marketData]);
const handleSymbolChange = useCallback(
(nextSymbol: string) => {
setSymbol(nextSymbol);
},
[]
);
return (
<ScrollView ref={scrollRef}>
<MarketHeader
symbol={symbol}
onSymbolChange={handleSymbolChange}
/>
<PriceChart data={chartData} />
<PortfolioSummary
portfolio={portfolio}
/>
</ScrollView>
);
}
---
Now each concept has a clear responsibility.
---
Теперь каждый хук имеет одну четко определенную функцию:
useState
→ selected symboluseRef
→ imperative ScrollView accessuseMemo
→ expensive chart transformationuseCallback
→ stable handler where usefulCustom Hooks
→ market and portfolio behaviouruseEffect
→ internal synchronization within those Hooks
Эффекты остаются внутри пользовательских хуков, где они синхронизируются с источником данных рынка и жизненным циклом приложения, а сам экран практически напоминает описание своих данных.
Ответы от старшего инженера
Идти на шаг дальше вопроса
Частой ошибкой является ответ только на то, что было спрошено. На вопрос «Что такое useMemo?» ответ «Он сохраняет значение в памяти» верен, но легко забываем. Более полный ответ:
useMemo хранит вычисленное значение, ключом к которому служат его зависимости. Он не является стандартным инструментом, поскольку сопряжен с дополнительными затратами и увеличивает сложность кода. Он полезен тогда, когда дорогостоящие вычисления выполняются неоправданно часто или когда важен стабильный обращение к данным, особенно после того, как профилирование выявит узкое место.
За несколько предложений вы продемонстрировали знание API, осведомленность о производительности, умение считать компромиссы и принимать решения.
Структура ответа из пяти шагов
Надежный шаблон для любого вопроса о Hook’ах:
1. What is it?
2. Why does it exist?
3. Real example
4. Trade-off / pitfall
5. React Native example when relevant
Применение к useRef:
- Что: постоянное изменяемое значение, которое не вызывает перерисовки.
- Почему: для данных, которые должны сохраняться между перерисовками, но не являются частью интерфейса.
- Пример: идентификатор таймера или хэндл императивного компонента.
- Компромисс: поскольку обновление такого значения не приводит к перерисовке, оно не должно хранить состояние интерфейса.
Вопросы, к которым стоит подготовиться
Что касается useState, будьте готовы обсудить функции-обновители и группировку нескольких вызовов, почему изменение обычной переменной не влияет на отображение экрана, что запускает перерисовку, где должно храниться определённое состояние и как избегать состояния, которое на самом деле является производным.
Что касается useEffect, ожидайте вопросов о роли массива зависимостей, устаревших замыканиях, многократных запусках, для чего предназначена функция очистки, отмене выполнения асинхронных операций, ситуациях, когда effect — не подходящий инструмент, и двойном вызове в режиме Strict Mode во время разработки.
Что касается useRef, подготовьтесь объяснить, почему запись в .current не приводит к перерисовке, в каких ситуациях ref предпочтительнее состояния, как refs привязываются к элементам DOM или нативным виджетам и что означает императивный API.
Что касается useMemo, необходимо четко понимать, ускоряет ли он всегда работу, в чем разница между ним и useCallback, что означает равенство по ссылке, когда целесообразно его удалять и как доказать, что он помог.
Что касается useCallback, нужно знать, почему идентичность функции может иметь значение, как она сочетается с React.memo, когда она не приносит никакой пользы, может ли замедлить работу и что должно входить в список зависимостей.
Упражнение на отладку
На собеседованиях часто дают компонент с ошибками. Вот пример с несколькими проблемами.
function UserList({ users }) {
const [search, setSearch] = useState("");
const filteredUsers = useMemo(() => {
return users.filter(user =>
user.name.includes(search)
);
}, [users]);
useEffect(() => {
console.log("Search:", search);
}, []);
return (
<List
data={filteredUsers}
renderItem={({ item }) => (
<UserRow
user={item}
onPress={() => selectUser(item.id)}
/>
)}
/>
);
}
Отсутствует зависимость в useMemo
Мемо формируется только на основе пользователей:
}, [users]);
но фильтр также считывает
search
поэтому список никогда не обновляется во время ввода пользователя. В списке зависимостей должны быть указаны оба элемента:
}, [users, search]);
Правило проверки кода сразу же выдаст предупреждение по этому поводу.
Эффект фиксируется на первом значении
У этого эффекта пустой массив:
useEffect(() => {
console.log(search);
}, []);
поэтому он выполняется только после монтирования и записывает начальный результат поиска. Если цель — логировать каждое изменение, необходимо использовать этот эффект:
useEffect(() => {
console.log(search);
}, [search]);
Встроенный обратный вызов, обрабатываемый с учетом обстоятельств
Обработчик строки
onPress={() => selectUser(item.id)}
является новой функцией при каждом отрисовывании. Само по себе это не является ошибкой. Сначала необходимо выяснить, действительно ли
UserRow
это затратно и действительно ли происходит ненужное повторное отрисовывание. Только на основании этих данных можно будет принять решение
useCallback
или перестроить компоненты, например, передав в элемент строки идентификатор и стабильный обработчик. Демонстрация такого сдержанного подхода является частью ожидаемого ответа.
Хорошие, качественные и профессиональные ответы
Эту последовательность легко запомнить. Хороший разработчик знает, что такое useCallback. Опытный разработчик знает, когда его использовать. Старший разработчик знает, когда его не использовать, и может объяснить связанные с этим компромиссы. Та же самая ступенчатая система применима ко всем хукам. Цель никогда не
Maximum Hooks
а скорее
Correct abstraction
+
Correct data flow
+
Correct performance trade-offs
Один набор концепций для веба и нативных приложений
Вам не нужны отдельные знания хуков для React и React Native. В веб-приложениях примеры обычно касаются
DOM
browser events
Web APIs
WebSockets
browser storage
в то время как обсуждения в мобильных приложениях касаются
AppState
Keyboard
native modules
device APIs
push notifications
network state
native event subscriptions
Однако основной набор инструментов в обоих случаях — это React:
useState
useEffect
useRef
useMemo
useCallback
useContext
useReducer
Custom Hooks
closures
dependency arrays
Поэтому сначала объясните концепцию React, а затем покажите, как применять её на конкретной платформе. Это поможет сохранить доверие как в веб-разработке, так и в мобильной разработке или гибридных проектах.
Краткая справочная таблица
useState
Элемент, который запускает перерисовку при обновлении. Типичные случаи использования:
form values
selected tabs
modal visibility
loading state
local UI state
useEffect
Синхронизация с внешними системами. Типичные случаи использования:
subscriptions
timers
WebSockets
AppState
native events
network operations
Избегайте его для значений, которые можно получить во время перерисовки.
useRef
Постоянное изменяемое значение, которое не участвует в перерисовке. Типичные случаи использования:
DOM/native refs
timers
previous values
imperative APIs
mutable non-UI data
useMemo
Значение, вычисленное с использованием мемоизации. Обращайтесь к нему, когда
calculation is expensive
or
referential identity matters
сначала необходимо произвести измерения.
useCallback
Ссылка на функцию, мемоизированная для повторного использования. Это целесообразно, когда
function identity matters
React.memo is involved
another Hook depends on function identity
не добавляйте её автоматически.
useContext
Читает общедоступные данные контекста, такие как:
theme
locale
authentication
configuration
useReducer
Моделирует сложные переходы. Полезно, когда у вас есть
many related state variables
complex transitions
state machine-like behaviour
Пользовательские хуки
Повторно используемое состояние, например:
useAuth()
useNetworkStatus()
useAppState()
usePagination()
useKeyboard()
useUserProfile()
Ментальная модель вместо глоссария
Вместо того чтобы запоминать однословное описание для каждого хука,
useState = state
useEffect = side effect
useRef = reference
useMemo = memoisation
useCallback = callback
представьте, как они связаны с процессом отрисовки: состояние интерфейса передается через useState, что приводит к повторной отрисовке; вычисляемые значения формируются с помощью useMemo; изменяемые данные, не связанные с интерфейсом, хранятся в useRef; а useEffect соединяет отрисованный интерфейс с API, событиями и нативными системами, причем все это обернуто в пользовательские хуки.
React Render
│
┌──────────┼──────────┐
│ │ │
UI State Derived Mutable
│ Values Data
│ │ │
useState useMemo useRef
│
▼
Re-render
│
▼
UI
│
▼
External Systems
│
▼
useEffect
│
▼
APIs / Events / Native
│
▼
Custom Hooks
В основе всего этого лежат несколько фундаментальных принципов:
Rendering
+
Closures
+
Referential Equality
+
Data Flow
+
Performance
Как только они будут усвоены, отдельные хуки станут понятными.
Вопросы, которые следует задавать при каждом ревью кода
Хуки — это инструменты для управления различными видами поведения в реактивных приложениях, и важной навыком является понимание причин использования каждого из них. Эти вопросы подходят как для ревью, так и для собеседований:
- Действительно ли необходимо наличие этого состояния, или его можно вычислить?
useState?Заключение
Хуки становятся проще, когда вы перестаёте рассматривать их как отдельные API и видите в них этапы одного процесса:
Rendering
↓
State
↓
Closures
↓
Effects
↓
References
↓
Memoisation
↓
Custom Hooks
↓
Performance
↓
Architecture
Учитывая этот подход, переход между React для веба и React Native становится гораздо менее сложным. Когда кто-то спрашивает, почему вы использовали useMemo в определенном месте, пропустите школьное определение и расскажите всю историю: какую проблему вы обнаружили, что её вызвало, почему подошел именно этот Hook, какие компромиссы вы учли и как оценили результат.
Перед собеседованием убедитесь, что можете естественно говорить о следующем:
- Основы: причины перерисовки, что такое снимок состояния после перерисовки, в чем разница между состоянием и обычной переменной, замыкания, равенство по ссылке и процесс согласования.
useContext и useReducer, специализированные хуки (useLayoutEffect, useId, useImperativeHandle, useSyncExternalStore, useTransition, useDeferredValue) и способы создания собственных хуков.React.memo сравнивает параметры, затраты на мемоизацию, профилирование процессов отрисовки, работы с длинными списками, сложными вычислениями и чрезмерно широкими подписками на хранилище данных.Цель не в том, чтобы запомнить ответы на все вопросы, а в достаточном понимании, чтобы разобраться с незнакомой проблемой во время собеседования.