How useSyncExternalStore() works internally in React?

useSyncExternalStore() is a React Hook that lets you subscribe to an external store, I’ve never used this hook before but it seems to be quite useful since it is pretty common for us to interact with Web API that has its own state, like localStorage .etc.

1. Understand why it is needed with a demo.

Let’s start with a demo borrowed from React.dev, but with part of the code rewritten.

Below is a demo that shows online status of your browser right now, if you turn off your network, you’ll see the status getting updated accordingly.

import { useOnlineStatus } from './useOnlineStatus.js';

function StatusBar() {
  const isOnline = useOnlineStatus();
  return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

function SaveButton() {
  const isOnline = useOnlineStatus();

  function handleSaveClick() {
    console.log('✅ Progress saved');
  }

  return (
    <button disabled={!isOnline} onClick={handleSaveClick}>
      {isOnline ? 'Save progress' : 'Reconnecting...'}
    </button>
  );
}

export default function App() {
  return (
    <>
      <p>Turn on & off your network to see the status changing</p>
      <StatusBar />
    </>
  );
}

I’ve created my own useOnlineStatus as below.

import { useEffect, useCallback, useState } from 'react';
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine)
const update = useCallback(() => {
setIsOnline(navigator.onLine)
}, [])
useEffect(() => {
window.addEventListener('online', update);
window.addEventListener('offline', update);
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
}
}, [update])
return isOnline;
}
import { useEffect, useCallback, useState } from 'react';
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine)
const update = useCallback(() => {
setIsOnline(navigator.onLine)
}, [])
useEffect(() => {
window.addEventListener('online', update);
window.addEventListener('offline', update);
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
}
}, [update])
return isOnline;
}

The code looks fine but actually it is not solid. The core issue is that the external store might be updated anytime, so it might have changed between useState() and useEffect(), for our code in this case we are unable to detect the change since event handlers are registered later that it.

Switching it to a sooner effect such as useLayoutEffect() or useInsertionEffect() doesn’t solve the issue since we don’t know anything about the external store.

In order to address issue, we must make sure that event handlers are registered no later than the first retrieval of the external store, or check one more time after the event handlers are registered, like below.

import { useEffect, useCallback, useState } from 'react';
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine)
const update = useCallback(() => {
setIsOnline(navigator.onLine)
}, [])
useEffect(() => {
window.addEventListener('online', update);
window.addEventListener('offline', update);
// check one more time since the external store might have changed
update()
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
}
}, [update])
return isOnline;
}
import { useEffect, useCallback, useState } from 'react';
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine)
const update = useCallback(() => {
setIsOnline(navigator.onLine)
}, [])
useEffect(() => {
window.addEventListener('online', update);
window.addEventListener('offline', update);
// check one more time since the external store might have changed
update()
return () => {
window.removeEventListener('online', update);
window.removeEventListener('offline', update);
}
}, [update])
return isOnline;
}

Let’s modify it a bit better to remove duplicate code.

import { useEffect, useCallback, useState } from 'react';
function getIsOnLine() {
return navigator.onLine
}
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
callback()
----------

This is important to make sure the latest data is retrieved

return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
}
}
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(getIsOnLine())
const update = useCallback(() => {
setIsOnline(getIsOnLine())
}, [])
useEffect(() => {
return subscribe(update);
}, [update])
return isOnline;
}
import { useEffect, useCallback, useState } from 'react';
function getIsOnLine() {
return navigator.onLine
}
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
callback()
----------

This is important to make sure the latest data is retrieved

return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
}
}
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(getIsOnLine())
const update = useCallback(() => {
setIsOnline(getIsOnLine())
}, [])
useEffect(() => {
return subscribe(update);
}, [update])
return isOnline;
}

We can even make it even closer to useSyncExternalStore().

import { useEffect, useCallback, useState } from 'react';
function getIsOnLine() {
return navigator.onLine
}
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
}
}
function useSyncExternalStore(subscribe, getSnapshot) {
const [data, setData] = useState(getSnapshot())
const update = useCallback(() => {
setData(getSnapshot())
}, [])
useEffect(() => {
update()
return subscribe(update);
}, [update])
return data
}
export function useOnlineStatus() {
const isOnline = useSyncExternalStore(subscribe, getIsOnLine)
return isOnline;
}
import { useEffect, useCallback, useState } from 'react';
function getIsOnLine() {
return navigator.onLine
}
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
}
}
function useSyncExternalStore(subscribe, getSnapshot) {
const [data, setData] = useState(getSnapshot())
const update = useCallback(() => {
setData(getSnapshot())
}, [])
useEffect(() => {
update()
return subscribe(update);
}, [update])
return data
}
export function useOnlineStatus() {
const isOnline = useSyncExternalStore(subscribe, getIsOnLine)
return isOnline;
}
import { useOnlineStatus } from './useOnlineStatus.js';

