Pull to refresh
817.86
OTUS
Цифровые навыки от ведущих экспертов

React hooks, как не выстрелить себе в ноги. Часть 3.2: useMemo, useCallback

Reading time 12 min
Views 40K

Данная статья - продолжение статьи про мемоизацию, в которой мы разбирали, зачем нужно использовать memo и как правильно с ним работать. В этой статье разберем, как правильно использовать useMemo и useCallback, какое у них api и разберем пару трюков. В прошлой статье я писал, что в следующей будет информация про useRef, useImperativeHandle и прочее, но в итоге решил, что текущий материал можно объяснить проще. Только трюки подведут нас к использованию useRef и уже в следующей лекции мы разберемся с ним.

Проблема. Лишняя вычислительная работа

У функциональных компонентов есть проблема - весь код в теле выполняется при каждом обновлении. Изменили состояние компонента, обновился родительский компонент или же изменился пропс - постоянно будет выполняться в весь код внутри.

import React, { FC } from "react";

export type FuncComponentProps = {
  data: Array<unknown>
}

export const FuncComponent: FC<FuncComponentProps> = ({ data }) => {
  // Совершаем какую-то вычислительную работу
  const preparedData = prepareData(data); // (1)
  
  return (
    <div>
    	... какой-то контент
    </div>
	);
};

В компоненте выше, в метке 1 функция prepareData будет выполняться при любом обновлении компонента. Как думаете, какие варианты обновления тут существуют?

  1. Если изменится data, и правда должен произойти пересчет.

  2. Если появится новый пропс.

  3. Если захотим добавить state в компонент, также prepareData будет выполняться при каждом изменении.

  4. Если обновится родительский компонент, наш FuncComponent не завернут в memo, потому тоже обновится и опять же выполнится prepareData.

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

Как использовать useMemo и решить проблему лишней работы

useMemo - это хук, который сохраняет результат вызова функции (первый аргумент) и пересчитывает его только при изменении зависимостей (второй аргумент). useMemo возвращает результат вызова первого аргумента.

Выглядит использование так:

// Типизация
function useMemo<T>(factory: () => T, deps: any[] | undefined): T;

// 1
const memoResult = useMemo(() => funcResult, []);

// 2
let testString = 'test';
const { data } = useMemo<{ data: string }>(() => ({ data: testString }), [testString]);

useMemo - это дженерик, и из него видно - то, что вернет первый аргумент, вернет и сам хук. В первом случае выполнится 1 раз, потому что массив зависимостей пустой. Во втором случае выполнится каждый раз при обновлении компонента и изменении testString из примера.

Обращаю внимание, если принудительно изменить что-то из массива зависимостей, в нашем случае, например: testString = 'new test string' - useMemo не будет изменен до тех пор, пока не обновится сам компонент. Ниже разберем подробнее это заблуждение, оно часто возникает у новичков.

А пока применим useMemo к нашей ситуации.

import React, { useMemo, FC } from "react";

export type FuncComponentProps = {
  data: Array<unknown>
}

export const FuncComponent: FC<FuncComponentProps> = ({ data }) => {
  // Теперь вычислительная работа будет выполнена строго при изменении data
  const preparedData = useMemo(() => prepareData(data), [data]); // (1)
  
  return (
    <div>
    	... какой-то контент
    </div>
	);
};

В useMemo первым аргументом мы передали функцию, которая вызовет prepareData(data) и вернет результат ее вызова. Вторым аргументом мы передали массив зависимостей, в нашем случае зависимость только одна - это data, ее и передаем.

Благодаря этому хуку наш компонент становится устойчивым, нам больше не нужно заботиться о том, чтобы родительский компонент не обновлялся лишний раз и не нужно оборачивать текущий компонент в memo. Также мы можем смело передавать дополнительные props в компонент или добавлять state - prepareData(data) будет вызвана только при изменении data.

Когда нужно использовать useMemo. Мемоизация тяжелых вычислений

useMemo предотвращает лишнюю вычислительную работу, однако его выполнение имеет свою цену. Посмотрите внимательно на этот код.

const memoResult = /* 1 */ useMemo(/* 2 */() => funcResult, /* 3 */ []);

