React.js - довольно простая в понимании и относительно легкая в освоении библиотека, особенно учитывая широкий выбор материалов, призванных помочь новичкам. Но каждый инструмент имеет свою долю уловок и проблем, которые обычно эти материалы не затрагивают. Вы сталкиваетесь с ними, когда кто-то проверяет ваш код или, того хуже, когда вы предстаете лицом к лицу с проблемой и отчаянно пытаетесь найти решение. Надеюсь, в этой статье я смогу раскрыть некоторые из этих моментов в React и, может, даже удивить вас!

  1. Изменение состояния с использованием предыдущего может быть непредсказуемым

    Управление состоянием лежит в основе React и, в то время как useState является самым распространенным хуком, возможно, существует некоторая неосведомленность о его реальном поведении.

    Давайте взглянем на следующий компонент:

    import React, { useState } from "react";
    import "./styles.css";
    
    export default function App() {
      const [counter, setCounter] = useState(0);
      return (
        <div className="App">
          <h1>Counter: {counter}</h1>
          <button
            onClick={() => {
              setCounter(counter + 1);
              setCounter(counter + 1);
            }}
          >
            +
          </button>
        </div>
      );
    }

    Какое значение счетчика вы ожидаете увидеть после того, как пользователь нажмет на кнопку?

    • 2

    • 1

    Не уверены? Давайте посмотрим:

    Что ж, знали ли вы правильный ответ или угадали, или просто решили нажать на кнопку, правильный ответ - 1. Значение счетчика будет равняться единице.

    Это происходит по той причине, что при обновлении нашего стейта используется предыдущая его версия: setCounter(count + 1). По сути, функция изменения состояния завернута в замыкание функционального компонента, так что предыдущее значение состояние берется из этого замыкания. Это означает, что когда функция изменения состояния будет вызвана (сеттеры состояния асинхронны), она может удерживать фактически устаревшую версию стейта. Кроме того, последовательное выполнение нескольких setState подряд может привести к тому, что алгоритмы планирования React обработают несколько очень быстрых обновлений состояния с использованием одного и того же обработчика событий.

    Та же самая проблема может проявиться при вызове setState внутри асинхронной функции.

    onClick={() => {
      setTimout(() => { setCounter(counter + 1); ), 1000);
    }};

    Однако, не беспокойтесь, React предоставляет простое решение этой проблемы - functional updates.

    setCounter((prevCounter) => prevCounter + 1);

    Вместо того, чтобы передавать значение напрямую в setCounter, мы передаем функцию. Эта функция принимает в качестве параметра предыдущее состояние.

    В этом случае мы можем быть уверены, что React предоставляет нам актуальную версию стейта, что позволяет избежать сценариев с непредсказуемым поведением.

    import React, { useState } from "react";
    import "./styles.css";
    
    export default function App() {
      const [counter, setCounter] = useState(0);
      return (
        <div className="App">
          <h1>Counter: {counter}</h1>
          <button
            onClick={() => {
              setCounter((prevCounter) => prevCounter + 1);
              setCounter((prevCounter) => prevCounter + 1);
            }}
          >
            +
          </button>
        </div>
      );
    }
    

    Всякий раз, когда вы изменяете состояние, опираясь на предыдущую его версию, убедитесь в том, что используете functional updates.

    Еще одно преимущество этого подхода заключается в том, что он позволяет осуществлять корректную мемоизацию функций, поскольку при передаче значения в качестве зависимости при каждом обновлении состояния функция будет пересоздаваться.

    // incorrect
    const update = useCallback(() => {
       setCounter(counter + 1);
    }, [counter]);
    
    // correct
    const update = useCallback(() => {
       setCounter(prevCounter => prevCounter + 1);
    }, []);
  2. Вы можете использовать useRef для хранения статичных переменных

    Мы привыкли использовать механизм ref в React для того, чтобы получить доступ к элементу DOM, будь то необходимость вычислить его размер, установить состояние фокуса или сделать что угодно еще, чего не может React сам по себе. Однако, ref может быть использован и для другой цели - чего-то, чего мы можем достичь без лишних усилий с помощью классов, но не можем с функциональными компонентами - хранить статическую переменную, которая не будет воссоздаваться при каждой перерисовке.

    Когда это может пригодиться? Это зависит от ситуации. Например, давайте представим, что мы используем некую JavaScript-библиотеку, которая была написана не для React.

    import React, { useState, useRef } from "react";
    import "./styles.css";
    
    class Dog {
      setName(name) {
        this.name = name;
      }
    
      bark() {
        alert("Woof! I'm " + this.name);
      }
    }
    
    export default function App() {
      const dogRef = useRef(new Dog());
      const [, setCount] = useState(0);
      const simpleVariable = new Dog();
    
      return (
        <div className="App">
          <div>
            <h1 style={{ background: "yellow" }}>???? No Ref:</h1>
            <button
              onClick={() => {
                simpleVariable.setName("Oliver");
                setCount((prevCount) => prevCount + 1);
              }}
              style={{ marginRight: 10 }}
            >
              First Click Here
            </button>
            then
            <button
              style={{ marginLeft: 10 }}
              onClick={() => simpleVariable.bark()}
            >
              Bark
            </button>
          </div>
    
          <div>
            <h1 style={{ background: "yellow" }}>???? Using Ref:</h1>
            <button
              onClick={() => {
                dogRef.current?.setName("Oliver");
                setCount((prevCount) => prevCount + 1);
              }}
              style={{ marginRight: 10 }}
            >
              First Click Here
            </button>
            then
            <button
              style={{ marginLeft: 10 }}
              onClick={() => dogRef.current?.bark()}
            >
              Bark
            </button>
          </div>
        </div>
      );
    }

    В этом примере у нас есть класс Dog. У него есть две функции - одна для установки имени экземпляра, другая для того, чтобы заставить его полаять.

    Если мы создадим экземпляр класса Dog с использованием обычной переменной, то при перерисовке компонента экземпляр будет создан заново. Так что, все изменения, которые мы успели сделать, пропадут.

    Однако, используя ref, мы можем оставлять наш экземпляр живым, пока мы сами не решим установить ему новое значение посредством перезаписи ref.current.

    someRef.current = newValue;

  3. React можно принудить перемонтировать компонент

    Изменение DOM - одно из самых ресурсоемких действий из тех, которые могут быть произведены с помощь React. Поэтому, мы обычно не хотим перемонтировать компоненты, пока это действительно не понадобится. Увы, иногда мы все же должны это делать по разным причинам. Итак, как в этом случае мы скажем React размонтировать и немедленно монтировать компонент снова? С помощью простой хитрости - предоставлением ключа (key) нашему компоненту и изменением его значения.

    key - это специальный атрибут, о котором вы скорее всего узнали, когда первый раз попытались отрендерить массив компонентов.

    Популярная консольная ошибка в React
    Популярная консольная ошибка в React

    key - это нечто, помогающее React отслеживать элементы, даже если мы изменяем их позицию в структуре компонента или перерисовываем родителя (иначе каждая отрисовка вызывает перемонтирование всего массива компонентов, что очень плохо с точки зрения производительности).

    Используя этот механизм, мы можем обмануть React, заставив его думать, что компонент отличается от своей предыдущей версии, тем самым вызвав перемонтирование.

    Убедитесь в этом сами:

    import React, { useEffect, useState, useCallback } from "react";
    import "./styles.css";
    
    export default function App() {
      const [key, setKey] = useState(1);
      const [console, setConsole] = useState([]);
    
      const onLifecycleChange = useCallback((message) => {
        setConsole((prevConsole) => [message, ...prevConsole]);
      }, []);
      return (
        <div className="App">
          <button
            onClick={() => {
              setKey((oldKey) => oldKey + 1);
            }}
          >
            Remount
          </button>
          <ChildComp key={key} onLifecycleChange={onLifecycleChange} />
    
          <div className="console">
            {console.map((text, i) => (
              <div key={i}>{text}</div>
            ))}
          </div>
        </div>
      );
    }
    
    const ChildComp = React.memo(({ onLifecycleChange }) => {
      useEffect(() => {
        onLifecycleChange("mounting ChildComp");
        return () => {
          onLifecycleChange("ummounting ChildComp");
        };
      }, [onLifecycleChange]);
    
      return <div style={{ marginTop: 10 }}>Child Comp</div>;
    });

    Кстати, зачем вообще это может пригодиться? Что ж, это быстрый способ создания кнопки перезагрузки вашего приложения, например. Другой пример - это реакция состояния компонента React на изменения, внесенные в DOM извне. Но этого все равно следует избегать, пока это не стало последней надеждой.

  4. Контекст работает не так, как вы ожидаете

    Context API - это великолепное встроенное решение для распространения состояния между компонентами, позволяющее избежать запутанной передачи пропсов на несколько уровней ниже от одного к другому.

    Это полезный инструмент, но он имеет свои ограничения. Он неэффективен в использовании для частых или сложных обновлений.

    Цитирую Себастьяна Маркбеджа, инженера в Facebook:

    По моему мнению, использование нового Context релевантно в случаях с нечастыми обновлениями (например, смена локали или темы оформления). Он также хорошо подойдет для случаев, где применялся старый Context - для статических переменных, а обновления распространять через подписки. Новый Context не готов стать основным способом распространения состояния.

    Даже команде React-Redux пришлось откатить части библиотеки, переписанные с использованием Context API в версии 6, по причине значительного снижения производительности по сравнению с предыдущей версией (сейчас React-Redux использует Context только для передачи экземпляра store).

    Еще одна важная проблема заключается в отсутствии возможности компонентов подписываться только на часть контекста (когда значение контекста является объектом).

    Давайте взглянем на следующий пример:

    Обновление любой части контекста вызывает ререндеринг всех подписанных компонентов
    Обновление любой части контекста вызывает ререндеринг всех подписанных компонентов

    В этом примере контекст хранит объект, состоящий из двух записей: name и age (имя и возраст). Мы имеем два компонента, подписанных на этот контекст. Один из них использует только age, другой - name. Однако, при обновлении только одной из записей, перерисовывается и компонент <Name>, и компонент <Age>.

    Вы можете перейти к демо по этой ссылке, чтобы увидеть все своими глазами.

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

    Существуют некоторые нативные решения этой проблемы, но почти все из них громоздкие и сложные. Но вы также можете рассмотреть вариант использования сторонней библиотеки, такой как use-context-selector, которая значительно упрощает эффективную работу с контекстом.

  5. React имеет целое API для работы с prop.children

    Передача дочерних компонентов - очень распространенный шаблон, независимо от того, используете ли вы композицию или просто пишите переиспользуемый компонент-обертку.

    Однако, знали ли вы, что React предоставляет не только возможность просто отрендерить переданный в качестве дочернего компонент, но и целое API, позволяющее сделать с ним практически все, что вы пожелаете.

    Прежде всего, children может быть строкой, объектом или массивом элементов этих типов. Это усложняет процесс итерирования этого свойства, поэтому для начала мы можем воспользоваться методом React.Children.toArray, чтобы преобразовать children к массиву.

    React.Children.toArray(children)
    
    // Если вы хотите использовать map/forEach:
    React.Children.map(children, fn)
    React.Children.forEach(children, fn)

    Мы также можем узнать количество переданных дочерних элементов, избегая тех же проблем:

    React.Children.count(children)

    Если вам нужно принудить ваш компонент принимать только один дочерний (я недавно заметил, что formik делает так), вы можете просто вставить следующую строку кода в ваш компонент, и React выполнит проверку и обработку ошибок для вас:

    React.Children.only(children)

    Вы можете зайти в песочницу, чтобы поиграться с этим.

    И на этом всё. Я надеюсь, что что-то из прочитанного оказалось полезным для вас. Пожалуйста, будьте открыты к тому, чтобы поделиться в комментариях любыми другими уловками и хитростями в React, которые знакомы вам, но упущены в этой статье.

