Урок 19: Проблема подъема состояния (Lifting State Up) в React

Сегодня нас ждет очень важная тема, которая является основой в построении по-настоящему динамичных и отзывчивых приложений. Мы поговорим о том, как заставить компоненты «общаться» друг с другом, когда они не находятся в прямых отношениях «родитель-потомок». Эта техника называется «подъем состояния» (Lifting State Up).

Если вы когда-нибудь задумывались: «Как мне синхронизировать два разных компонента, чтобы изменение в одном сразу же отражалось в другом?», то этот урок даст вам исчерпывающий ответ. Мы разберем проблему на практике, найдем ее корень и применим элегантное и мощное решение, принятое в React-сообществе.

Проблема: «Разобщенные» компоненты-братья

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

  1. ProductList это список товаров, где каждый товар можно выбрать.

  2. ShoppingCart это корзина, которая показывает итоговую стоимость и количество выбранных товаров.

Вот как они могут выглядеть в нашем коде:

jsx
// Компонент списка товаров
function ProductList() {
  // Состояние выбранных товаров находится ЗДЕСЬ
  const [selectedItems, setSelectedItems] = useState([]);

  const products = [
    { id: 1, name: 'Ноутбук', price: 1000 },
    { id: 2, name: 'Телефон', price: 500 },
    { id: 3, name: 'Наушники', price: 150 },
  ];

  const handleToggleProduct = (productId) => {
    // Логика добавления/удаления товара из selectedItems
    setSelectedItems(prev => 
      prev.includes(productId) 
        ? prev.filter(id => id !== productId) 
        : [...prev, productId]
    );
  };

  return (
    <div>
      <h2>Список товаров</h2>
      {products.map(product => (
        <div key={product.id}>
          <span>{product.name} - ${product.price}</span>
          <button onClick={() => handleToggleProduct(product.id)}>
            {selectedItems.includes(product.id) ? 'Убрать' : 'Добавить'}
          </button>
        </div>
      ))}
    </div>
  );
}

// Компонент корзины
function ShoppingCart() {
  // Упс! Корзина не знает о selectedItems из ProductList!
  // У нее свое, НЕСВЯЗАННОЕ состояние.
  const [cartItems, setCartItems] = useState([]); 

  // ... какая-то своя логика для корзины

  return (
    <div>
      <h2>Корзина</h2>
      <p>Товаров в корзине: {cartItems.length}</p>
      {/* Здесь мы не можем отобразить выбранные товары! */}
    </div>
  );
}

// Основной компонент приложения
function App() {
  return (
    <div>
      <ProductList />
      <ShoppingCart />
    </div>
  );
}

Что мы видим? Оба компонента, ProductList и ShoppingCart, являются «братскими» (sibling components). Они рождены от одного родителя, компонента App. Но состояние выбранных товаров selectedItems живет исключительно внутри ProductList. Компонент ShoppingCart понятия не имеет, какие товары выбраны и не может отобразить эту информацию. Это и есть та самая «разобщенность».

Почему это плохо? Наше приложение перестает быть целостным. Данные, которые по смыслу должны быть едиными (состояние выбора товаров), размазаны по разным, не связанным между собой, уголкам приложения. Мы не можем создать согласованный пользовательский интерфейс.

Решение: Поднимаем состояние наверх

Философия React предлагает нам прекрасное решение: «Подъем состояния вверх» (Lifting State Up). Суть этой концепции проста: если два или более компонента должны отражать одни и те же изменяемые данные, вам следует поднять общее состояние до их ближайшего общего родителя.

Давайте переосмыслим нашу архитектуру:

  1. Мы убираем состояние selectedItems из ProductList.

  2. Мы помещаем это состояние в их общего родителя, компонент App.

  3. Теперь App становится «единственным источником правды» (Single Source of Truth) для информации о выбранных товарах.

  4. Компонент App передает это состояние вниз обоим компонентам-потомкам: и ProductList и ShoppingCart.

Но как же ProductList теперь будет изменять это состояние, если оно находится в App? Очень просто! Компонент App будет передавать вниз не только данные, но и функции для их обновления. Это похоже на делегирование полномочий: «Вот тебе данные, сынок и вот функция, которую ты можешь вызвать, когда захочешь эти данные поменять. Но сами данные хранятся у меня».

Давайте посмотрим, как это выглядит в коде.

Общее состояние в родителе

Сейчас мы перепишем наш пример, следуя принципу подъема состояния. Внимательно следите за изменениями.

Шаг 1: Переносим состояние и функции в App