Сам вызов useMemo запускает проверку под капотом react, был ли данный хук зарегистрирован. React регистрирует все хуки, вызываемые в конкретном компоненте, кстати, именно поэтому все хуки должны находиться до выполнения условий, таких как if, switch. Если не зарегистрирован - регистрирует, иначе ищет сохраненные данные хука. К тому же, useMemo клонирует функцию, что в него передали, и замыкание перестает работать, это по большей части нужно для корректной работы useEffect, однако useMemo также выполняет дополнительную работу по клонированию функции (первого аргумента). Также выделяется дополнительная память для создания первого аргумента (см 2 метку), то есть каждый раз перед вызовом useMemo создается новая функция, а также создается новый массив зависимостей (см 3 метку). Также useMemo сравнивает новый и предыдущий массив зависимостей и если произошло изменение, вызывает новую функцию, клонирует ее и сохраняет результат ее вызова, после чего возвращает результат.

Вызов useMemo имеет свою цену и это нужно понимать. Хорошо про цену рассказано тут. Для себя я вывел следующее правило:

Если сложность вычислений больше O(n), я использую useMemo. Если сложность вычислений постоянная или O(log n) - не использую. Про сложность хорошо рассказано тут.

Ниже примеры, когда цена useMemo выше цены вычислений.

const resBad1 = useMemo(() => arg1 === arg2, [arg1, arg2]); // Плохо
const resGood1 = arg1 === arg2; // Хорошо

const resBad2 = useMemo(() => arg1 + arg2 / arg3, [arg1, arg2, arg3]); // Плохо
const resGood2 = arg1 + arg2 / arg3; // Хорошо

const resBad3 = useMemo(() => {
  if (loading) return <Loading />
  if (error) return <Error />
  return <Content />
}, [loading, error]); // Плохо

const resGood3 = (() => {
  if (loading) return <Loading />
  if (error) return <Error />
  return <Content />
})(); // Хорошо. Это вызов анонимной самовызываемой функции

Заблуждение с обновлением зависимостей

import React, { useMemo, FC } from "react";

export type FuncComponentProps = {
  data: Array<unknown>
}

let dep1 = 'some string';

export const FuncComponent: FC<FuncComponentProps> = ({ data }) => {
  const preparedData = useMemo(() => prepareData(dep1), [dep1]); // (1)
  
  return (
    <div onClick={() => (dep1 = 'other string')} /* 2 */>
    	... какой-то контент
    </div>
	);
};

Часто допускают следующую ошибку: чтобы обновить useMemo обновляют значение какой-то зависимости. В примере выше директивно обновляют значение dep1 (см 2 метку). Однако обновления не происходит, как считаете почему, и как это исправить?

Само по себе изменение каких-либо переменных не запускает обновление компонента, соответственно в памяти переменная dep1 и правда изменилась, но компонент не обновился. Можно дождаться обновления компонента, а можно переменную использовать как состояние, тогда все будет работать, как и ожидалось.

import React, { useMemo, FC, useState } from "react";

export type FuncComponentProps = {
  data: Array<unknown>
}

export const FuncComponent: FC<FuncComponentProps> = ({ data }) => {
  const [dep1, setDep1] = useState('some string');
  const preparedData = useMemo(() => prepareData(dep1), [dep1]); // (1)
  
  return (
    <div onClick={() => setDep1('other string')} /* 2 */>
    	... какой-то контент
    </div>
	);
};

Когда нужно использовать useMemo. Мемоизация ссылочных типов данных

В прошлой статье мы разбирали, что memo проверяет все props и предотвращает лишние обновления компонента. Сравните два TargetComponent в метках 1 и 2. Мы используем один и тот же компонент и он обернут в memo. Как считаете какая между ними разница в поведении при обновлении родительского компонента?

import React, { useMemo, FC, memo } from "react";

export type TargetComponentProps = object

export const TargetComponent = memo<TargetComponentProps>(() => {
  return (
    <div>
    	... какой-то контент
    </div>
	);
});

export const FuncComponent: FC = () => {
  return (
    <div>
      // 1
      <TargetComponent string="test" />
      // 2
      <TargetComponent testArray={[]} />
    </div>
  );
};

В первом случае TargetComponent никогда не будет обновляться при обновлении родителя. Во втором случае TargetComponent будет обновляться всегда при обновлении родителя. Почему так происходит?

Дело в том, что memo сравнивает по строгому равно пропсы, которые получает компонент. Соответственно в первом случае "test" === "test" -> true , а во втором случае [] === [] -> false.

Один из способов решения этой проблемы мы разбирали в прошлой лекции. Второй способ - использовать useMemo

import React, { useMemo, FC, memo } from "react";

export type TargetComponentProps = object

export const TargetComponent = memo<TargetComponentProps>(() => {
  return (
    <div>
    	... какой-то контент
    </div>
	);
});

