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

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

Я создал тематическое исследование из 12 частей, чтобы продемонстрировать распространенные проблемы и способы их решения. Я также собрал React Hooks Radar и React Hooks Checklist для небольших рекомендаций и быстрой справки.

Пример использования: внедрение интервала

Цель состоит в том, чтобы реализовать счетчик, который начинается с 0 и увеличивается каждые 500ms. Должны быть предусмотрены три кнопки управления: start, stop и clear.

Уровень 0: Hello World

export default function Level00() {
  console.log('renderLevel00');
  const [count, setCount] = useState(0);
  return (
    <div>
      count => {count}
      <button onClick={() => setCount(count + 1)}>+</button>
      <button onClick={() => setCount(count - 1)}>-</button>
    </div>
  );
}

Это простой, правильно реализованный счетчик, который увеличивается или уменьшается при нажатии пользователем.

Уровень 1: setInterval

export default function Level01() {
  console.log('renderLevel01');
  const [count, setCount] = useState(0);
  setInterval(() => {
    setCount(count + 1);
  }, 500);
  return <div>count => {count}</div>;
}

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

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

🔗 Справочник по API хуков: useEffect

Уровень 2: useEffect

export default function Level02() {
  console.log('renderLevel02');
  const [count, setCount] = useState(0);
  useEffect(() => {
    setInterval(() => {
      setCount(count + 1);
    }, 500);
  });
  return <div>Level 2: count => {count}</div>;
}

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

🔗 Справочник по API хуков: useEffect, Сроки эффектов.

Уровень 3: беги только один раз

export default function Level03() {
  console.log('renderLevel03');
  const [count, setCount] = useState(0);
  useEffect(() => {
    setInterval(() => {
      setCount(count + 1);
    }, 300);
  }, []);
  return <div>count => {count}</div>;
}

Указание [] в качестве второго аргумента для useEffect вызовет функцию один раз после mount. Несмотря на то, что setInterval вызывается только один раз, этот код реализован неправильно.

count увеличится с 0 до 1 и останется прежним . Стрелочная функция будет создана один раз, и когда это произойдет, count будет 0.

В этом коде есть незначительная утечка ресурсов. Даже после размонтирования компонента setCount все равно будет вызываться.

🔗 Справочник по API хуков: useEffect, Условное срабатывание эффекта.

Уровень 4: очистка

useEffect(() => {
    const interval = setInterval(() => {
      setCount(count + 1);
    }, 300);
    return () => clearInterval(interval);
  }, []);

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

Этот код не имеет утечек ресурсов, но, как и предыдущий, реализован неправильно.

🔗 Справочник по API хуков: Очистка эффекта.

Уровень 5: используйте count как зависимость

useEffect(() => {
  const interval = setInterval(() => {
    setCount(count + 1);
  }, 500);
  return () => clearInterval(interval);
}, [count]);

Если присвоить массиву dependencies useEffect его жизненный цикл изменится. В этом примере useEffect будет вызываться один раз после mount и каждый раз при изменении count. Функция очистки будет вызываться каждый раз, когда count изменяется для удаления предыдущего ресурса.

Этот код работает правильно, без ошибок, но слегка вводит в заблуждение. setInterval создается и удаляется каждые 500ms. Каждый setInterval всегда вызывается один раз.

🔗 Справочник по API хуков: useEffect, Условное срабатывание эффекта.

Уровень 6: setTimeout

useEffect(() => {
  const timeout = setTimeout(() => {
    setCount(count + 1);
  }, 500);
  return () => clearTimeout(timeout);
}, [count]);

Этот код и приведенный выше код работают правильно. Поскольку useEffect вызывается каждый раз при изменении count, использование setTimeout имеет тот же эффект, что и вызов setInterval.

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

Уровень 7: функциональные обновления для useState

useEffect(() => {
  const interval = setInterval(() => {
    setCount(c => c + 1);
  }, 500);
  return () => clearInterval(interval);
}, []);

В предыдущем примере мы запускали useEffect при каждом count изменении. Это было необходимо, потому что нам нужно было всегда иметь актуальное текущее значение.

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

Этот код работает правильно и более эффективно. Мы используем один setInterval в течение жизненного цикла компонента. clearInterval будет вызываться только один раз после размонтирования компонента.

🔗 Справочник по API хуков: useState, Функциональные обновления.

Уровень 8: локальная переменная

export default function Level08() {
  console.log('renderLevel08');
  const [count, setCount] = useState(0);
  let interval = null;
  const start = () => {
    interval = setInterval(() => {
      setCount(c => c + 1);
    }, 500);
  };
  const stop = () => {
    clearInterval(interval);
  };
  return (
    <div>
      count => {count}
      <button onClick={start}>start</button>
      <button onClick={stop}>stop</button>
    </div>
  );
}

Мы добавили кнопки start и stop. Этот код реализован неправильно, кнопка stop не работает. Новая ссылка создается во время каждого render, поэтому stop будет иметь ссылку на null.

🔗 Справочник по API хуков: Есть ли что-нибудь вроде переменных экземпляра?

Уровень 9: useRef