Комментарии (4)


  1. Alexandroppolus
    17.09.2021 17:26
    +6

    Во втором поинте строка const dogRef = useRef(new Dog()); не совсем хорошая - будет каждый раз вызываться конструктор, хотя сохранится только первый экземпляр. Обычно делают const [dogRef] = useState(() => new Dog());, либо пишут подобный хук-обертку над useRef, чтобы мог так же принимать функцию. useMemo для подобного не подходит, это ненадежное хранилище, если верить доке.

    Трюк с key применял (чтобы предотвратить ненужную анимацию). По сути, поинты 2 и 3 - это просто пример использования key и useRef, чуть менее банальный чем обычно.

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


    1. SeokkySss Автор
      17.09.2021 17:30

      Спасибо за интересные замечания, дополняющие оригинальную статью!


  1. alexesDev
    17.09.2021 18:32

    Да, перечитывание FAQ в документации часто удивляет...


  1. Sadler
    18.09.2021 17:37

    Я до сих пор не вижу области применимости useRef для хранения произвольных данных. Когда это может быть оправданно вместо useState или mobx storage? То есть, инстанс класса я ведь могу запихнуть и в любой другой стор, зачем абузить useRef?

    Про api с Children, действительно, не знал, использовал typescript, чтобы ограничить число дочерних компонентов или заранее знать, пришёл массив или нет. Наверное, с api должно быть удобнее, спасибо.