function StatusBar() {
  const isOnline = useOnlineStatus();
  return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}

export default function App() {
  return (
    <>
      <p>Turn on & off your network to see the status changing</p>
      <StatusBar />
    </>
  );
}

We can see that the tricky part of syncing external store is to make sure every update externally gets detected, and our solution is make sure there is an update after event listeners are registered.

But still our implementation has caveat - that is the issue of tearing.

1.1 Issue of Tearing

React team has done an excellent explanation of tearing, here I’ll just briefly summarize.

Recall we mentioned in how does useTransition() work internally in React that, render phase in concurrent mode could be interrupted and resumed based on the priorities of tasks. So while React is rendering the React tree, there is a high chance that the process is interrupted and broken into different tries with async scheduling.

Because the scheduling is not synchronous, as we’ve talked about this in how React scheduler works, we can basically think it is a better setTimeout() with no minimum delay. So if the external store changes during the rendering, it is possible that on different parts of the UI we see different data being committed in the end. Thus Tearing happens, below is an example.

import { useEffect, startTransition, useCallback, useState } from 'react';

let data = 1
function getData() {
  return data
}

setTimeout(() => data = 2, 100)

function Cell() {
  let start = Date.now()
  while (Date.now() - start < 50) {
    // force yielding to main thread in concurrent mode
  }
  const data = getData()
  return <div style={{padding: '1rem', border: '1px solid red'}}>{data}</div>
}

export default function App() {
  const [showCells, setShowCells] = useState(false)

  useEffect(() => {
    startTransition(() => setShowCells(true))
  }, [])
  return (
    <>
      <p>Example of tearing. <br/>below are multiple cells rendering same external data which changes during rendering.</p>
      {showCells ? <div style={{display: 'flex', gap: '1rem'}}>
        <Cell/><Cell/><Cell/><Cell/>
      </div> : <p>preparing..</p>}
    </>
  );
}

Tearing happens out of the possibility of rendering under concurrent mode being interrupted. (remove the startTransition() in above code and tearing doesn’t reproduce), there is nothing we can do since React doesn’t offer us hack into the internal Fiber Architecture.

That’s why React offers us useSyncExternalStore().

Below is a demo with slight fix of above demo but with useSyncExternalStore().

import { useEffect, useSyncExternalStore, startTransition, useCallback, useState } from 'react';

let data = 1
function getData() {
  return data
}

setTimeout(() => data = 2, 100)

function Cell() {
  let start = Date.now()
  while (Date.now() - start < 50) {
    // force yielding to main thread in concurrent mode
  }
  const data = useSyncExternalStore(() => {return () => {}},getData);
  return <div style={{padding: '1rem', border: '1px solid red'}}>{data}</div>
}

export default function App() {
  const [showCells, setShowCells] = useState(false)

  useEffect(() => {
    startTransition(() => setShowCells(true))
  }, [])
  return (
    <>
      <p>Example of tearing. <br/>below are multiple cells rendering same external data which changes during rendering.</p>
      {showCells ? <div style={{display: 'flex', gap: '1rem'}}>
        <Cell/><Cell/><Cell/><Cell/>
      </div> : <p>preparing..</p>}
    </>
  );
}

We can see that Tearing is fixed.

2. How useSyncExternalStore() works internally in React

So to summarize, useSyncExternalStore() does 2 things for us.

  1. makes sure all changes from external store are detected
  2. makes sure same data is rendered for same store on UI, under concurrent mode.

Now let’s dive into the React internals to see how it is done.

2.1 mountSyncExternalStore() in initial mount.

function mountSyncExternalStore<T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T,
): T {
const fiber = currentlyRenderingFiber;
const hook = mountWorkInProgressHook();
------------------------------------

create a new hook

let nextSnapshot;
const isHydrating = getIsHydrating();
if (isHydrating) {
...
} else {
nextSnapshot = getSnapshot();
----------------------------

retrieve the data once, just like we did in initializer of useState()

// Unless we're rendering a blocking lane, schedule a consistency check.
------------------------------------------------------------------------
// Right before committing, we will walk the tree and check if any of the
------------------------------------------------------------------------
// stores were mutated.
------------------------
//
// We won't do this if we're hydrating server-rendered content, because if
// the content is stale, it's already visible anyway. Instead we'll patch
// it up in a passive effect.
const root: FiberRoot | null = getWorkInProgressRoot();
if (!includesBlockingLane(root, renderLanes)) {
pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
}

This part is important, it schedules a check right before committing

to see if external data has changed. It only applies in non-blocking lane,

which basically means it is in concurrent mode

This tries to address the issue of tearing mentioned above.

}
// Read the current snapshot from the store on every render. This breaks the
// normal rules of React, and only works because store updates are
// always synchronous.
hook.memoizedState = nextSnapshot;

This hook just holds the data

same to useState() in our own implementation

const inst: StoreInstance<T> = {
value: nextSnapshot,
getSnapshot,
};
hook.queue = inst;
----------

This looks similar to the update queue we talked about

in How does useState() work internally in React?

but actually it just borrows field name

// Schedule an effect to subscribe to the store.
mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]);
----------------------------