export const FuncComponent: FC = () => {
  const testArray = useMemo(() => [], []);
  const testObj = useMemo(() => ({}), []);
  // 0
  const testFunc = useMemo(() => (arg: number) => ({}), []);
  return (
    <div>
      // 1
      <TargetComponent string="test" />
      // 2
      <TargetComponent
        testArray={testArray}
        testObj={testObj}
        testFunc={testFunc}
      />
    </div>
  );
};

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

Однако обратите внимание на метку номер 0.

const testFunc = useMemo(() => (arg: number) => ({}), []);

Мемоизация функции выглядит некрасиво, можно ли ее как-то упростить? Да, специально для мемоизации функций, в react существует хук useCallback.

useCallback

useCallback под капотом тот же useMemo и по сути является синтаксическим сахаром. Сравните два способа мемоизации функции с useMemo и с useCallback.

const testFunc = useMemo(() => (arg: number) => ({}), []);

const testFunc = useCallback((arg: number) => ({}), []);

А вот так выглядит типизация useCallback

function useCallback<T extends (...args: any[]) => any>(callback: T, deps: any[]): T;

Это и все что нужно знать про useCallback. Далее, чтобы закрепить, поговорим про проблему замыканий в useCallback и разберем способ её решения. Также поговорим про мемоизацию нескольких функций с помощью одного useMemo.

Проблема замыкания в useCallback

Посмотрите этот пример кода.

import React, { FC, useCallback, useState } from "react";

type NameValue = {
  firstName: string;
  lastName: string;
};

type NameInputProps = {
  value: NameValue;
  onChange: (value: NameValue) => void;
};

const NameInput: FC<NameInputProps> = ({ value, onChange }) => {
  const onChangeFirstName = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      console.log(1, value); // 1
      onChange({ ...value, firstName: e.target.value });
    },
    [onChange]
  );

  const onChangeLastName = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      console.log(2, value); // 2
      onChange({ ...value, lastName: e.target.value });
    },
    [onChange]
  );

  return (
    <div>
      <div>firstName</div>
      <input value={value.firstName} onChange={onChangeFirstName} />
      <div>lastName</div>
      <input value={value.lastName} onChange={onChangeLastName} />
    </div>
  );
};

export const ClosureProblemExample = () => {
  const [value, setValue] = useState({} as NameValue);
  return (
    <div>
      <NameInput value={value} onChange={setValue} />
      {JSON.stringify(value)} // 3
    </div>
  );
};

Здесь написан компонент ввода имени, у его значения есть два поля firstName и lastName. Для каждого поля отдельный input и для каждого обработчика onChange инпута отдельный useCallback. Кстати, здесь можно не использовать useCallback, потому что <input />, как и прочие html компоненты, будет обновляться всегда, независимо от просов, но для нашего примера мы все же используем useCallback, потому что для сложных компонентов ввода, аналогов input, нередко нужна мемоизация.

Обратите внимание на useCallback, на массив зависимостей. В нем содержится только onChange, но не содержится value. Можно ожидать, что value попадет в мемоизированную функцию через замыкание. Однако этого не происходит и в результате просто невозможно изменить оба поля. Вот здесь можно поэкспериментировать с этим примером здесь.

Помните, выше описывал цену useMemo и что под капотом он клонирует функцию вместе со всеми замыканиями. Это и приводит к данной проблеме. Как считаете, как можно починить?

Может показаться, что хорошее решение добавить value в массив зависимостей, тем более, что появляется подсказка eslint, требующая именно этого. И действительно, теперь значение изменяется как надо. Однако это плохое решение.

const onChangeFirstName = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      onChange({ ...value, firstName: e.target.value });
    },
    [onChange, value /* <- добавить value - плохое решение */]
  );

Мы хотим, чтобы когда изменяется одна составляющая value, например firstName, инпут ответственный за lastName не обновлялся. Однако value находится в зависимостях у onChangeLastName и эта функция будет обновляться. А так как функция - это ссылочный тип, то и наш input будет получать новую функцию и соответственно тоже будет обновляться. Когда мы использовали useCallback, то хотели улучшить производительность приложения, но добавив value в массив зависимостей мы только ухудшили, потому что по-прежнему оба инпута обновляются, так еще и каждый useCallback выполняет дополнительную работу под капотом.

Пути решения проблемы замыкания - useRef

useCallback не работает с классическими замыканиями, но прекрасно работает с замыканиями, созданными с помощью useRef

