Проблема устаревших замыканий и способы ее решения в React. Часть 2

Устаревшие замыкания в React: useCallback

В первой части мы реализовали почти то же самое, что делает хук useCallback! Используя useCallback, мы создаем замыкание, а передаваемая в него функция кэшируется:

// эта встроенная функция кэшируется точно так же, как указано в предыдущем разделе
const onClick = useCallback(() => {}, []);

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

const Component = () => {
const [state, setState] = useState();

const onClick = useCallback(() => {
// достп к состоянию внутри
console.log(state);

// нужно добавить это в массив зависимостей
}, [state]);
};

Этот массив зависимостей заставляет React обновлять кэшированное замыкание точно так же, как мы делали при сравнении value !== prevValue. Если мы забудем об этом массиве, замыкание станет устаревшим:

const Component = () => {
const [state, setState] = useState();

const onClick = useCallback(() => {
// здесь state всегда будет начальным значением состояния
// замыкание никогда не обновляется
console.log(state);

// забыли про зависимости
}, []);
};

И каждый раз при запуске этого коллбэка, все, что будет логировано, будет undefined.

Поэкспериментируйте с кодом здесь:

Устаревшие замыкания в React: Ref-атрибуты

Вторым по распространенности способом возникновения проблемы “устаревших” замыканий после хуков useCallback и useMemo являются Ref-атрибуты.

Что произойдет, если использовать Ref для коллбэка onClick вместо хука useCallback? Именно так иногда рекомендуют поступать в интернет-публикациях для мемоизации свойств компонентов. На первый взгляд, это проще: просто передаем функцию в useRef и обращаемся к ней через ref.current. Никаких зависимостей, никаких забот.

const Component = () => {
const ref = useRef(() => {
// обработчик кликов
});

// ref.current сохраняет функцию и демонстрирует стабильность между повторными рендерами
return <HeavyComponent onClick={ref.current} />;
};

Однако каждая функция внутри компонента будет формировать замыкание, в том числе и функция, передаваемая в useRef. Ref будет инициализирован только один раз при создании и никогда не будет обновляться самостоятельно. Это та логика, которую мы создали в самом начале. Только вместо value, мы передаем функцию, которую хотим сохранить. Что-то вроде этого:

const ref = {};

const useRef = (callback) => {
if (!ref.current) {
ref.current = callback;
}

return ref.current;
};

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

const Component = ({ someProp }) => {
const [state, setState] = useState();

const ref = useRef(() => {
// и то, и другое станет устаревшим и не будет меняться
console.log(someProp);
console.log(state);
});
};

Чтобы исправить это, необходимо обеспечить обновление значения ref каждый раз, когда что-то, к чему мы пытаемся получить доступ внутри, изменяется. По сути, нам нужно реализовать подобие функциональности массива зависимостей для хука useCallback.

const Component = ({ someProp }) => {
// инициализация ref - создание замыкания!
const ref = useRef(() => {
// и то, и другое станет устаревшим и не будет меняться
console.log(someProp);
console.log(state);
});

useEffect(() => {
// обновление замыкания при изменения состояния или свойств
ref.current = () => {
console.log(someProp);
console.log(state);
};
}, [state, someProp]);
};

Поэкспериментируйте с кодом здесь:

Устаревшие замыкания в React: React.memo

Мы вернулись к началу статьи и к загадке, с которой все началось. Снова посмотрим на проблемный код:

const HeavyComponentMemo = React.memo(
HeavyComponent,
(before, after) => {
return before.title === after.title;
},
);

const Form = () => {
const [value, setValue] = useState();
const onClick = () => {
// сюда передаем данные формы
console.log(value);
};

return (
<>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<HeavyComponentMemo
title="Welcome to the form"
onClick={onClick}
/>
</>
);
};

Нажимая на кнопку, мы получаем “undefined”. value внутри onClick не обновляется. Можете ли вы теперь сказать, почему это происходит?

Конечно, это устаревшее замыкание. После создания onClick сначала формируется замыкание со значением состояния по умолчанию, т. е. “undefined”. Это замыкание мы передаем мемоизированному компоненту вместе со свойством title. Внутри функции сравнения мы сравниваем только title. Этот элемент не меняется, это просто строка. Функция сравнения всегда возвращает true, HeavyComponent не обновляется, и в результате в нем сохраняется ссылка на самое первое замыкание onClick с замороженным значением “undefined”.

Теперь, когда мы знаем проблему, как ее решить? Здесь проще сказать, чем сделать.

В идеале нужно сравнивать каждое свойство в функции сравнения, поэтому необходимо включить туда onClick:

(before, after) => {
return (
before.title === after.title &&
before.onClick === after.onClick
);
};

Однако в данном случае это означает, что мы просто реализуем поведение React по умолчанию и делаем именно то, что делает React.memo без функции сравнения. Поэтому можно просто отказаться от нее и оставить только React.memo(HeavyComponent).

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

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

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

Выход из ловушки замыканий с помощью Ref-атрибутов

Этот трюк очень прост, но может навсегда изменить то, как вы мемоизируете функции в React.

