Meet the new hook useSyncExternalStore, introduced in React 18 for external stores

0
76
Saeloun Logo


Before diving into the useSyncExternalStore API
let us get familiar with the terms
which would be useful to understand the new hook.

Concurrent rendering and startTransition API

Concurrency is the mechanism to execute multiple tasks simultaneously
by prioritizing the tasks.
This concept is explained in an easy way by
Dan Abramov
with an analogy of phone calls.

We can opt-in to keep the app responsive while rendering
with the help of the startTransition API.
In other words,
React can now render with pauses.
This allows browsers to handle events in between.

Check out more details on
the startTransition API, which we have written in our previous post.

External store

The external store is something which we can subscribe to.
Examples of the external store include
Redux store, Zustand store, global variables,
module scope variables, DOM state, etc.

Internal stores

Internal stores include props, context, useState, useReducer.

Tearing

Tearing refers to visual inconsistency.
It means that a UI shows multiple values for the same state.

Before React 18, this issue did not come up.
But in React 18, concurrent rendering makes this issue possible
because React pauses during rendering.
Between these pauses, updates can pull in the changes
related to the data being used to render.
It causes the UI to show two different values for the same data.

Let us consider the example mentioned in the
WG discussion of tearing.

Here, a component needs to access some external store to get the color.

With synchronous rendering, the color rendered on UI is consistent.


In concurrent rendering, initially, the color fetched is blue.
React yields, and the store gets updated to red.
React continues rendering with the updated value red.
It causes inconsistency in UI, which is known as ‘tearing’.


To fix this issue,
the React team added useMutableSource hook
to safely and efficiently read from a mutable external source.
But, members of the working group
reported
flaws with the existing API contract
that make it difficult for library maintainers to adopt useMutableSource in their implementations.
After a lot of discussions,
the useMutableSource hook was redesigned and renamed to useSyncExternalStore.

Understanding useSyncExternalStore hook

The new useSyncExternalStore hook
available in React 18
allows to properly subscribe to values in stores.

To help simplify the migration, React provides a new package use-sync-external-store.
This package has a shim that works with any React, which has support for hooks.

import {useSyncExternalStore} from 'react';

  or

// Backwards compatible shim
import {useSyncExternalStore} from 'use-sync-external-store/shim';

//Basic usage. getSnapshot must return a cached/memoized result
useSyncExternalStore(
  subscribe: (callback) => Unsubscribe
  getSnapshot: () => State
) => State

// Selecting a specific field using an inline getSnapshot
const selectedField = useSyncExternalStore(store.subscribe, () => store.getSnapshot().selectedField);

useSyncExternalStore hook takes two functions

  • ‘subscribe’ function to register a callback function
  • ‘getSnapshot’ is used to check if the subscribed value has changed
    since the last time, it was rendered,
    It either needs to be an immutable value like a string or number,
    or it needs to be a cached/memoized object.
    The immutable value is then returned by the hook.

A version of the API with automatic support for memoizing the result of getSnapshot:

import {useSyncExternalStoreWithSelector} from 'use-sync-external-store/with-selector';

const selection = useSyncExternalStoreWithSelector(
  store.subscribe,
  store.getSnapshot,
  getServerSnapshot,
  selector,
  isEqual
);

Let us check out the example discussed
in a
React 18 for External Store Libraries talk
by
Daishi Kato.

import React, { useState, useEffect, useCallback, startTransition } from "react";

// library code

const createStore = (initialState) => {
  let state = initialState;
  const getState = () => state;
  const listeners = new Set();
  const setState = (fn) => {
    state = fn(state);
    listeners.forEach((l) => l());
  }
  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  }
  return {getState, setState, subscribe}
}

const useStore = (store, selector) => {
  const [state, setState] = useState(() => selector(store.getState()));
  useEffect(() => {
    const callback = () => setState(selector(store.getState()));
    const unsubscribe = store.subscribe(callback);
    callback();
    return unsubscribe;
  }, [store, selector]);
  return state;
}

//Application code

const store = createStore({count: 0, text: 'hello'});

const Counter = () => {
  const count = useStore(store, useCallback((state) => state.count, []));
  const inc = () => {
    store.setState((prev) => ({...prev, count: prev.count + 1}))
  }
  return (
    <div>
      {count} <button onClick={inc}>+1</button>
    </div>
  );
}

const TextBox = () => {
  const text = useStore(store, useCallback((state) => state.text, []));
  const setText = (event) => {
    store.setState((prev) => ({...prev, text: event.target.value}))
  }
  return (
    <div>
      <input value={text} onChange={setText} className='full-width'/>
    </div>
  );
}

const App = () => {
  return(
    <div className='container'>
      <Counter />
      <Counter />
      <TextBox />
      <TextBox />
    </div>
  )
}

If we use startTransition somewhere in the code,
it may lead to tearing.
To fix the tearing issue we can now use the useSyncExternalStore API.

Let us modify the useStore hook of the library
to use useSyncExternalStore
instead of the useEffect and useState hooks.

import { useSyncExternalStore } from 'react';

const useStore = (store, selector) => {
  return useSyncExternalStore(
    store.subscribe,
    useCallback(() => selector(store.getState(), [store, selector]))
  )
}

The code looks clean, maintainable, and safe with the new hook.
Migration to the useSyncExternalStore hook in external stores
is easy and recommended to avoid any potential issues.

What kinds of libraries are affected by concurrent rendering?
  • Libraries that have components and custom hooks which don’t access external mutable data
    while rendering and only pass information using React props, state or context,
    are not affected.

  • The libraries which deal
    with data fetching, state management, or styling (Redux, MobX, Relay) are affected.
    It is because these libraries store their state outside of React.
    With concurrent rendering,
    those data stores can be updated in the middle of rendering,
    without React knowing about it.

To know more about the useSyncExternalStore hook,
read through the list of links we have compiled –





Source link

Leave a reply

Please enter your comment!
Please enter your name here