Skip to main content

Concurrent

useDeferredValue

Debounce:

import { useDeferredValue } from 'react'

export default function App() {
const [text, setText] = useState('hello')

// Debounced value.
const deferredText = useDeferredValue(text, { timeoutMs: 2000 })

return (
<div>
<input value={text} onChange={handleChange} />
<List text={deferredText} />
</div>
)
}

useDeferredValue only works when SlowComponent has been wrapped with React.memo(). Without React.memo(), SlowComponent would re-render whenever its parent component re-renders, regardless of whether props has changed or not.

import { useDeferredValue, useState } from 'react'

export default function App() {
const [count, setCount] = useState(0)
const deferredCount = useDeferredValue(count)
const isBusyRecalculating = count !== deferredCount

return (
<>
<ImportantStuff count={count} />
<SlowWrapper
style={{ opacity: isBusyRecalculating ? 0.5 : 1 }}
>
<SlowStuff count={deferredCount} />
{isBusyRecalculating && <Spinner />}
</SlowWrapper>
<button type="button" onClick={() => setCount(count + 1)}>
Increment
</button>
</>
)
}

useTransition

startTransition 回调中的更新都会被认为是非紧急处理, 如果出现更紧急的更新 (User Input), 则上面的更新都会被中断, 直到没有其他紧急操作之后才会去继续执行更新.

Opt-in concurrent features (implementing debounce-like function):

  • Avoid blocking updates: events (e.g. click) are triggering updates in synchronous mode.
  • Avoid unnecessary Suspense fallbacks.
// Use this function to schedule a task for a root.
function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
const existingCallbackNode = root.callbackNode
markStarvedLanesAsExpired(root, currentTime)

// `getNextLanes()` returns highest priority lane
// if there are SyncLane and Transition Lanes, SyncLane will be chosen
const nextLanes = getNextLanes(
root,
root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
)

if (nextLanes === NoLanes) {
// Special case: There's nothing to work on.
if (existingCallbackNode !== null) {
cancelCallback(existingCallbackNode)
}
root.callbackNode = null
root.callbackPriority = NoLane
return
}

if (existingCallbackNode != null) {
// This is important!
// If a re-render is not done, and we schedule a new one,
// the old one is going to be canceled.
// This is how interruption happens.
cancelCallback(existingCallbackNode)
}

// Schedule a new callback.
let newCallbackNode
if (newCallbackPriority === SyncLane) {
// For SyncLane, the reconciliation is sync work, not concurrent mode
// meaning there is no yielding to main thread, potentially becomes blocking
scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root))
} else {
let schedulerPriorityLevel
switch (lanesToEventPriority(nextLanes)) {
case DiscreteEventPriority:
schedulerPriorityLevel = ImmediateSchedulerPriority
break
case ContinuousEventPriority:
schedulerPriorityLevel = UserBlockingSchedulerPriority
break
case DefaultEventPriority:
schedulerPriorityLevel = NormalSchedulerPriority
break
case IdleEventPriority:
schedulerPriorityLevel = IdleSchedulerPriority
break
default:
schedulerPriorityLevel = NormalSchedulerPriority
break
}

// If not SyncLane, concurrent mode is used,
// reconciliation yields to main thread time to time
// which makes UI interactive and thus possible to cancel previous re-render
newCallbackNode = scheduleCallback(
schedulerPriorityLevel,
performConcurrentWorkOnRoot.bind(null, root),
)
}

root.callbackPriority = newCallbackPriority
root.callbackNode = newCallbackNode
}
import { useRef, useState, useTransition } from 'react'
import Spinner from './Spinner'

export default function App() {
const input = useRef('')
const [searchInputValue, setSearchInputValue] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const [isPending, startTransition] = useTransition()

// Urgent: show what was typed.
setSearchInputValue(input)

// Debounced callback.
startTransition(() => {
setSearchQuery(input)
})

return <div>{isPending && <Spinner />}</div>
}

Form state:

import { useTransition } from 'react'

export default function App() {
const sendMessage = async (formData: FormData) => {
const message = formData.get('message')

console.log(message)

// Artificial delay to simulate async operation
await new Promise(resolve => setTimeout(resolve, 2000))

// TODO: do call (e.g. API call) to send the message
}

const [isPending, startTransition] = useTransition()

const action = (formData: FormData) => {
startTransition(async () => {
await sendMessage(formData)
})
}

return (
<form action={action}>
<label htmlFor="message">Message:</label>
<input name="message" id="message" />

<button type="submit" disabled={isPending}>
{isPending ? 'Sending ...' : 'Send'}
</button>
</form>
)
}

useSyncExternalStore

Props/Context/useState/useReducer are internal states not affected by concurrent features.

External stores affected by concurrent features including:

  • Global variables: document.body.
  • Date.
  • Redux store.
  • Zustand store.

useSyncExternalStore allows external stores to support concurrent reads by forcing updates to the store to be synchronous:

  • Caching data from external APIs: As this hook is mostly used to subscribe external third-party data sources, caching that data gets simpler as well. You can keep your app's data in sync with the external data source and later can also use it for offline support.
  • WebSocket connection: As a WebSocket is a "continuous" connection, you can use this hook to manage the WebSocket connection state data in real-time.
  • Managing browser storage: In such cases where you need to sync data between the web browser's storage and the application's state, you can use useSyncExternalStore to subscribe to updates in the external store.
type UseSyncExternalStore = <State>(
subscribe: (callback: Callback) => Unsubscribe,
getSnapshot: () => State,
getServerSnapshot?: () => State,
) => State