Избавимся пока от функции сравнения в реализации React.memo и onClick. Оставим только чистый компонент с состоянием и мемоизированный HeavyComponent:

const HeavyComponentMemo = React.memo(HeavyComponent);

const Form = () => {
const [value, setValue] = useState();

return (
<>
<input type="text" value={value} onChange={(e) => setValue(e.target.value)} />
<HeavyComponentMemo title="Welcome to the form" onClick={...} />
</>
);
}

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

Храниться она будет в Ref, поэтому добавим ее туда. Пока что Ref пуст:

const Form = () => {
const [value, setValue] = useState();

// добавление пустого ref
const ref = useRef();
};

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

const Form = () => {
const [value, setValue] = useState();

// добавление пустого ref
const ref = useRef();

useEffect(() => {
// наш колбэк, который нужно вызвать
// с состоянием
ref.current = () => {
console.log(value);
};

// никакого массива зависимостей!
});
};

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

Но мы не можем просто передать ref.current мемоизированному компоненту. Это значение будет меняться при каждом повторном рендеринге, поэтому мемоизация просто не будет работать.

const Form = () => {
const ref = useRef();

useEffect(() => {
ref.current = () => {
console.log(value);
};
});

return (
<>
{/* Невозможно выполнить, мемоизация не сработает */}
<HeavyComponentMemo onClick={ref.current} />
</>
);
};

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

const Form = () => {
const ref = useRef();

useEffect(() => {
ref.current = () => {
console.log(value);
};
});

const onClick = useCallback(() => {
// пустая зависимость! не будет меняться
}, []);

return (
<>
{/* Now memoization will work, onClick never changes */}
<HeavyComponentMemo onClick={onClick} />
</>
);
};

Теперь мемоизация работает идеально  —  onClick не меняется. Но есть одна проблема: мы не получаем никакого результата.

И вот в чем дело: нам нужно лишь вызвать ref.current внутри этого мемоизированного коллбэка.

useEffect(() => {
ref.current = () => {
console.log(value);
};
});

const onClick = useCallback(() => {
// здесь происходит вызов ref
ref.current();

// массив зависимостей все еще пуст!
}, []);

Вы заметили, что ref отсутствует в зависимостях useCallback? Это и не нужно. Сам по себе ref никогда не изменяется. Это просто ссылка на изменяемый объект, который возвращает хук useRef.

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

const a = { value: 'one' };
// b - другая переменная, ссылающаяся на тот же объект
const b = a;

Если изменить объект через одну из ссылок, а затем обратиться к нему через другую, то изменения будут присутствовать:

a.value = 'two';

console.log(b.value); // будет "два" ("two")

В нашем случае этого не происходит: мы имеем абсолютно одинаковую ссылку внутри useCallback и внутри useEffect. Поэтому, если мы меняем свойство current объекта ref внутри useEffect, получаем доступ именно к этому свойству внутри useCallback. Этим свойством оказывается замыкание, в котором фиксируются последние данные о состоянии.

Полный код будет выглядеть следующим образом:

const Form = () => {
const [value, setValue] = useState();
const ref = useRef();

useEffect(() => {
ref.current = () => {
// будет самым свежим
console.log(value);
};
});

const onClick = useCallback(() => {
// будет самым свежим
ref.current?.();
}, []);

return (
<>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<HeavyComponentMemo
title="Welcome closures"
onClick={onClick}
/>
</>
);
};

Теперь у нас идеальная ситуация: тяжелый компонент корректно мемоизирован и его повторный рендеринг не выполняется при каждом изменении состояния. А коллбэк onClick в отношении него получает доступ к свежим данным в компоненте, не нарушая процесс мемоизации. Можно спокойно отправлять все необходимое на бэкенд!

Поэкспериментируйте с исправленным примером здесь:


Надеюсь, вы разобрались с понятием “замыкания”, и оно стало для вас простым и понятным. И все же напомню несколько моментов, которые необходимо учитывать, приступая к работе с замыканиями.

  • Замыкания образуются каждый раз, когда функция создается внутри другой функции.
  • Поскольку React-компоненты  —  это просто функции, каждая функция, созданная внутри них, образует замыкание, включая такие хуки, как useCallback и useRef.
  • При вызове функции, формирующей замыкание, все данные вокруг нее “замораживаются” (принцип снапшота).
  • Чтобы обновить эти данные, необходимо заново создать “замыкающую” функцию. Именно это и позволяют сделать зависимости хуков типа useCallback.
  • Если пропустить зависимость или не обновить замыкающую функцию, присвоенную ref.current, замыкание становится “устаревшим”.
  • Избежать ловушки “устаревшего замыкания” в React можно, воспользовавшись тем, что Ref является изменяемым объектом. Мы можем изменить ref.current вне устаревшего замыкания, а затем обращаться к нему внутри. Это будут самые свежие данные.

Эта статья также доступна в видеоформате.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Nadia Makarevich: Fantastic closures and how to find them in React

Предыдущая статьяRust: рефакторинг для новичков 
Следующая статья10 вопросов, которые помогут нанять лучшего Android-разработчика