Great, mountEffect() is the internal of useEffect(), so

it is similar to what we do, scheduling a (passive) effect to init subscription

// Schedule an effect to update the mutable instance fields. We will update
---------------------------------------------------------------------------
// this whenever subscribe, getSnapshot, or value changes. Because there's no
----------------------------------------------------------
// clean-up function, and we track the deps correctly, we can call pushEffect
// directly, without storing any additional state. For the same reason, we
// don't need to set a static flag, either.
// TODO: We can move this to the passive phase once we add a pre-commit
// consistency check. See the next comment.
fiber.flags |= PassiveEffect;
pushEffect(
----------

Similar to what we explained in How does useEffect() work internally in React?

This pushes effects to the fiber

HookHasEffect | HookPassive,
-----------

Passive means it is similar to what we did with useEffect()

updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
--------------------

For initial mount, since nextSnapshot is the same, it only takes

advantage of the check, because theoretically there is small window

betweengetSnapshot() in line 314 and mountEffect() in line 359

Different from the consistency check to address tearing issue, this

re-rendering to make sure the latest data is updated, similar to the

purpose of calling update() again in our implementation

undefined,
null,
);
return nextSnapshot;
}
function subscribeToStore(fiber, inst, subscribe) {
const handleStoreChange = () => {
// The store changed. Check if the snapshot changed since the last time we
// read from the store.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
forceStoreRerender(fiber);
}
};
// Subscribe to the store and return a clean-up function.
return subscribe(handleStoreChange);
}
function updateStoreInstance<T>(
fiber: Fiber,
inst: StoreInstance<T>,
nextSnapshot: T,
getSnapshot: () => T,
) {
// These are updated in the passive phase
inst.value = nextSnapshot;
inst.getSnapshot = getSnapshot;
// Something may have been mutated in between render and commit. This could
// have been in an event that fired before the passive effects, or it could
// have been in a layout effect. In that case, we would have used the old
// snapshot and getSnapshot values to bail out. We need to check one more time.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
forceStoreRerender(fiber);
}
}
function checkIfSnapshotChanged(inst) {
const latestGetSnapshot = inst.getSnapshot;
const prevValue = inst.value;
try {
const nextValue = latestGetSnapshot();
return !is(prevValue, nextValue);

Both in subscribeToStore() and updateStoreInstance(),

this check ensures a re-render is scheduled if external data changes

} catch (error) {
return true;
}
}
function forceStoreRerender(fiber) {
const root = enqueueConcurrentRenderForLane(fiber, SyncLane);
if (root !== null) {
scheduleUpdateOnFiber(root, fiber, SyncLane, NoTimestamp);
--------

Here it forces SyncLane for the re-render

As mentioned in How does React do the initial mount

SyncLane is blocking lane, thus this new render won't be concurrent mode

Since tearing only happens in concurrent mode, this prevents tearing

from happening in next render

}
}
function mountSyncExternalStore<T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T,
): T {
const fiber = currentlyRenderingFiber;
const hook = mountWorkInProgressHook();
------------------------------------

create a new hook

let nextSnapshot;
const isHydrating = getIsHydrating();
if (isHydrating) {
...
} else {
nextSnapshot = getSnapshot();
----------------------------

retrieve the data once, just like we did in initializer of useState()

// Unless we're rendering a blocking lane, schedule a consistency check.
------------------------------------------------------------------------
// Right before committing, we will walk the tree and check if any of the
------------------------------------------------------------------------
// stores were mutated.
------------------------
//
// We won't do this if we're hydrating server-rendered content, because if
// the content is stale, it's already visible anyway. Instead we'll patch
// it up in a passive effect.
const root: FiberRoot | null = getWorkInProgressRoot();
if (!includesBlockingLane(root, renderLanes)) {
pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
}

This part is important, it schedules a check right before committing

to see if external data has changed. It only applies in non-blocking lane,

which basically means it is in concurrent mode

This tries to address the issue of tearing mentioned above.

}
// Read the current snapshot from the store on every render. This breaks the
// normal rules of React, and only works because store updates are
// always synchronous.
hook.memoizedState = nextSnapshot;

This hook just holds the data

same to useState() in our own implementation

const inst: StoreInstance<T> = {
value: nextSnapshot,
getSnapshot,
};
hook.queue = inst;
----------

This looks similar to the update queue we talked about

in How does useState() work internally in React?

but actually it just borrows field name

// Schedule an effect to subscribe to the store.
mountEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [subscribe]);
----------------------------

Great, mountEffect() is the internal of useEffect(), so

it is similar to what we do, scheduling a (passive) effect to init subscription

// Schedule an effect to update the mutable instance fields. We will update
---------------------------------------------------------------------------
// this whenever subscribe, getSnapshot, or value changes. Because there's no
----------------------------------------------------------
// clean-up function, and we track the deps correctly, we can call pushEffect
// directly, without storing any additional state. For the same reason, we
// don't need to set a static flag, either.
// TODO: We can move this to the passive phase once we add a pre-commit
// consistency check. See the next comment.
fiber.flags |= PassiveEffect;
pushEffect(
----------

Similar to what we explained in How does useEffect() work internally in React?

This pushes effects to the fiber

HookHasEffect | HookPassive,
-----------

Passive means it is similar to what we did with useEffect()

updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
--------------------

For initial mount, since nextSnapshot is the same, it only takes

advantage of the check, because theoretically there is small window

betweengetSnapshot() in line 314 and mountEffect() in line 359

Different from the consistency check to address tearing issue, this

re-rendering to make sure the latest data is updated, similar to the

purpose of calling update() again in our implementation

undefined,
null,
);
return nextSnapshot;
}
function subscribeToStore(fiber, inst, subscribe) {
const handleStoreChange = () => {
// The store changed. Check if the snapshot changed since the last time we
// read from the store.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
forceStoreRerender(fiber);
}
};
// Subscribe to the store and return a clean-up function.
return subscribe(handleStoreChange);
}
function updateStoreInstance<T>(
fiber: Fiber,
inst: StoreInstance<T>,
nextSnapshot: T,
getSnapshot: () => T,
) {
// These are updated in the passive phase
inst.value = nextSnapshot;
inst.getSnapshot = getSnapshot;
// Something may have been mutated in between render and commit. This could
// have been in an event that fired before the passive effects, or it could
// have been in a layout effect. In that case, we would have used the old
// snapshot and getSnapshot values to bail out. We need to check one more time.
if (checkIfSnapshotChanged(inst)) {
// Force a re-render.
forceStoreRerender(fiber);
}
}
function checkIfSnapshotChanged(inst) {
const latestGetSnapshot = inst.getSnapshot;
const prevValue = inst.value;
try {
const nextValue = latestGetSnapshot();
return !is(prevValue, nextValue);

Both in subscribeToStore() and updateStoreInstance(),

this check ensures a re-render is scheduled if external data changes

} catch (error) {
return true;
}
}
function forceStoreRerender(fiber) {
const root = enqueueConcurrentRenderForLane(fiber, SyncLane);
if (root !== null) {
scheduleUpdateOnFiber(root, fiber, SyncLane, NoTimestamp);
--------

Here it forces SyncLane for the re-render

As mentioned in How does React do the initial mount

SyncLane is blocking lane, thus this new render won't be concurrent mode

Since tearing only happens in concurrent mode, this prevents tearing

from happening in next render

}
}

The code is a bit complex, but we can see that the idea is straightforward. One puzzle is how the consistency check works, let’s continue.

2.2 updateSyncExternalStore() in re-rendering

function updateSyncExternalStore<T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T,
): T {
const fiber = currentlyRenderingFiber;
const hook = updateWorkInProgressHook();
// Read the current snapshot from the store on every render. This breaks the
// normal rules of React, and only works because store updates are
// always synchronous.
const nextSnapshot = getSnapshot();
const prevSnapshot = hook.memoizedState;
const snapshotChanged = !is(prevSnapshot, nextSnapshot);
if (snapshotChanged) {
hook.memoizedState = nextSnapshot;

So if new state is found, it updates the data right away.

markWorkInProgressReceivedUpdate();
}
const inst = hook.queue;
updateEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [
subscribe,
]);

This follows useEffect() to do the update and cleanup based on deps

// Whenever getSnapshot or subscribe changes, we need to check in the
// commit phase if there was an interleaved mutation. In concurrent mode
// this can happen all the time, but even in synchronous mode, an earlier
// effect may have mutated the store.
if (
inst.getSnapshot !== getSnapshot ||
snapshotChanged ||
// Check if the subscribe function changed. We can save some memory by
// checking whether we scheduled a subscription effect above.
(workInProgressHook !== null &&
workInProgressHook.memoizedState.tag & HookHasEffect)
) {
fiber.flags |= PassiveEffect;
pushEffect(
HookHasEffect | HookPassive,
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
undefined,
null,
);

if getSnapshot() changes, we need to update the effect, just like

when the dependency array changes for useEffect()

// Unless we're rendering a blocking lane, schedule a consistency check.
// Right before committing, we will walk the tree and check if any of the
// stores were mutated.
const root: FiberRoot | null = getWorkInProgressRoot();
if (root === null) {
throw new Error(
'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
);
}
if (!includesBlockingLane(root, renderLanes)) {
pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
}

Here the consistency check is sheduled again.

To be honest, I don't quite understand why it is put here...

Since even if there is no snapshot change and no getSnapShot() change

there is still chance that external store changes without emitting to us

(think about throttled events), so putting it here under conditions seems

to be a bug for me.

2023-08-11 Update:

This turned out not to be a bug. Here it schedules consistency check mainly for

changes on the subscribe() and getSnapShot(), check out PR

}
return nextSnapshot;
}
function updateSyncExternalStore<T>(
subscribe: (() => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T,
): T {
const fiber = currentlyRenderingFiber;
const hook = updateWorkInProgressHook();
// Read the current snapshot from the store on every render. This breaks the
// normal rules of React, and only works because store updates are
// always synchronous.
const nextSnapshot = getSnapshot();
const prevSnapshot = hook.memoizedState;
const snapshotChanged = !is(prevSnapshot, nextSnapshot);
if (snapshotChanged) {
hook.memoizedState = nextSnapshot;

So if new state is found, it updates the data right away.

markWorkInProgressReceivedUpdate();
}
const inst = hook.queue;
updateEffect(subscribeToStore.bind(null, fiber, inst, subscribe), [
subscribe,
]);

This follows useEffect() to do the update and cleanup based on deps

// Whenever getSnapshot or subscribe changes, we need to check in the
// commit phase if there was an interleaved mutation. In concurrent mode
// this can happen all the time, but even in synchronous mode, an earlier
// effect may have mutated the store.
if (
inst.getSnapshot !== getSnapshot ||
snapshotChanged ||
// Check if the subscribe function changed. We can save some memory by
// checking whether we scheduled a subscription effect above.
(workInProgressHook !== null &&
workInProgressHook.memoizedState.tag & HookHasEffect)
) {
fiber.flags |= PassiveEffect;
pushEffect(
HookHasEffect | HookPassive,
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
undefined,
null,
);

if getSnapshot() changes, we need to update the effect, just like

when the dependency array changes for useEffect()

// Unless we're rendering a blocking lane, schedule a consistency check.
// Right before committing, we will walk the tree and check if any of the
// stores were mutated.
const root: FiberRoot | null = getWorkInProgressRoot();
if (root === null) {
throw new Error(
'Expected a work-in-progress root. This is a bug in React. Please file an issue.',
);
}
if (!includesBlockingLane(root, renderLanes)) {
pushStoreConsistencyCheck(fiber, getSnapshot, nextSnapshot);
}

Here the consistency check is sheduled again.

To be honest, I don't quite understand why it is put here...

Since even if there is no snapshot change and no getSnapShot() change

there is still chance that external store changes without emitting to us

(think about throttled events), so putting it here under conditions seems

to be a bug for me.

2023-08-11 Update:

This turned out not to be a bug. Here it schedules consistency check mainly for

changes on the subscribe() and getSnapShot(), check out PR

}
return nextSnapshot;
}

The code looks reasonable except the last part of scheduling consistency check. I’ve put up a demo showing that it doesn’t work as expected under certain condition.

import {
  useEffect,
  useSyncExternalStore,
  startTransition,
  useState,
  useLayoutEffect,
  useMemo,
} from 'react';

let data = 1;
let callbacks = [];

function getSnapShot() {
  return data;
}

setTimeout(() => (data = 2), 800);

function subscribe(callback) {
  callbacks.push(callback);
  return () => {
    callbacks = callbacks.filter((item) => item != callback);
  };
}

function Cell() {
  let start = Date.now();
  while (Date.now() - start < 50) {
    // force yielding to main thread in concurrent mode
  }
  const data = useSyncExternalStore(subscribe, getSnapShot);

  return <div style={{ padding: '1rem', border: '1px solid red' }}>{data}</div>;
}

export default function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    startTransition(() => {
      setCount((count) => count + 1);
    });
  }, []);

  return (
    <div>
      <p>
        Example of tearing. <br />
        below are multiple cells rendering same external data which changes
        during rendering.
      </p>
      <div style={{ display: 'flex', gap: '1rem' }}>
        <Cell />
        <Cell />
        <Cell />
        <Cell />
        <Cell />
        <Cell />
      </div>
    </div>
  );
}