const NameInput: FC<NameInputProps> = ({ value, onChange }) => {
  const valueCopy = useRef(value);
  valueCopy.current = value;

  const onChangeFirstName = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      onChange({ ...valueCopy.current, firstName: e.target.value });
    },
    [onChange]
  );

  const onChangeLastName = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      onChange({ ...valueCopy.current, lastName: e.target.value });
    },
    [onChange]
  );

  return (
    <div>
      <div>firstName</div>
      <input value={value.firstName} onChange={onChangeFirstName} />
      <div>lastName</div>
      <input value={value.lastName} onChange={onChangeLastName} />
    </div>
  );
};

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

Пути решения проблемы замыкания - useEvent

useEvent - это кастомный хук, его еще нет в релизах react, однако его предложил сам Дэн Абрамов и, возможно, он появится в обозримом будущем, как официальный хук. На текущем моменте мы можем использовать свой, рукописный вариант, и он тоже будет работать. В нашей компании мы используем такой вариант.

export type Callback = (...args: unknown[]) => unknown;

export const useEvent = <T extends Callback = Callback>(callback: T): T => {
  const copy = useRef<T>();
  copy.current = callback;

  return useCallback<T>(((...args) => copy.current(...args)) as T, []);
};

Более подробно про этот хук можно посмотреть тут.

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

const NameInput: FC<NameInputProps> = ({ value, onChange }) => {
  const onChangeFirstName = useEvent((e: React.ChangeEvent<HTMLInputElement>) => {
      onChange({ ...valueCopy.current, firstName: e.target.value });
  });

  const onChangeLastName = useEvent((e: React.ChangeEvent<HTMLInputElement>) => {
      onChange({ ...valueCopy.current, lastName: e.target.value });
  });

  return (
    <div>
      <div>firstName</div>
      <input value={value.firstName} onChange={onChangeFirstName} />
      <div>lastName</div>
      <input value={value.lastName} onChange={onChangeLastName} />
    </div>
  );
};

Код стал чище и работает ничуть не хуже.

Несколько useCallback превращайте в один useMemo

Вернемся к нашему примеру с NameInput

const NameInput: FC<NameInputProps> = ({ value, onChange }) => {
  const valueCopy = useRef(value);
  valueCopy.current = value;

  const onChangeFirstName = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      onChange({ ...valueCopy.current, firstName: e.target.value });
    },
    [onChange]
  );

  const onChangeLastName = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      onChange({ ...valueCopy.current, lastName: e.target.value });
    },
    [onChange]
  );

  return (
    <div>
      <div>firstName</div>
      <input value={value.firstName} onChange={onChangeFirstName} />
      <div>lastName</div>
      <input value={value.lastName} onChange={onChangeLastName} />
    </div>
  );
};

Когда у нас несколько useCallback их можно превратить в один useMemo

const NameInput: FC<NameInputProps> = ({ value, onChange }) => {
  const valueCopy = useRef(value);
  valueCopy.current = value;

  const { onChangeFirstName, onChangeLastName } = useMemo(
    () => ({
      onChangeFirstName: (e: React.ChangeEvent<HTMLInputElement>) => {
        onChange({ ...valueCopy.current, firstName: e.target.value });
      },
      onChangeLastName: (e: React.ChangeEvent<HTMLInputElement>) => {
        onChange({ ...valueCopy.current, lastName: e.target.value });
      }
    }),
    [onChange]
  );


  return (
    <div>
      <div>firstName</div>
      <input value={value.firstName} onChange={onChangeFirstName} />
      <div>lastName</div>
      <input value={value.lastName} onChange={onChangeLastName} />
    </div>
  );
};

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

Заключение

В этой статье разобрали, как работать с useMemo и useCallback.

useMemo стоит использовать в двух случаях:

  1. Когда в компоненте выполняются сложные вычисления useMemo сохранит их и лишняя работа будет упразднена.

  2. Когда нужно передать ссылочный тип данных в мемоизированный компонент.

useCallback - это синтаксический сахар, обертка на useMemo специально для функций.

И useMemo и useCallback под капотом клонируют функции, переданные первым аргументом вместе с замыканием, потому классическое замыкание перестает работать, но починить это можно с помощью useRef.

В следующей статье разберемся с reference и всеми инструментами для работы с ними: useRefcreateRefforwardRefuseImperativeHandle.

Также хочу пригласить вас на наш курс по react.js в нем помимо прочего разберем как кешировать функции с помощью useMemo и как это позволит нам создавать мемоизированные массивы инпутов. 7 ноября пройдет бесплатный урок курса по теме "Создание быстрых сайтов с Astro.build". Узнать подробнее о курсе и зарегистрироваться на бесплатный урок можно по ссылке ниже.

Tags:
Hubs:
+7
Comments 9
Comments Comments 9

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS