Hooks
Hooks
Avoiding useEffect with callback refs
Callback refs
This is where callback refs come into play. If you've ever looked at the https://github.com/DefinitelyTyped/DefinitelyTyped/blob/fc9b16957473f81a1d708e6948b8d61e292aeb58/types/react/v17/index.d.ts > L85, we can see that we can not only pass a ref object into it, but also a function:
type Ref<T> = RefCallback<T> | RefObject<T> | null
And those functions run after rendering, where it is totally fine to execute side effects. Maybe it would have been better if ref would just be called onAfterRender or something.
With that knowledge, what stops us from focusing the input right inside the callback ref, where we have direct access to the node?
<input
ref={(node) => {
node?.focus()
}}
defaultValue="Hello world"
/>
Well, a tiny detail does: React will run this function after every render. So unless we are fine with focusing our input that often (which we are likely not), we have to tell React to only run this when we want to.
useCallback to the rescue
Luckily, React uses referential stability to check if the callback ref should be run or not. That means if we pass the same ref(erence, pun intended) to it, execution will be skipped.
And that is where useCallback comes in, because that is how we ensure a function is not needlessly created. Maybe that's why they are called callback-refs - because you have to wrap them in useCallback all the time. 😂
Here's the final solution:
const ref = React.useCallback((node) => {
node?.focus()
}, [])
return <input ref={ref} defaultValue="Hello world" />
And as shown in this https://reactjs.org/docs/hooks-faq.html > how-can-i-measure-a-dom-node, you can use it to run any sort of side effects, e.g. call setState in it. I'll just leave the example here because it's actually pretty good:
function MeasureExample() {
const [height, setHeight] = React.useState(0)
const measuredRef = React.useCallback(node => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height)
}
}, [])
return (
<>
<h1 ref={measuredRef}>Hello, world</h1>
<h2>The above header is {Math.round(height)}px tall</h2>
</>
)
}
So please, if you need to interact with DOM nodes directly after they rendered, try not to jump to useRef + useEffect directly, but consider using callback refs instead.
useSyncExternalStore
useSyncExternalStore is a hook recommended for reading and subscribing from external data sources in a way that’s compatible with concurrent rendering features like selective hydration and time slicing. This method returns the value of the store and accepts three arguments:
subscribe
: function to register a callback that is called whenever the store changes.getSnapshot
: function that returns the current value of the store.getServerSnapshot
: function that returns the snapshot used during server rendering
Example: online
This beta doc page gives a good example:
function subscribe(callback) {
window.addEventListener("online", callback);
window.addEventListener("offline", callback);
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
}
function useOnlineStatus() {
return useSyncExternalStore(
subscribe,
() => navigator.onLine,
() => true
);
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
Another example: scrollY
There are so many external data sources that we can subscribe to, and implementing your own selector system on top might enable you to optimize React re-renders
// A memoized constant fn prevents unsubscribe/resubscribe
// In practice it is not a big deal
function subscribe(onStoreChange) {
global.window?.addEventListener("scroll", onStoreChange);
return () =>
global.window?.removeEventListener(
"scroll",
onStoreChange
);
}
function useScrollY(selector = (id) => id) {
return useSyncExternalStore(
subscribe,
() => selector(global.window?.scrollY),
() => undefined
);
}
We can now use this hook with an optional selector:
function ScrollY() {
const scrollY = useScrollY();
return <div>{scrollY}</div>;
}
function ScrollYFloored() {
const to = 100;
const scrollYFloored = useScrollY((y) =>
y ? Math.floor(y / to) * to : undefined
);
return <div>{scrollYFloored}</div>;
}
When you don't need a
scrollY
1 pixel precision level, returning a wide range value such asscrollY
can also be considered as over-returning. Consider returning a narrower value.For example: a
useResponsiveBreakpoint()
hook that only returns a limited set of values (small
,medium
orlarge
) will be more optimized than auseViewportWidth()
hook.If a React component only handles
large
screens differently, you can create an even narroweruseIsLargeScreen()
hook returning a boolean.
Чиним мемоизацию с useRef
Представим компонент:
const HeavyComponentMemo = React.memo(HeavyComponent);
const Form = () => {
const [value, setValue] = useState();
const onClick = useCallback(() => {
// submit data here
console.log(value);
// adding value to the dependency
}, [value]);
return (
<>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<HeavyComponentMemo
title="Welcome to the form"
onClick={onClick}
/>
</>
);
};
HeavyComponentMemo
мемоизирован, все его пропсы тоже, но мемоизация не будет работать, потому что onClick
меняется на каждое новое value
из-за зависимостей useCallback
.
Можно попробовать избежать этого, передав кастомную функцию сравнения в React.memo
:
const HeavyComponentMemo = React.memo(
HeavyComponent,
(before, after) => {
return before.title === after.title;
},
);
Но это тоже не сработает, потому что onClick
превратится в stale closure
:
React.memo
сохранит пропсы, которые попадут в него первый раз. В тот момент, value
внутри замыкания onClick
имело значение undefined
. И из-за сохраненных пропсов, оно останется таким навсегда.
С этим можно бороться с помощью useRef
:
const Form = () => {
const [value, setValue] = useState();
const ref = useRef();
useEffect(() => {
ref.current = () => {
// will be latest
console.log(value);
};
});
const onClick = useCallback(() => {
// will be latest
ref.current?.();
}, []);
return (
<>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<HeavyComponentMemo
title="Welcome closures"
onClick={onClick}
/>
</>
);
};
Здесь объект ref
создается один раз, поэтому в useCallback
нет зависимостей, а значит onClick
будет создаваться один раз.
При этом мы будем обновлять value
внутри замыкания ref.current
на каждый рендер, а значит функция будет отрабатывать нормально.
Можно вынести эту логику в хук:
const useCallbackNoDeps = (cb: (value) => void) => {
const ref = useRef<(value) => void>();
useEffect(() => {
ref.current = cb;
});
return useCallback((...args) => {
ref.current?.(...args);
}, []);
};
Он будет похож на экспериментальный реакт хук https://react.dev/learn/separating-events-from-effects > declaring-an-effect-event