Here we can see that tearing happens again even though useSyncExternalStore() is used. I think this possibly is a bug and I’ve created a pull request to fix this.

2.3 Consistency Check under concurrent mode.

Now let’s find out how Consistency Check works.

function pushStoreConsistencyCheck<T>(
fiber: Fiber,
getSnapshot: () => T,
renderedSnapshot: T,
) {
fiber.flags |= StoreConsistency;
const check: StoreConsistencyCheck<T> = {
getSnapshot,
value: renderedSnapshot,
};
let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
if (componentUpdateQueue === null) {
componentUpdateQueue = createFunctionComponentUpdateQueue();
currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
------------

recall in useEffect, updateQueue also

holds lastEffect to hold the effects. And it is cleared in renderWithHooks(),

meaning every time a component is rendered, it starts fresh.

componentUpdateQueue.stores = [check];
------

Separate field for external store check

} else {
const stores = componentUpdateQueue.stores;
if (stores === null) {
componentUpdateQueue.stores = [check];
} else {
stores.push(check);
}
}
}
function pushStoreConsistencyCheck<T>(
fiber: Fiber,
getSnapshot: () => T,
renderedSnapshot: T,
) {
fiber.flags |= StoreConsistency;
const check: StoreConsistencyCheck<T> = {
getSnapshot,
value: renderedSnapshot,
};
let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
if (componentUpdateQueue === null) {
componentUpdateQueue = createFunctionComponentUpdateQueue();
currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
------------

recall in useEffect, updateQueue also

holds lastEffect to hold the effects. And it is cleared in renderWithHooks(),

meaning every time a component is rendered, it starts fresh.

componentUpdateQueue.stores = [check];
------

Separate field for external store check

} else {
const stores = componentUpdateQueue.stores;
if (stores === null) {
componentUpdateQueue.stores = [check];
} else {
stores.push(check);
}
}
}

Now it’s time to see when and how is consistency check triggered? It is actually right before committing, in the end of Render phase.