export function useSyncExternalStore<Snapshot>(
subscribe: (onStoreChange: () => void) => () => void,
getSnapshot: () => Snapshot,
getServerSnapshot?: () => Snapshot,
): Snapshot

subscribe method should subscribe to store changes, and it should return function to unsubscribe from store changes. Ensure onStoreChange is called whenever store changes, will trigger re-render of component.

getSnapshot method would return a snapshot of data from store. While store has not changed, repeated calls to getSnapshot must return same value. If store changes and returned value is different (as compared by Object.is), React re-renders component.

getServerSnapshot method would return initial snapshot of data from server. It will be used only during server rendering and during hydration of server-rendered content on client. The server snapshot must be the same between client and server, and is usually serialized and passed from server to client.

Simple shim for useSyncExternalStore:

function useSyncExternalStore(subscribe, getSnapshot) {
const [data, setData] = useState(() => getSnapshot())

const update = useCallback(() => {
setData(() => getSnapshot())
}, [])

useEffect(() => {
update()
return subscribe(update)
}, [update])

return data
}

Sync Browser API

Sync navigator online API:

function subscribe(onStoreChange) {
window.addEventListener('online', onStoreChange)
window.addEventListener('offline', onStoreChange)
return () => {
window.removeEventListener('online', onStoreChange)
window.removeEventListener('offline', onStoreChange)
}
}

function useOnlineStatus() {
return useSyncExternalStore(
subscribe,
() => navigator.onLine,
() => true
)
}

function ChatIndicator() {
const isOnline = useOnlineStatus()
// ...
}

Sync Browser Event

Sync browser scroll event:

// A memoized constant fn prevents unsubscribe/resubscribe
// In practice it is not a big deal
function subscribe(onStoreChange) {
globalThis.window?.addEventListener('scroll', onStoreChange)
return () => globalThis.window?.removeEventListener('scroll', onStoreChange)
}

function useScrollY(selector = id => id) {
return useSyncExternalStore(
subscribe,
() => selector(globalThis.window?.scrollY),
() => undefined
)
}
export default function ScrollY() {
const scrollY = useScrollY()
return <div>{scrollY}</div>
}
export default function ScrollYFloored() {
const to = 100
const scrollYFloored = useScrollY(y =>
y ? Math.floor(y / to) * to : undefined
)
return <div>{scrollYFloored}</div>
}

Sync Browser Router

function useHistorySelector(selector) {
const history = useHistory()
return useSyncExternalStore(history.listen, () => selector(history))
}
export default function CurrentPathname() {
const pathname = useHistorySelector(history => history.location.pathname)
return <div>{pathname}</div>
}
export default function CurrentHash() {
const hash = useHistorySelector(history => history.location.hash)
return <div>{hash}</div>
}

Sync External State

Simple demo from React Conf 2021:

import { useSyncExternalStore } from 'react'

// We will also publish a backwards compatible shim
// It will prefer the native API, when available
import { useSyncExternalStore } from 'use-sync-external-store/shim'

const store = {
state: { count: 0 },
listeners: new Set(),
setState: (fn) => {
store.state = fn(store.state)
store.listeners.forEach(listener => listener())
},
subscribe: (callback) => {
store.listeners.add(callback)
return () => store.listeners.delete(callback)
},
getSnapshot: () => {
const snap = Object.freeze(store.state)
return snap
},
}

export default function App() {
// Basic usage. getSnapshot must return a cached/memoized result
const state = useSyncExternalStore(store.subscribe, store.getSnapshot)

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

return (
<div>
{state.count}
{selectedField}
</div>
)
}

Migrate from useState + useEffect + useRef to useSyncExternalStore for 3rd external stores libraries (e.g. Redux):

import { useCallback, useEffect, useState } from 'react'
import { useSyncExternalStore } from 'use-sync-external-store/shim'

function createStore(initialState) {
let state = initialState
const listeners = new Set()

const getState = () => state
const setState = (fn) => {
state = fn(state)
listeners.forEach(listener => listener())
}
const subscribe = (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
}

return {
getState,
setState,
subscribe,
}
}

// Explicitly process external store for React v17.
// Sync external store state to React internal state
// with `useState` and `store.subscribe`:
// store.setState -> updater -> setState.
function useStoreLegacy(store, selector) {
const [state, setState] = useState(() => selector(store.getState()))

useEffect(() => {
const updater = () => setState(selector(store.getState()))
const unsubscribe = store.subscribe(updater)
updater()
return unsubscribe
}, [store, selector])

return state
}

// Use `useSyncExternalStore` for React v18+.
function useStore(store, selector) {
return useSyncExternalStore(
store.subscribe,
useCallback(() => selector(store.getState()), [store, selector])
)
}

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

function Counter() {
const count = useStore(
store,
useCallback(state => state.count, [])
)

const handleClick = () =>
store.setState(state => ({ ...state, count: state.count + 1 }))

return (
<div>
{count}
<button type="button" onClick={handleClick}>+1</button>
</div>
)
}

function TextBox() {
const text = useStore(
store,
useCallback(state => state.text, [])
)

const handleChange = (event) => {
store.setState(state => ({ ...state, text: event.target.value }))
}

return (
<div>
<input type="text" value={text} onChange={handleChange} />
</div>
)
}

export default function App() {
return (
<div>
<Counter />
<Counter />
<TextBox />
<TextBox />
</div>
)
}

React.createRoot(document.querySelector('#root')).render(<App />)