Hooks

Hooks

Avoiding useEffect with callback refs

Link

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 as scrollY 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 (smallmedium or large) will be more optimized than a useViewportWidth() hook.

If a React component only handles large screens differently, you can create an even narrower useIsLargeScreen() 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