jsx
// Основной компонент приложения ТЕПЕРЬ хранит состояние
function App() {
  // Состояние поднято наверх, в общего родителя!
  const [selectedItems, setSelectedItems] = useState([]);

  // Функция для обновления состояния тоже находится здесь
  const handleToggleProduct = (productId) => {
    setSelectedItems(prev => 
      prev.includes(productId) 
        ? prev.filter(id => id !== productId) 
        : [...prev, productId]
    );
  };

  // Данные о товарах тоже можно хранить здесь или вынести в отдельный файл
  const products = [
    { id: 1, name: 'Ноутбук', price: 1000 },
    { id: 2, name: 'Телефон', price: 500 },
    { id: 3, name: 'Наушники', price: 150 },
  ];

  return (
    <div>
      {/* Шаг 2: Передаем данные и функции потомкам через props */}
      <ProductList 
        products={products}
        selectedItems={selectedItems}
        onToggleProduct={handleToggleProduct}
      />
      <ShoppingCart 
        selectedItems={selectedItems}
        products={products}
      />
    </div>
  );
}

Обратите внимание: App теперь владеет состоянием selectedItems и функцией handleToggleProduct для его изменения.

Шаг 2: Модифицируем ProductList для работы с props

Теперь ProductList становится, так называемым, «управляемым компонентом». Он не имеет собственного состояния, а полностью управляется данными и функциями, пришедшими сверху через props.

jsx
// Компонент списка товаров ТЕПЕРЬ получает все необходимое через props
function ProductList({ products, selectedItems, onToggleProduct }) {
  // Внутреннего состояния больше нет!

  return (
    <div>
      <h2>Список товаров</h2>
      {products.map(product => (
        <div key={product.id}>
          <span>{product.name} - ${product.price}</span>
          {/* Вызываем функцию, переданную из App */}
          <button onClick={() => onToggleProduct(product.id)}>
            {selectedItems.includes(product.id) ? 'Убрать' : 'Добавить'}
          </button>
        </div>
      ))}
    </div>
  );
}

Шаг 3: Модифицируем ShoppingCart для работы с props

Аналогичным образом мы настраиваем корзину. Она получает массив selectedItems и список всех products, чтобы рассчитать общую стоимость и количество.

jsx
// Компонент корзины ТЕПЕРЬ получает выбранные товары через props
function ShoppingCart({ selectedItems, products }) {
  // Внутреннего состояния cartItems больше нет!

  // Рассчитываем общую стоимость на лету
  const totalPrice = selectedItems.reduce((total, itemId) => {
    const product = products.find(p => p.id === itemId);
    return total + (product ? product.price : 0);
  }, 0);

  return (
    <div>
      <h2>Корзина</h2>
      <p>Товаров в корзине: {selectedItems.length}</p>
      <p>Общая стоимость: ${totalPrice}</p>
      <h4>Выбранные товары:</h4>
      <ul>
        {selectedItems.map(itemId => {
          const product = products.find(p => p.id === itemId);
          return <li key={itemId}>{product?.name}</li>;
        })}
      </ul>
    </div>
  );
}

Что мы получили в итоге?

Давайте оценим результат нашего рефакторинга:

  1. Синхронизация. Теперь, когда пользователь нажимает кнопку «Добавить» в ProductList, состояние в App обновляется. Это, в свою очередь, приводит к повторному рендеру обоих компонентов, ProductList и ShoppingCart. Корзина моментально отображает новые данные. Больше никакой рассинхронизации!

  2. Предсказуемость. Данные текут в одном направлении: сверху вниз (от родителя к потомку). Состояние находится в одном месте, что делает приложение гораздо более предсказуемым и легким для отладки.

  3. Меньше дублирования. Мы избавились от потенциального дублирования логики в разных компонентах. Вся бизнес-логика, связанная с выбором товаров, централизована в App.

  4. Переиспользуемость. Компоненты ProductList и ShoppingCart стали более «чистыми» и переиспользуемыми. Они просто отображают то, что им передали и сообщают о событиях наверх. Их можно легко встроить в другую часть приложения, если потребуется, при условии, что родитель будет обеспечивать их данными.

Это и есть суть подъема состояния. Мы превратили два независимых, не общающихся компонента в слаженный дуэт, который работает как единое целое.

Пример с формой с валидацией и динамическим предпросмотром

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

  • BlogForm. Поля для ввода заголовка и содержимого.

  • BlogPreview. Динамический предпросмотр статьи в том виде, в каком она будет отображаться на сайте.

Очевидно, что оба компонента должны работать с одними и теми же данными: текстом заголовка и содержимого.

jsx
// Изначальная, НЕПРАВИЛЬНАЯ реализация
function BlogForm() {
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');

  return (
    <form>
      <input 
        type="text" 
        value={title} 
        onChange={(e) => setTitle(e.target.value)} 
        placeholder="Заголовок" 
      />
      <textarea 
        value={content} 
        onChange={(e) => setContent(e.target.value)} 
        placeholder="Содержимое статьи" 
      />
    </form>
  );
}

function BlogPreview() {
  // Свое состояние, не связанное с BlogForm!
  const [previewTitle, setPreviewTitle] = useState('Пример заголовка');
  const [previewContent, setPreviewContent] = useState('Пример текста...');

  return (
    <div className="preview">
      <h1>{previewTitle}</h1>
      <p>{previewContent}</p>
    </div>
  );
}

