Передача значений через пропсы — это не причина для установки Redux или Zustand.
Проверьте четыре распространенных причины добавления государственной библиотеки в рабочий код на React: использование Context для передачи значений через пропсы, useState, useSyncExternalStore и затраты на перерисовку из-за Context.
Если спросить команду, почему в их React-приложении используются Redux, Zustand или MobX, обычно первым ответом будет проблема передачи значений через свойства. Это действительно раздражает, но это не веская причина для добавления зависимости, так же как и три аргумента, которые обычно следуют за этим. Ниже каждый из этих четырех аргументов рассматривается на примере небольшого рабочего кода, а также показано, что уже предоставляет React для подобных случаев, и когда библиотека управления состоянием действительно окупает себя.
Четыре распространенных обоснования
Аргументы обычно представляются в предсказуемом порядке:
- Передача значения через компоненты, которые им не пользуются, затруднительна, поэтому приложению нужен хранилище состояния.
- Клиентское состояние, которое действительно меняется, например флаг темы светлая/темная, определенно требует библиотеки.
- Чтобы все остальные компоненты, которые читают это состояние, обновлялись автоматически, без ручной настройки свойств, необходима такая библиотека.
Каждый из этих подходов теряет смысл, как только рассматриваешь конкретный код.
Prop drilling: проблема, которую React решил много лет назад
Prop drilling означает передачу значения через несколько уровней исключительно для того, чтобы глубоко вложенный компонент мог его прочитать, при этом ни один из промежуточных уровней им не пользуется. Четыре небольших файла иллюстрируют этот механизм. Компонент App хранит объект user в состоянии и передает его компоненту Dashboard.
// App.jsx
import { useState } from 'react';
import Dashboard from './components/Dashboard';
function App() {
const [user, setUser] = useState({ name: 'Akshat', avatarUrl: '/me.png' });
return <Dashboard user={user} />;
}
export default App;
Dashboard ничего не делает с объектом user, кроме как передавать его дальше компоненту Sidebar.
// components/Dashboard.jsx
import Sidebar from './Sidebar';
function Dashboard({ user }) {
return <Sidebar user={user} />;
}
export default Dashboard;
Sidebar делает то же самое, извлекая два поля для отображения аватара.
// components/Sidebar.jsx
import Avatar from './Avatar';
function Sidebar({ user }) {
return <Avatar name={user.name} avatarUrl={user.avatarUrl} />;
}
export default Sidebar;
Только компонент Avatar фактически отображает полученные данные.
// components/Avatar.jsx
function Avatar({ name, avatarUrl }) {
return <img src={avatarUrl} alt={name} />;
}
export default Avatar;
Ни Dashboard.jsx, ни Sidebar.jsx не используют данные user в своей логике. Они лишь передают их дальше. Если avatarUrl будет переименован в photoUrl, оба файла потребуют изменений, хотя их поведение не изменилось, просто потому что они передают данные, которые им не принадлежат.
Контекст напрямую передаёт значения
Встроенным решением в React является контекст. Сначала создаётся объект контекста в отдельном модуле.
// context/UserContext.js
import { createContext } from 'react';
const UserContext = createContext(null);
export default UserContext;
Затем компонент App оборачивает соответствующую подструктуру в элемент Provider контекста и передаёт значение user, вместо того чтобы передавать его как свойство.
// App.jsx
import { useState } from 'react';
import UserContext from './context/UserContext';
import Dashboard from './components/Dashboard';
function App() {
const [user, setUser] = useState({ name: 'Akshat', avatarUrl: '/me.png' });
return (
<UserContext.Provider value={user}>
<Dashboard />
</UserContext.Provider>
);
}
export default App;
Компоненты Dashboard и Sidebar превращаются в чистые элементы макета, в которых совсем не упоминается user.
// components/Dashboard.jsx
import Sidebar from './Sidebar';
function Dashboard() {
return <Sidebar />;
}
export default Dashboard;
// components/Sidebar.jsx
import Avatar from './Avatar';
function Sidebar() {
return <Avatar />;
}
export default Sidebar;
В конце компонент Avatar сам запрашивает это значение.
// components/Avatar.jsx
import { useContext } from 'react';
import UserContext from '../context/UserContext';
function Avatar() {
const user = useContext(UserContext);
return <img src={user.avatarUrl} alt={user.name} />;
}
export default Avatar;
У каждого из этих трех элементов есть своя задача. Функция createContext создаёт канал, независимый от параметров компонента. Класс Provider делает переменную user доступной для всего его поддерева одновременно. Функция useContext(UserContext) зачитывает значение из ближайшего соответствующего элемента Provider, расположенного выше компонента, пропуская все промежуточные уровни. Промежуточные компоненты перестают использовать переменную user, поскольку она им никогда не была нужна. К слову, в React 19 также возможно отображение объекта контекста непосредственно в качестве элемента provider, но приведённый здесь способ использования .Provider по-прежнему работает.
Почему аргумент о необходимости проникновения через параметры компонента остался в употреблении дольше, чем существовала причина для него
История объясняет, почему этот спор продолжается. Redux появился в июне 2015 года, его создали Дэн Абрамов и Эндрю Кларк на основе архитектуры Flux от Facebook: единый центральный хранилище с строгими правилами изменения состояния. API Context в React стал стабильной и официально поддерживаемой функцией лишь в версии 16.3 в марте 2018 года, почти через три года. В начальный период развития Redux хранилище действительно было практичным способом сделать значение доступным в любой части структуры без необходимости передачи его через каждый компонент.
Это перестало быть верным, когда Context стал стабильным, но учебные материалы так и не соответствовали новой реальности. В учебниках по-прежнему использовался подход с передачей значений через пропсы как пример проблемы, поскольку его легко показать на доске, и эта привычка сохранялась долгое время после того, как причина её существования исчезла.
Однако проп-дрилинг связан с распространением данных, а не с их изменением. Следующий вопрос заключается в том, что происходит, когда распространённые значения начинают меняться на стороне клиента.
Изменение состояния — именно то, что делает useState
Возьмём пример переключателя темы: один флаг, содержащий значение light или dark, который меняется с помощью кнопки, расположенной рядом с текстом, отображающим текущую тему.
// components/SettingsPanel.jsx
import { useState } from 'react';
function SettingsPanel() {
const [theme, setTheme] = useState('light');
return (
<div>
<p>Current theme: {theme}</p>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</div>
);
}
export default SettingsPanel;
При нажатии на кнопку вызывается функция setTheme, компонент SettingsPanel перерисовывается с новым значением, и абзац обновляется. Никакие библиотеки не задействованы, и они не нужны. Управление значением, его изменение и перерисовка компонента — вот для чего существует useState, и каждый React-приложение делает это постоянно.
Обычно пример представляется с неким изменением, которое выполняет всю основную работу. Проблема не в том, что значение меняется, а в том, что оно считывается где-то ещё. Представьте, что кнопка остаётся внутри SettingsPanel.jsx, в то время как Header.jsx и Sidebar.jsx — два не связанных между собой файла, которые ни один из них не импортирует и которыми SettingsPanel тоже не импортируется, — также должны отображать текущую тему. Состояние, созданное с помощью useState, принадлежит единственному экземпляру компонента. Ничто вне этого компонента не может его считать или получить уведомление о изменениях, если только общий предок не передаст его дальше, что возвращает нас к проблеме передачи значений через свойства.
Таким образом, useState отлично справляется с обработкой изменений. Открытым вопросом остаётся то, как два компонента без общего родителя могут автоматически обновляться без использования свойств.
Обмен состоянием между не связанными компонентами с помощью useSyncExternalStore
Если ни Header, ни Sidebar не могут хранить это значение, оно должно находиться вне дерева компонентов: в переменной уровня модуля, инициализируемой один раз при первом импорте её файла, а не внутри какой-либо функции компонента. React никогда не видит, когда обычная переменная переопределяется, поэтому он не может самостоятельно перерисовать что-либо при изменении этого значения. Небольшой модуль-хранилище заполняет этот пробел.
// store/themeStore.js
import { useSyncExternalStore } from 'react';
let state = { theme: 'light' };
const listeners = new Set();
export function setTheme(theme) {
state = { ...state, theme };
listeners.forEach((listener) => listener());
}
function subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useTheme() {
return useSyncExternalStore(subscribe, () => state.theme);
}
Модуль содержит объект state и множество Set слушателей. Функция setTheme заменяет объект state на новый и вызывает каждого слушателя. Приватная функция регистрации добавляет слушателя в это множество и возвращает функцию для очистки, которая его удаляет. Функция useTheme связывает всё это с React через useSyncExternalStore — хук, предназначенный специально для состояния, находящегося вне компонентов, но используемого несколькими из них.
Этот хук принимает здесь два аргумента. Первый — функция регистрации, которая указывает React, как привязать обратный вызов, долженный срабатывать каждый раз при изменении внешнего значения, и должна возвращать соответствующую функцию отписки для очистки. Второй аргумент — getSnapshot, в данном случае функция-стрелка () => state.theme — это способ, с помощью которого React получает текущее значение каждый раз, когда в этом возникает необходимость.
Два потребителя импортируют хук, и ни один из них не знает о существовании другого.
// components/Header.jsx
import { useTheme } from '../store/themeStore';
function Header() {
const theme = useTheme();
return <header className={theme === 'dark' ? 'header-dark' : 'header-light'}>Site Header</header>;
}
export default Header;
// components/Sidebar.jsx
import { useTheme } from '../store/themeStore';
function Sidebar() {
const theme = useTheme();
return <aside className={theme === 'dark' ? 'sidebar-dark' : 'sidebar-light'}>Navigation</aside>;
}
export default Sidebar;
Третий компонент содержит кнопку и импортирует как хук, так и функцию setTheme из того же хранилища.
// components/ThemeToggleButton.jsx
import { setTheme, useTheme } from '../store/themeStore';
function ThemeToggleButton() {
const theme = useTheme();
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
);
}
export default ThemeToggleButton;
Что происходит шаг за шагом
Каждый из следующих шагов можно наблюдать при запуске кода:
- При монтировании компоненты
HeaderиSidebarвызывают функциюuseTheme(). React вызывает методgetSnapshotдля каждого из них, получает значение'light'и отрисовывает компоненты с этим значением. - React также вызывает функцию регистрации один раз для каждого компонента, в результате чего два внутренних обратных вызова React добавляются в общий набор
listeners. Компоненты остаются отдельными элементами в этом наборе.
ThemeToggleButton вызывает функцию setTheme('dark'). Сначала переменная state присваивается совершенно новому объекту { theme: 'dark' }; это обычный JavaScript-код, не связанный ни с чем конкретным в React.setTheme проходит по списку слушателей и вызывает каждого из них. Поскольку эти слушатели принадлежат React, их вызов заставляет React обработать все зарегистрированные компоненты.getSnapshot для компонента Header, видит значение 'dark' вместо 'light' и перерисовывает его. Аналогичная проверка приводит к перерисовке компонента Sidebar.Эта функция setTheme — не тот сеттер, который возвращает функция useState. Это обычный вручную написанный код, который выполняет две задачи за один вызов: обновляет источник данных, а затем уведомляет все слушатели.
Никакие параметры не передавались никуда. Вся схема подключения состоит из импортов. Приблизительно то же самое делает Zustand внутренне: это небольшая, упакованная версия того же паттерна регистрации и уведомления.
Важные моменты, которые стоит знать
getSnapshotдолжен возвращать ту же самую ценность, когда ничего не изменилось. Безопасно возвращать примитив, такой какstate.theme; создание нового объекта или массива при каждом вызове заставляет React считать, что хранилище постоянно меняется.- Если вы рендерите на сервере,
useSyncExternalStoreпринимает третий аргумент —getServerSnapshot— для исходного HTML. Состояние на уровне модуля на сервере также делится между запросами, поэтому не включайте в него данные конкретного пользователя.
Почему Context не является безопасным компромиссом
В случае хранилища модулей нет ничего, что нужно было бы предоставить. Переменная state в файле themeStore.js никогда не находилась в дереве компонентов; к ней обращаются через вызов хука, без участия предка-Provider. Оборачивание компонента App в ThemeProvider приведёт к добавлению слоя, который ничего не передаёт.
У контекста есть свои законные области применения: ограничение действия значения определённой поддереву или замена зависимости в тестах путём отображения другого Provider. Однако его часто рекомендуют как осторожную альтернативу библиотеке, и в этой роли он стоит дороже, чем кажется. Рассмотрим ситуацию, когда один контекст хранит как тему, так и информацию о корзине.
// context/AppContext.jsx
import { createContext, useState } from 'react';
const AppContext = createContext();
export function AppProvider({ children }) {
const [state, setState] = useState({
theme: 'light',
cart: ['book'],
});
return <AppContext.Provider value={state}>{children}</AppContext.Provider>;
}
export default AppContext;
Небольшая метка отображает только тему из этого контекста.
// components/ThemeLabel.jsx
import { useContext } from 'react';
import AppContext from '../context/AppContext';
function ThemeLabel() {
const { theme } = useContext(AppContext);
return <span>{theme}</span>;
}
export default ThemeLabel;
useContext(AppContext) подключает ThemeLabel к всему объекту, хранящемуся в Provider, а не только к полям theme. Когда значение cart меняется где-то ещё, Provider получает новый объект state, и из-за изменения ссылки ThemeLabel также перерисовывается, хотя само поле theme не изменилось и компонент никогда не взаимодействует с cart. У контекста нет встроенного способа указать «пробудить меня только при изменении этого поля»; каждый потребитель обновляется при любом изменении значения.
Магазин из предыдущего раздела уже избегает этой проблемы. Функция useTheme() считывает один фрагмент данных через метод getSnapshot, и компонент перерисовывается только тогда, когда значение этого фрагмента изменяется. Использование Context в качестве промежуточного решения позволяет сократить количество установок, но приводит к более частым перерисовкам. Это можно смягчить, разделив состояние на несколько узких контекстов, но тогда вы сами будете создавать то, что магазин на основе селекторов предоставляет бесплатно.
Где библиотеки состояния действительно находят своё место
Ничто из этого не делает Redux, Zustand или MobX бесполезными. Четыре приведенных аргумента оказались неверными, но сами библиотеки — нет. Проблему передачи значений между компонентами решает Context. Изменение состояния обрабатывается с помощью useState. Автоматические обновления в несвязанных компонентах решаются с помощью useSyncExternalStore, входящего в состав React. А Context, который считался компромиссом, оказывается более ресурсоемким, чем небольшой хранилище для общих значений, которые часто меняются.
Настоящая необходимость в библиотеке возникает тогда, когда у общего состояния много независимых изменителей, а не в основном читателей, и когда количество компонентов, которые им пользуются, превышает возможности нескольких ручно созданных модульных хранилищ для поддержания целостности данных. Координация обновлений, использование мидлвэра, инструментов разработчика и предсказуемый отслеживание изменений в таком масштабе — именно здесь зрелая библиотека оправдывает свою цену и заслуживает отдельного рассмотрения.
Основные выводы
- Обращайтесь к Context, а не к хранилищу данных, когда единственная проблема — передача значений через слои, которые их не используют.
useState— это подходящий инструмент для изменения состояния, принадлежащего одному компоненту.- Для состояния, которым делятся компоненты без общего родителя, часто достаточно небольшого хранилища, созданного с помощью
useSyncExternalStore. - Один общий Context перерисовывает все компоненты-потребители при любых изменениях; для данных, которые часто меняются, предпочтительнее использовать узкие Context или хранилище на основе селекторов.
- Используйте библиотеку управления состоянием, когда у вас много независимых авторов данных и растущее количество читателей, а не из-за проблемы передачи значений через множество уровней компонентов.
Связанная литература
- Уменьшение размера React-компонентов с использованием подхода prop-drilling и God Components — Изучите семь конкретных шаблонов рефакторинга для разбиения объемных React-компонентов путем изоляции состояния, загрузки данных, прав доступа и логики загрузки вместо простого разделения файлов.
- Как React-Redux решает о перерисовке: селекторы, проверка равенства и типизированные хуки — Руководство по внутреннему устройству React-Redux: Provider, проверка равенства с помощью useSelector, мемоизированные селекторы, функция connect(), типизированные хуки с использованием withTypes() и редкие крайние случаи.