Skip to content
This repository has been archived by the owner on Jan 10, 2025. It is now read-only.

[react-hooks] Fix mutating refs during render phase or asynchronously #1813

Merged
merged 5 commits into from
Apr 7, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/react-hooks/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## [Unreleased]

- Added `useIsomorphicLayoutEffect` hook [#1813](https://github.com/Shopify/quilt/pull/1813).
- Updated `useLazyRef` hook implementation to avoid mutating refs directly during the render phase, which is unsafe [#1813](https://github.com/Shopify/quilt/pull/1813).
- Updated `useTimeout` and `useInterval` hooks. Both of these hooks use mutable ref to hold on to the latest callback function. Now updating this ref synchronously to avoid stale callbacks being invoked [#1813](https://github.com/Shopify/quilt/pull/1813).

## [1.12.2] - 2021-03-03

### Fixed
Expand Down
7 changes: 7 additions & 0 deletions packages/react-hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ $ yarn add @shopify/react-hooks
- [useDelayedCallback()](#usedelayedcallback)
- [useForceUpdate()](#useforceupdate)
- [useInterval()](#useinterval)
- [useIsomorphicLayoutEffect()](#useisomorphiclayouteffect)
- [useLazyRef()](#uselazyref)
- [useMedia() & useMediaLayout()](#usemedia--usemedialayout)
- [useMountedRef()](#usemountedref)
Expand Down Expand Up @@ -154,6 +155,12 @@ function MyComponent() {

This is a TypeScript implementation of @gaeron's `useInterval` hook from the [Overreacted blog post](https://overreacted.io/making-setinterval-declarative-with-react-hooks/#just-show-me-the-code).

### `useIsomorphicLayoutEffect()`

This hook is a drop-in replacement for `useLayoutEffect` that can be used safely in a server-side rendered app. It resolves to `useEffect` on the server and `useLayoutEffect` on the client (since `useLayoutEffect` cannot be used in a server-side environment).

Refer to the [`useLayoutEffect` documentation to learn more](https://reactjs.org/docs/hooks-reference.html#uselayouteffect).

### `useLazyRef()`

This hook creates a ref object like React’s `useRef`, but instead of providing it the value directly, you provide a function that returns the value. The first time the hook is run, it will call the function and use the returned value as the initial `ref.current` value. Afterwards, the function is never invoked. You can use this for creating refs to values that are expensive to initialize.
Expand Down
1 change: 1 addition & 0 deletions packages/react-hooks/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export {useTimeout} from './timeout';
export {useToggle} from './toggle';
export {useForceUpdate} from './force-update';
export {useDelayedCallback} from './delayed-callback';
export {useIsomorphicLayoutEffect} from './isomorphic-layout-effect';
5 changes: 4 additions & 1 deletion packages/react-hooks/src/hooks/interval.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {useEffect, useRef} from 'react';

import {useIsomorphicLayoutEffect} from './isomorphic-layout-effect';

type IntervalCallback = () => void;
type IntervalDelay = number | null;

Expand All @@ -11,7 +13,8 @@ type IntervalDelay = number | null;
export function useInterval(callback: IntervalCallback, delay: IntervalDelay) {
const savedCallback = useRef(callback);

useEffect(() => {
// Need to use a layout effect to force the saved callback to be synchronously updated during a commit
useIsomorphicLayoutEffect(() => {
savedCallback.current = callback;
}, [callback]);

Expand Down
14 changes: 14 additions & 0 deletions packages/react-hooks/src/hooks/isomorphic-layout-effect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {useEffect, useLayoutEffect} from 'react';

// https://github.com/facebook/react/blob/master/packages/shared/ExecutionEnvironment.js
const canUseDOM =
typeof window !== 'undefined' &&
typeof window.document !== 'undefined' &&
typeof window.document.createElement !== 'undefined';

/**
* A hook that resolves to useEffect on the server and useLayoutEffect on the client
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this be a server effect for the server?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what you're referring to. Are you talking about react-effect?

This change isn't related to running effects on the server. useEffect does not run in a server-side environment. useLayoutEffect throws a warning when used in a server-side environment, which is why we only invoke it when on the client.

Copy link
Contributor

@dahukish dahukish Apr 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh, words. resolves to misread that. 🙇

*/
export const useIsomorphicLayoutEffect = canUseDOM
? useLayoutEffect
: useEffect;
11 changes: 3 additions & 8 deletions packages/react-hooks/src/hooks/lazy-ref.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import {useRef, MutableRefObject} from 'react';

const UNSET = Symbol('unset');
import {useRef, useState, MutableRefObject} from 'react';

export function useLazyRef<T>(getValue: () => T): MutableRefObject<T> {
const ref = useRef<T | typeof UNSET>(UNSET);

if (ref.current === UNSET) {
ref.current = getValue();
}
const [value] = useState<T>(getValue);
const ref = useRef<T>(value);

return ref as MutableRefObject<T>;
}
5 changes: 4 additions & 1 deletion packages/react-hooks/src/hooks/timeout.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import {useEffect, useRef} from 'react';

import {useIsomorphicLayoutEffect} from './isomorphic-layout-effect';

type IntervalCallback = () => void;
type IntervalDelay = number | null;

export function useTimeout(callback: IntervalCallback, delay: IntervalDelay) {
const savedCallback = useRef(callback);

useEffect(() => {
// Need to use a layout effect to force the saved callback to be synchronously updated during a commit
useIsomorphicLayoutEffect(() => {
savedCallback.current = callback;
}, [callback]);

Expand Down