function App() {
  return (
    <div>
      <BlogForm />
      <BlogPreview />
    </div>
  );
}

Опять та же проблема! То, что мы вводим в форме, никак не отражается в предпросмотре. Давайте применим подъем состояния.

Рефакторинг с подъемом состояния:

jsx
function App() {
  // Поднимаем состояние формы в общего родителя
  const [formData, setFormData] = useState({
    title: '',
    content: ''
  });

  // Функция для обновления любого поля формы
  const handleInputChange = (fieldName, value) => {
    setFormData(prevData => ({
      ...prevData,
      [fieldName]: value
    }));
  };

  return (
    <div>
      <BlogForm 
        formData={formData} 
        onInputChange={handleInputChange} 
      />
      <BlogPreview 
        title={formData.title} 
        content={formData.content} 
      />
    </div>
  );
}

// BlogForm теперь управляемый
function BlogForm({ formData, onInputChange }) {
  return (
    <form>
      <input 
        type="text" 
        value={formData.title} 
        onChange={(e) => onInputChange('title', e.target.value)} 
        placeholder="Заголовок" 
      />
      <textarea 
        value={formData.content} 
        onChange={(e) => onInputChange('content', e.target.value)} 
        placeholder="Содержимое статьи" 
      />
    </form>
  );
}

// BlogPreview теперь просто отображает переданные props
function BlogPreview({ title, content }) {
  // Если поля пустые, показываем заглушку
  const displayTitle = title || '(Заголовок еще не написан)';
  const displayContent = content || '(Текст статьи еще не написан...)';

  return (
    <div className="preview">
      <h1>{displayTitle}</h1>
      <p>{displayContent}</p>
    </div>
  );
}

Теперь при каждом вводе символа в форме BlogForm состояние в App обновляется, что вызывает повторный рендер и BlogForm (где поля просто получают обновленные значения) и BlogPreview, который моментально показывает введенный текст в виде отформатированной статьи. Мы создали интерактивный, динамический предпросмотр и главный инструмент для этого подъем состояния.

Практические задачи для закрепления

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

Задача 1: Синхронизированные счетчики

Создайте два компонента-счетчика, CounterA и CounterB, которые отображают одно и то же числовое значение. Кнопки «+» и «-» в любом из компонентов должны изменять это общее значение. Оба счетчика должны всегда показывать одно и то же число.

Подсказка. Создайте состояние count в общем родителе (App) и передайте его, а также функции increment и decrement в оба компонента-счетчика.

Задача 2: Фильтрация и отображение списка

Создайте приложение, состоящее из трех компонентов:

  1. SearchBar — поле ввода для поиска.

  2. ProductTable — таблица, отображающая список товаров (массив объектов с полями name и category).

  3. Summary — блок, показывающий общее количество отфильтрованных товаров.

Реализуйте логику так, чтобы ввод текста в SearchBar фильтровал список в ProductTable и одновременно обновлял цифру в Summary.

Подсказка. Состояние searchQuery и исходный массив products должны находиться в AppSearchBar получает searchQuery и функцию setSearchQueryProductTable и Summary получают уже отфильтрованный массив filteredProducts (который вычисляется в App на основе searchQuery и products).

Задача 3: Цветовые палитры

Создайте приложение для подбора цветовой палитры. У вас должно быть:

  • ColorPicker — три поля ввода типа range (ползунки) для выбора значений Red, Green, Blue (от 0 до 255).

  • ColorDisplay — div, который показывает цвет, полученный из этих значений RGB.

  • LuminanceIndicator — компонент, который показывает текстовое значение: «Светлый», если цвет светлый и «Темный», если цвет темный. (Формулу яркости можно упростить: luminance = (0.299 * R + 0.587 * G + 0.114 * B); если luminance > 128, то цвет светлый).

Подсказка. Состояние color (объект { r: 0, g: 0, b: 0 }) живет в AppColorPicker получает функции для обновления rgbColorDisplay и LuminanceIndicator получают текущий объект color.

Заключение

Сегодня вы только что освоили одну из важных тем, это подъем состояния. Это не просто техника, это образ мышления. Всякий раз, когда вы видите, что несколько компонентов должны отражать одни и те же данные, ваш первый импульс должен быть: «Надо поднять это состояние к их общему родителю».

Этот подход является прямым путем к построению предсказуемых, хорошо структурированных приложений с односторонним потоком данных. Он тесно связан с концепцией «единственного источника правды», которая лежит в основе многих state management-решений (включая сам React и более сложные инструменты, как Redux).

В следующих уроках мы посмотрим на другую сторону этой медали. Что делать, когда передача props через множество уровней компонентов становится слишком громоздкой.

Удачи в практике и до встречи в следующем уроке!

Хотите освоить React на практике?
Перейдите к полному курсу с уроками по React для начинающих, где вас ждут 30 подробных уроков.

Поделиться статьей:
Поддержать автора блога

Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.

Персональные рекомендации
Оставить комментарий