// This is the entry point for every concurrent task, i.e. anything that
// goes through Scheduler.
export function performConcurrentWorkOnRoot(
root: FiberRoot,
didTimeout: boolean,
): RenderTaskFn | null {
...
// Determine the next lanes to work on, using the fields stored
// on the root.
// TODO: This was already computed in the caller. Pass it as an argument.
let lanes = getNextLanes(
root,
root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
);
if (lanes === NoLanes) {
// Defensive coding. This is never expected to happen.
return null;
}
// We disable time-slicing in some cases: if the work has been CPU-bound
// for too long ("expired" work, to prevent starvation), or we're in
// sync-updates-by-default mode.
// TODO: We only check `didTimeout` defensively, to account for a Scheduler
// bug we're still investigating. Once the bug in Scheduler is fixed,
// we can remove this, since we track expiration ourselves.
const shouldTimeSlice =
!includesBlockingLane(root, lanes) &&
!includesExpiredLane(root, lanes) &&
(disableSchedulerTimeoutInWorkLoop || !didTimeout);
let exitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes)
: renderRootSync(root, lanes);
if (exitStatus !== RootInProgress) {
if (exitStatus === RootErrored) {
// If something threw an error, try rendering one more time. We'll
// render synchronously to block concurrent data mutations, and we'll
// includes all pending updates are included. If it still fails after
// the second attempt, we'll give up and commit the resulting tree.
const originallyAttemptedLanes = lanes;
const errorRetryLanes = getLanesToRetrySynchronouslyOnError(
root,
originallyAttemptedLanes,
);
if (errorRetryLanes !== NoLanes) {
lanes = errorRetryLanes;
exitStatus = recoverFromConcurrentError(
root,
originallyAttemptedLanes,
errorRetryLanes,
);
}
}
if (exitStatus === RootFatalErrored) {
const fatalError = workInProgressRootFatalError;
prepareFreshStack(root, NoLanes);
markRootSuspended(root, lanes);
ensureRootIsScheduled(root);
throw fatalError;
}
if (exitStatus === RootDidNotComplete) {
// The render unwound without completing the tree. This happens in special
// cases where need to exit the current render without producing a
// consistent tree or committing.
markRootSuspended(root, lanes);
} else {
// The render completed.
// Check if this render may have yielded to a concurrent event, and if so,
// confirm that any newly rendered stores are consistent.
// TODO: It's possible that even a concurrent render may never have yielded
// to the main thread, if it was fast enough, or if it expired. We could
// skip the consistency check in that case, too.
const renderWasConcurrent = !includesBlockingLane(root, lanes);
const finishedWork: Fiber = (root.current.alternate: any);
if (
renderWasConcurrent &&
!isRenderConsistentWithExternalStores(finishedWork)
) {

The consistency check is here!

// A store was mutated in an interleaved event. Render again,
// synchronously, to block further mutations.
exitStatus = renderRootSync(root, lanes);
----------------------------------------

A re-render in sync-mode is kicked off

before committing happens, so users won't see tearing on UI

// We need to check again if something threw
if (exitStatus === RootErrored) {
const originallyAttemptedLanes = lanes;
const errorRetryLanes = getLanesToRetrySynchronouslyOnError(
root,
originallyAttemptedLanes,
);
if (errorRetryLanes !== NoLanes) {
lanes = errorRetryLanes;
exitStatus = recoverFromConcurrentError(
root,
originallyAttemptedLanes,
errorRetryLanes,
);
// We assume the tree is now consistent because we didn't yield to any
// concurrent events.
}
}
if (exitStatus === RootFatalErrored) {
const fatalError = workInProgressRootFatalError;
prepareFreshStack(root, NoLanes);
markRootSuspended(root, lanes);
ensureRootIsScheduled(root);
throw fatalError;
}
// FIXME: Need to check for RootDidNotComplete again. The factoring here
// isn't ideal.
}
// We now have a consistent tree. The next step is either to commit it,
// or, if something suspended, wait to commit it after a timeout.
root.finishedWork = finishedWork;
root.finishedLanes = lanes;
finishConcurrentRender(root, exitStatus, finishedWork, lanes);
----------------------

commitRoot() happens inside here

}
}
ensureRootIsScheduled(root);
return getContinuationForRoot(root, originalCallbackNode);
}
// This is the entry point for every concurrent task, i.e. anything that
// goes through Scheduler.
export function performConcurrentWorkOnRoot(
root: FiberRoot,
didTimeout: boolean,
): RenderTaskFn | null {
...
// Determine the next lanes to work on, using the fields stored
// on the root.
// TODO: This was already computed in the caller. Pass it as an argument.
let lanes = getNextLanes(
root,
root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
);
if (lanes === NoLanes) {
// Defensive coding. This is never expected to happen.
return null;
}
// We disable time-slicing in some cases: if the work has been CPU-bound
// for too long ("expired" work, to prevent starvation), or we're in
// sync-updates-by-default mode.
// TODO: We only check `didTimeout` defensively, to account for a Scheduler
// bug we're still investigating. Once the bug in Scheduler is fixed,
// we can remove this, since we track expiration ourselves.
const shouldTimeSlice =
!includesBlockingLane(root, lanes) &&
!includesExpiredLane(root, lanes) &&
(disableSchedulerTimeoutInWorkLoop || !didTimeout);
let exitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes)
: renderRootSync(root, lanes);
if (exitStatus !== RootInProgress) {
if (exitStatus === RootErrored) {
// If something threw an error, try rendering one more time. We'll
// render synchronously to block concurrent data mutations, and we'll
// includes all pending updates are included. If it still fails after
// the second attempt, we'll give up and commit the resulting tree.
const originallyAttemptedLanes = lanes;
const errorRetryLanes = getLanesToRetrySynchronouslyOnError(
root,
originallyAttemptedLanes,
);
if (errorRetryLanes !== NoLanes) {
lanes = errorRetryLanes;
exitStatus = recoverFromConcurrentError(
root,
originallyAttemptedLanes,
errorRetryLanes,
);
}
}
if (exitStatus === RootFatalErrored) {
const fatalError = workInProgressRootFatalError;
prepareFreshStack(root, NoLanes);
markRootSuspended(root, lanes);
ensureRootIsScheduled(root);
throw fatalError;
}
if (exitStatus === RootDidNotComplete) {
// The render unwound without completing the tree. This happens in special
// cases where need to exit the current render without producing a
// consistent tree or committing.
markRootSuspended(root, lanes);
} else {
// The render completed.
// Check if this render may have yielded to a concurrent event, and if so,
// confirm that any newly rendered stores are consistent.
// TODO: It's possible that even a concurrent render may never have yielded
// to the main thread, if it was fast enough, or if it expired. We could
// skip the consistency check in that case, too.
const renderWasConcurrent = !includesBlockingLane(root, lanes);
const finishedWork: Fiber = (root.current.alternate: any);
if (
renderWasConcurrent &&
!isRenderConsistentWithExternalStores(finishedWork)
) {

The consistency check is here!

// A store was mutated in an interleaved event. Render again,
// synchronously, to block further mutations.
exitStatus = renderRootSync(root, lanes);
----------------------------------------

A re-render in sync-mode is kicked off

before committing happens, so users won't see tearing on UI

// We need to check again if something threw
if (exitStatus === RootErrored) {
const originallyAttemptedLanes = lanes;
const errorRetryLanes = getLanesToRetrySynchronouslyOnError(
root,
originallyAttemptedLanes,
);
if (errorRetryLanes !== NoLanes) {
lanes = errorRetryLanes;
exitStatus = recoverFromConcurrentError(
root,
originallyAttemptedLanes,
errorRetryLanes,
);
// We assume the tree is now consistent because we didn't yield to any
// concurrent events.
}
}
if (exitStatus === RootFatalErrored) {
const fatalError = workInProgressRootFatalError;
prepareFreshStack(root, NoLanes);
markRootSuspended(root, lanes);
ensureRootIsScheduled(root);
throw fatalError;
}
// FIXME: Need to check for RootDidNotComplete again. The factoring here
// isn't ideal.
}
// We now have a consistent tree. The next step is either to commit it,
// or, if something suspended, wait to commit it after a timeout.
root.finishedWork = finishedWork;
root.finishedLanes = lanes;
finishConcurrentRender(root, exitStatus, finishedWork, lanes);
----------------------

commitRoot() happens inside here

}
}
ensureRootIsScheduled(root);
return getContinuationForRoot(root, originalCallbackNode);
}
function isRenderConsistentWithExternalStores(finishedWork: Fiber): boolean {
// Search the rendered tree for external store reads, and check whether the
// stores were mutated in a concurrent event. Intentionally using an iterative
// loop instead of recursion so we can exit early.
let node: Fiber = finishedWork;
while (true) {
if (node.flags & StoreConsistency) {
const updateQueue: FunctionComponentUpdateQueue | null =
(node.updateQueue: any);
if (updateQueue !== null) {
const checks = updateQueue.stores;
if (checks !== null) {
for (let i = 0; i < checks.length; i++) {
const check = checks[i];
const getSnapshot = check.getSnapshot;
const renderedValue = check.value;
try {
if (!is(getSnapshot(), renderedValue)) {
----------------------------------

Here it is!

// Found an inconsistent store.
return false;
}
} catch (error) {
// If `getSnapshot` throws, return `false`. This will schedule
// a re-render, and the error will be re-thrown during render.
return false;
}
}
}
}
}
const child = node.child;
if (node.subtreeFlags & StoreConsistency && child !== null) {
child.return = node;
node = child;
continue;
}
if (node === finishedWork) {
return true;
}
while (node.sibling === null) {
if (node.return === null || node.return === finishedWork) {
return true;
}
node = node.return;
}
node.sibling.return = node.return;
node = node.sibling;
}
// Flow doesn't know this is unreachable, but eslint does
// eslint-disable-next-line no-unreachable
return true;
}
function isRenderConsistentWithExternalStores(finishedWork: Fiber): boolean {
// Search the rendered tree for external store reads, and check whether the
// stores were mutated in a concurrent event. Intentionally using an iterative
// loop instead of recursion so we can exit early.
let node: Fiber = finishedWork;
while (true) {
if (node.flags & StoreConsistency) {
const updateQueue: FunctionComponentUpdateQueue | null =
(node.updateQueue: any);
if (updateQueue !== null) {
const checks = updateQueue.stores;
if (checks !== null) {
for (let i = 0; i < checks.length; i++) {
const check = checks[i];
const getSnapshot = check.getSnapshot;
const renderedValue = check.value;
try {
if (!is(getSnapshot(), renderedValue)) {
----------------------------------

Here it is!

// Found an inconsistent store.
return false;
}
} catch (error) {
// If `getSnapshot` throws, return `false`. This will schedule
// a re-render, and the error will be re-thrown during render.
return false;
}
}
}
}
}
const child = node.child;
if (node.subtreeFlags & StoreConsistency && child !== null) {
child.return = node;
node = child;
continue;
}
if (node === finishedWork) {
return true;
}
while (node.sibling === null) {
if (node.return === null || node.return === finishedWork) {
return true;
}
node = node.return;
}
node.sibling.return = node.return;
node = node.sibling;
}
// Flow doesn't know this is unreachable, but eslint does
// eslint-disable-next-line no-unreachable
return true;
}

3. Summary

Now we understand how useSyncExternalStore() works internally, it mainly addresses 2 issues.

  1. Tearing under concurrent mode: useSyncExternalStore() solves this by scheduling consistency check after rendering is done and before committing starts. A re-render in sync mode is forced so no inconsistent data are painted on UI.
  2. undetected external store changes: useSyncExternalStore() makes sure there is a passive effect to check if there are any changes, if so a re-render in sync mode is scheduled. Notice this is after committing so users will see UI flickering.

😳 Would you like to share my post to more people ?    

❮ Prev: How forwardRef() works internally in React?

Next: When do useEffect() callbacks get run? Before paint or after paint?