export default function Level09() {
  console.log('renderLevel09');
  const [count, setCount] = useState(0);
  const intervalRef = useRef(null);
  const start = () => {
    intervalRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, 500);
  };
  const stop = () => {
    clearInterval(intervalRef.current);
  };
  return (
    <div>
      count => {count}
      <button onClick={start}>start</button>
      <button onClick={stop}>stop</button>
    </div>
  );
}

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

Этот код кажется правильным, но содержит небольшую ошибку. Если start вызывается несколько раз, setInterval будет вызываться несколько раз, вызывая утечку ресурсов.

🔗 Справочник по API хуков: useRef

Уровень 10: useCallback

export default function Level10() {
  console.log('renderLevel10');
  const [count, setCount] = useState(0);
  const intervalRef = useRef(null);
  const start = () => {
    if (intervalRef.current !== null) {
      return;
    }
    intervalRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, 500);
  };
  const stop = () => {
    if (intervalRef.current === null) {
      return;
    }
    clearInterval(intervalRef.current);
    intervalRef.current = null;
  };
  return (
    <div>
      count => {count}
      <button onClick={start}>start</button>
      <button onClick={stop}>stop</button>
    </div>
  );
}

Чтобы избежать утечки ресурсов, мы просто игнорируем вызовы, если interval уже запущен. Хотя вызов clearInterval(null) не вызывает никаких ошибок, рекомендуется удалять ресурс только один раз.

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

memoization - основной инструмент оптимизации производительности в React. React.memo выполняет поверхностное сравнение, и если ссылки совпадают, рендеринг пропускается.

Если start и stop были переданы компоненту memoized, вся оптимизация не удалась бы, потому что новая ссылка возвращается после каждого рендеринга.

🔗 React Hooks: мемоизация

Уровень 11: useCallback

export default function Level11() {
  console.log('renderLevel11');
  const [count, setCount] = useState(0);
  const intervalRef = useRef(null);
  const start = useCallback(() => {
    if (intervalRef.current !== null) {
      return;
    }
    intervalRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, 500);
  }, []);
  const stop = useCallback(() => {
    if (intervalRef.current === null) {
      return;
    }
    clearInterval(intervalRef.current);
    intervalRef.current = null;
  }, []);
return (
    <div>
      count => {count}
      <button onClick={start}>start</button>
      <button onClick={stop}>stop</button>
    </div>
  );
}

Чтобы React.memo выполняла свою работу должным образом, все, что нам нужно сделать, это сделать memoize функции, используя useCallback ловушку. Таким образом, одна и та же ссылка будет предоставляться после каждого рендеринга.

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

🔗 Справочник по API хуков: useCallback

Уровень 12: кастомный крючок

function useCounter(initialValue, ms) {
  const [count, setCount] = useState(initialValue);
  const intervalRef = useRef(null);
  const start = useCallback(() => {
    if (intervalRef.current !== null) {
      return;
    }
    intervalRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, ms);
  }, []);
  const stop = useCallback(() => {
    if (intervalRef.current === null) {
      return;
    }
    clearInterval(intervalRef.current);
    intervalRef.current = null;
  }, []);
  const reset = useCallback(() => {
    setCount(0);
  }, []);
  return { count, start, stop, reset };
}

Чтобы упростить код, нам нужно инкапсулировать всю сложность внутри useCounter настраиваемого хука и предоставить чистый api: { count, start, stop, reset }.

export default function Level12() {
  console.log('renderLevel12');
  const { count, start, stop, reset } = useCounter(0, 500);
  return (
    <div>
      count => {count}
      <button onClick={start}>start</button>
      <button onClick={stop}>stop</button>
      <button onClick={reset}>reset</button>
    </div>
  );
}

🔗 Справочник по API хуков: Использование пользовательского хука

React Hooks Radar

Все React Hooks равны, но некоторые крючки более равны, чем другие.

✅ зеленый

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

  1. useReducer
  2. useState
  3. useContext

🌕 желтый

Желтые хуки обеспечивают полезную оптимизацию производительности с помощью мемоизации. К управлению жизненным циклом и входными данными следует подходить с осторожностью.

  1. useCallback
  2. useMemo

🔴 красный

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

  1. useRef
  2. useEffect
  3. useLayoutEffect

Контрольный список React Hooks

  1. Соблюдайте Правила крючков.
  2. Не выполняйте никаких побочных эффектов в основной функции рендеринга.
  3. Отписаться / утилизировать / уничтожить все используемые ресурсы.
  4. Предпочитайте useReducer или функциональные обновления для useState, чтобы предотвратить чтение и запись одного и того же значения в ловушку.
  5. Не используйте изменяемые переменные внутри функции рендеринга, вместо этого используйте useRef.
  6. Если то, что вы сохраняете в useRef, имеет меньший жизненный цикл, чем сам компонент, не забудьте сбросить значение при утилизации ресурса.
  7. Будьте осторожны с бесконечной рекурсией и нехваткой ресурсов.
  8. Запомните функции и объекты, когда это необходимо для повышения производительности.
  9. Правильно фиксируйте входные зависимости (undefined = ›каждый рендер, [a, b] =› при изменении a или b, [] = ›только один раз).
  10. Используйте таможенные хуки для нетривиальных случаев использования.

Репозиторий GitHub



Подробнее