Skip to main content

React Hooks Notes

  • Reuse stateful logic between components (avoid wrapper hell in render props or HOC)
  • Split one complex component into smaller functions
  • Use more of React features without classes
  • Class components will read this.props too early or too late, because of mutable this in React (however props argument of function components is immutable), that says function components capture the rendered values. more details on Overreacted.

Hooks Types

Hooks definition:

  • 从 React 内部 (Reconciler) 看, Hooks 可分为三类:
    • State Hooks (useState/useReducer/useContext/useRef/useCallback/useMemo): 主要作用于 Reconciler.Render 阶段, fiber.pendingProps/memoizedProps/memoizedState/updateQueue.
    • Effect Hooks (useLayoutEffect/useEffect): 在 Reconciler.Render 阶段设置 fiber.flags (effects flags), 主要作用于 Reconciler.Commit 阶段, 功能接近 ClassComponent.LifeCycle.
    • Hybrid Hooks (useDeferredValue/useTransition/useId/useSyncExternalStore): State + Effect Hooks, 既保存状态, 又产生副作用.
interface Hook {
// hook 保存的数据.
memoizedState: any
// 本次更新以 baseState 为基础计算新的 state.
baseState: any
// 本次更新开始时已有的 update 队列.
baseQueue: Update<any, any> | null
// 本次更新需要增加的 update 队列.
queue: UpdateQueue<any, any> | null
// 指向下一个 hook.
next: Hook | null
}

interface Update<S, A> {
lane: Lane
action: A
hasEagerState: boolean
eagerState: S | null
next: Update<S, A>
}

interface UpdateQueue<S, A> {
pending: Update<S, A> | null
interleaved: Update<S, A> | null
dispatch: ((A) => mixed) | null
lanes: Lanes
lastRenderedReducer: ((S, A) => S) | null
lastRenderedState: S | null
}

interface Effect {
tag: HookFlags
create: () => (() => void) | void
destroy: (() => void) | void
deps: Array<mixed> | null
next: Effect
}

type HookType =
| 'useState'
| 'useReducer'
| 'useContext'
| 'useRef'
| 'useEffect'
| 'useInsertionEffect'
| 'useLayoutEffect'
| 'useCallback'
| 'useMemo'
| 'useImperativeHandle'
| 'useDebugValue'
| 'useDeferredValue'
| 'useTransition'
| 'useMutableSource'
| 'useSyncExternalStore'
| 'useId'
| 'useCacheRefresh'

Hooks Memoized State

  • FunctionComponent 内部所有 Hooks memoized state 组成 FunctionComponent Fiber memoized state.
  • FunctionComponent Fiber: fiber.memoizedState 指向第一个 Hook.
HooksMemoized State
useRefref: { current }
useMemo[nextValue, deps]
useCallback[callback, deps]
useStatestate
useEffecteffect: { tag, create, destroy, deps, next }

Hooks Workflow

  • Reconciler.Render/Reconciler.Update: performUnitOfWork -> beginWork -> updateFunctionComponent -> renderWithHooks -> mountXXX/updateXXX/rerenderXXX -> reconcileChildren.
  • Reconciler.Commit:
    • Update layout effect (useLayoutEffect):
      • Mutation phase: commitWork -> commitHooKEffectListUnmount -> effect.destroy.
      • Layout phase: commitLifeCycles -> commitHookEffectListMount -> effect.create.
    • Update | Passive passive effect (useEffect):
      • Layout phase: commitLifeCycles -> schedulePassiveEffects, 收集 Effects.
      • scheduleCallback -> flushPassiveEffects -> effect.destroy -> effect.create.
    • 只有 effect.tag 包含 HasEffect 时才会调用 effect.destroyeffect.create.
  • renderWithHooks:
    • HooksDispatcherOnMount: mountXXX.
    • HooksDispatcherOnUpdate: updateXXX.
    • HooksDispatcherOnRerender: updateXXX/rerenderXXX.
  • mountXXX: mountWorkInProgressHook -> respective mount logic.
  • updateXXX: updateWorkInProgressHook -> respective update logic.

ReactReconciler/ReactFiberBeginWork:

function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes
): Fiber | null {
const updateLanes = workInProgress.lanes

switch (workInProgress.tag) {
case FunctionComponent: {
const Component = workInProgress.type
const unresolvedProps = workInProgress.pendingProps
const resolvedProps
= workInProgress.elementType === Component
? unresolvedProps
: resolveDefaultProps(Component, unresolvedProps)
return updateFunctionComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes
)
}
}
}

function updateFunctionComponent(
current,
workInProgress,
Component,
nextProps: any,
renderLanes
) {
const context = prepareToReadContext(workInProgress, renderLanes)

// 进入 Hooks 相关逻辑, 最后返回下级 ReactElement 对象.
const nextChildren = renderWithHooks(
current,
workInProgress,
Component,
nextProps,
context,
renderLanes
)

const hasId = checkDidRenderIdHook()

if (current !== null && !didReceiveUpdate) {
bailoutHooks(current, workInProgress, renderLanes)
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes)
}

if (getIsHydrating() && hasId)
pushMaterializedTreeId(workInProgress)

// React DevTools reads this flag.
workInProgress.flags |= PerformedWork

// 进入 Reconcile 函数, 生成下级 Fiber 节点.
reconcileChildren(current, workInProgress, nextChildren, renderLanes)
// 返回下级 Fiber 节点.
return workInProgress.child
}

ReactReconciler/ReactFiberHooks:

// 渲染优先级.
let renderLanes: Lanes = NoLanes

// 当前正在构造的 Fiber, 等同于 workInProgress.
let currentlyRenderingFiber: Fiber = null

// Hooks 链表被存储在 fiber.memoizedState:
// currentHook = fiber(current).memoizedState.
let currentHook: Hook | null = null
// workInProgressHook = fiber(workInProgress).memoizedState.
let workInProgressHook: Hook | null = null

// 在 FunctionComponent 的执行过程中, 是否再次发起了更新.
// 只有 FunctionComponent 被完全执行之后才会重置.
// 当 render 异常时, 通过该变量可以决定是否清除 render 过程中的更新.
let didScheduleRenderPhaseUpdate = false

// 在本次 FunctionComponent 的执行过程中, 是否再次发起了更新.
// 每一次调用 FunctionComponent 都会被重置.
let didScheduleRenderPhaseUpdateDuringThisPass = false

// 在本次 FunctionComponent 的执行过程中, 重新发起更新的最大次数.
const RE_RENDER_LIMIT = 25

export function renderWithHooks<Props, SecondArg>(
current: Fiber | null,
workInProgress: Fiber,
Component: (p: Props, arg: SecondArg) => any,
props: Props,
secondArg: SecondArg,
nextRenderLanes: Lanes
): any {
// Store context.
renderLanes = nextRenderLanes
currentlyRenderingFiber = workInProgress

workInProgress.memoizedState = null
workInProgress.updateQueue = null
workInProgress.lanes = NoLanes

// Mount or Update hooks dispatcher.
ReactCurrentDispatcher.current
= current === null || current.memoizedState === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate

// 执行 FunctionComponent 函数, 执行 `useXXX`.
let children = Component(props, secondArg)

// Check if there was a render phase update
if (didScheduleRenderPhaseUpdateDuringThisPass) {
// Keep rendering in a loop for as long as render phase updates continue.
// Use a counter to prevent infinite loops.
let numberOfReRenders = 0

do {
didScheduleRenderPhaseUpdateDuringThisPass = false
localIdCounter = 0

if (numberOfReRenders >= RE_RENDER_LIMIT) {
throw new Error(
'Too many re-renders. React limits the number of renders to prevent '
+ 'an infinite loop.'
)
}

numberOfReRenders += 1

// Start over from the beginning of the list
currentHook = null
workInProgressHook = null
workInProgress.updateQueue = null
// Rerender hooks dispatcher.
ReactCurrentDispatcher.current = HooksDispatcherOnRerender

children = Component(props, secondArg)
} while (didScheduleRenderPhaseUpdateDuringThisPass)
}

// Restore context.
ReactCurrentDispatcher.current = ContextOnlyDispatcher
renderLanes = NoLanes
currentlyRenderingFiber = null
currentHook = null
workInProgressHook = null
didScheduleRenderPhaseUpdate = false

return children
}

const HooksDispatcherOnMount: Dispatcher = {
useCallback: mountCallback,
useContext: readContext,
useEffect: mountEffect,
useImperativeHandle: mountImperativeHandle,
useLayoutEffect: mountLayoutEffect,
useInsertionEffect: mountInsertionEffect,
useMemo: mountMemo,
useReducer: mountReducer,
useRef: mountRef,
useState: mountState,
useDebugValue: mountDebugValue,
useDeferredValue: mountDeferredValue,
useTransition: mountTransition,
useMutableSource: mountMutableSource,
useSyncExternalStore: mountSyncExternalStore,
useId: mountId,
unstable_isNewReconciler: enableNewReconciler,
readContext,
}

const HooksDispatcherOnUpdate: Dispatcher = {
useCallback: updateCallback,
useContext: readContext,
useEffect: updateEffect,
useImperativeHandle: updateImperativeHandle,
useInsertionEffect: updateInsertionEffect,
useLayoutEffect: updateLayoutEffect,
useMemo: updateMemo,
useReducer: updateReducer,
useRef: updateRef,
useState: updateState,
useDebugValue: updateDebugValue,
useDeferredValue: updateDeferredValue,
useTransition: updateTransition,
useMutableSource: updateMutableSource,
useSyncExternalStore: updateSyncExternalStore,
useId: updateId,
unstable_isNewReconciler: enableNewReconciler,
readContext,
}

const HooksDispatcherOnRerender: Dispatcher = {
useCallback: updateCallback,
useContext: readContext,
useEffect: updateEffect,
useImperativeHandle: updateImperativeHandle,
useInsertionEffect: updateInsertionEffect,
useLayoutEffect: updateLayoutEffect,
useMemo: updateMemo,
useReducer: rerenderReducer,
useRef: updateRef,
useState: rerenderState,
useDebugValue: updateDebugValue,
useDeferredValue: rerenderDeferredValue,
useTransition: rerenderTransition,
useMutableSource: updateMutableSource,
useSyncExternalStore: updateSyncExternalStore,
useId: updateId,
unstable_isNewReconciler: enableNewReconciler,
readContext,
}

// 创建 Hook, 挂载到 Hooks 链表.
function mountWorkInProgressHook(): Hook {
// hook 实例
const hook: Hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: hookForB,
}

if (workInProgressHook === null) {
// Fist hook in the list.
currentlyRenderingFiber.memoizedState = workInProgressHook = hook
} else {
// Append to the end of list.
workInProgressHook = workInProgressHook.next = hook
}

return workInProgressHook
}

// 移动 Hooks 链表指针, 获取 workInProgressHook.
function updateWorkInProgressHook(): Hook {
let nextCurrentHook: Hook | null
let nextWorkInProgressHook: Hook | null

if (currentHook === null) {
const current = currentlyRenderingFiber.alternate
nextCurrentHook = current ? current.memoizedState : null
} else {
nextCurrentHook = currentHook.next
}

if (workInProgressHook === null)
nextWorkInProgressHook = currentlyRenderingFiber.memoizedState
else nextWorkInProgressHook = workInProgressHook.next

if (nextWorkInProgressHook !== null) {
// There's already a work-in-progress. Reuse it.
workInProgressHook = nextWorkInProgressHook
nextWorkInProgressHook = workInProgressHook.next
currentHook = nextCurrentHook
} else {
// Clone from the current hook.
if (nextCurrentHook === null)
throw new Error('Rendered more hooks than during the previous render.')

currentHook = nextCurrentHook

const newHook: Hook = {
memoizedState: currentHook.memoizedState,

baseState: currentHook.baseState,
baseQueue: currentHook.baseQueue,
queue: currentHook.queue,

next: null,
}

if (workInProgressHook === null) {
// This is the first hook in the list.
currentlyRenderingFiber.memoizedState = workInProgressHook = newHook
} else {
// Append to the end of the list.
workInProgressHook = workInProgressHook.next = newHook
}
}

return workInProgressHook
}

function commitHookEffectListMount(tag: number, finishedWork: Fiber) {
const updateQueue: FunctionComponentUpdateQueue | null
= finishedWork.updateQueue
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null

if (lastEffect !== null) {
const firstEffect = lastEffect.next
let effect = firstEffect

do {
if ((effect.tag & tag) === tag) {
const create = effect.create
effect.destroy = create()
}

effect = effect.next
} while (effect !== firstEffect)
}
}

function commitHookEffectListUnmount(tag: number, finishedWork: Fiber) {
const updateQueue: FunctionComponentUpdateQueue | null
= finishedWork.updateQueue
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null

if (lastEffect !== null) {
const firstEffect = lastEffect.next
let effect = firstEffect

do {
if ((effect.tag & tag) === tag) {
// 根据传入的 tag 过滤 Effects 链表.
const destroy = effect.destroy
effect.destroy = undefined

if (destroy !== undefined)
destroy()
}

effect = effect.next
} while (effect !== firstEffect)
}
}

Minimal Hooks Implementation

const MyReact = (function () {
const hooks = []
let currentHook = 0 // array of hooks, and an iterator!
return {
render(Component) {
const Comp = Component() // run effects
Comp.render()
currentHook = 0 // reset for next render
return Comp
},
useEffect(callback, depArray) {
const hasNoDeps = !depArray
const deps = hooks[currentHook] // type: array | undefined
const hasChangedDeps = deps
? !depArray.every((el, i) => el === deps[i])
: true
if (hasNoDeps || hasChangedDeps) {
callback()
hooks[currentHook] = depArray
}
currentHook++ // done with this hook
},
useState(initialValue) {
hooks[currentHook] = hooks[currentHook] || initialValue // type: any
const setStateHookIndex = currentHook // for setState's closure!
const setState = newState => (hooks[setStateHookIndex] = newState)
return [hooks[currentHook++], setState]
},
}
})()
function Counter() {
const [count, setCount] = MyReact.useState(0)
const [text, setText] = MyReact.useState('foo') // 2nd state hook!
MyReact.useEffect(() => {
console.log('effect', count, text)
}, [count, text])
return {
click: () => setCount(count + 1),
type: txt => setText(txt),
noop: () => setCount(count),
render: () => console.log('render', { count, text }),
}
}

let App

App = MyReact.render(Counter)
// effect 0 foo
// render {count: 0, text: 'foo'}

App.click()
App = MyReact.render(Counter)
// effect 1 foo
// render {count: 1, text: 'foo'}

App.type('bar')
App = MyReact.render(Counter)
// effect 1 bar
// render {count: 1, text: 'bar'}

App.noop()
App = MyReact.render(Counter)
// // no effect run
// render {count: 1, text: 'bar'}

App.click()
App = MyReact.render(Counter)
// effect 2 bar
// render {count: 2, text: 'bar'}
function Component() {
const [text, setText] = useSplitURL('www.netlify.com')
return {
type: txt => setText(txt),
render: () => console.log({ text }),
}
}

function useSplitURL(str) {
const [text, setText] = MyReact.useState(str)
const masked = text.split('.')
return [masked, setText]
}

let App

App = MyReact.render(Component)
// { text: [ 'www', 'netlify', 'com' ] }

App.type('www.reactjs.org')
App = MyReact.render(Component)
// { text: [ 'www', 'reactjs', 'org' ] }}

UseState Hook

  • Read rendered props/state.
  • Return value of useState is ref to hooks[idx]: direct change to return value doesn't change state value.
  • Return function of useState (setState) is to change value of hooks[idx].
  • 由于 setState 更新状态 (dispatch action) 时基于 hook.BaseState, setState(value + 1)setState(value => value + 1) 存在差异.
  • 当在 useEffect 中调用 setState 时, 最好使用 setState(callback) 形式, 这样可以不用再 Deps List 中显式声明 state, 也可以避免一些 BUG.
  • dispatchAction:
    • 创建 Update 对象.
    • 将 Update 对象添加到 hook.queue.pending 队列.
    • 根据 reducerEagerState 与 currentState, 决定是否发起新的 Reconciler 调度.

UseState Hooks Dispatcher

function mountState<T>(initialState: T) {
const hook = mountWorkInProgressHook()

if (typeof initialState === 'function')
initialState = initialState()

// Setup Hook.
hook.memoizedState = hook.baseState = initialState
const queue = (hook.queue = {
pending: null,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: initialState,
})
const dispatch = (queue.dispatch = dispatchAction.bind(
null,
currentlyRenderingFiber,
queue
))

// Return Hook state and dispatch action.
return [hook.memoizedState, dispatch]
}

function updateState<T>(initialState: T) {
const basicStateReducer = (state, action) => {
return typeof action === 'function' ? action(state) : action
}

return updateReducer(basicStateReducer)
}

function dispatchAction<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A
) {
// 1. 创建 Update 对象.
const eventTime = requestEventTime()
const lane = requestUpdateLane(fiber)
const update: Update<S, A> = {
lane,
action,
eagerReducer: null,
eagerState: null,
next: null,
}

// 2. 将 Update 对象添加到 hook.queue.pending 队列.
const pending = queue.pending
if (pending === null) {
// 首个 Update, 创建一个环形链表.
update.next = update
} else {
update.next = pending.next
pending.next = update
}
queue.pending = update

const alternate = fiber.alternate
if (
fiber === currentlyRenderingFiber
|| (alternate !== null && alternate === currentlyRenderingFiber)
) {
// 渲染时更新, 做好全局标记.
didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate
= true
} else {
if (
fiber.lanes === NoLanes
&& (alternate === null || alternate.lanes === NoLanes)
) {
const lastRenderedReducer = queue.lastRenderedReducer

if (lastRenderedReducer !== null) {
let prevDispatcher
const currentState: S = queue.lastRenderedState
const eagerState = lastRenderedReducer(currentState, action)
update.eagerReducer = lastRenderedReducer
update.eagerState = eagerState

// 若在 Render 阶段, reducerEagerState === currentState,
// 则可以无需再次计算状态, 跳过调度阶段, 后续直接使用 update.eagerState.
if (is(eagerState, currentState))
return
}
}

// 3. 发起调度更新, 进入 Reconciler.
scheduleUpdateOnFiber(fiber, lane, eventTime)
}
}

Queueing series of state updates:

export function getFinalState(baseState, queue) {
let finalState = baseState

for (const update of queue)
finalState = typeof update === 'function' ? update(finalState) : update

return finalState
}

UseState Hooks Usage

setState((prevState) => {
// Object.assign would also work
return { ...prevState, ...updatedValues }
})
let newState = baseState
const firstUpdate = hook.baseQueue.next
let update = firstUpdate

// setState(value + 1) 与 setState(value => value + 1) 存在差异
// 遍历 baseQueue 中的每一个 update
do {
if (typeof update.action === 'function')
newState = update.action(newState)
else newState = action

update = reconciler()
} while (update !== firstUpdate)
import { useState } from 'react'

export default function Example() {
// Declare a new state variable, which we'll call "count"
const [count, setCount] = useState(0)

return (
<div>
<p>
You clicked
{' '}
{count}
{' '}
times
</p>
<button type="button" onClick={() => setCount(count + 1)}>Click me</button>
</div>
)
}
import { useEffect, useState } from 'react'

function FriendStatus(props) {
const [isOnline, setIsOnline] = useState(null)

function handleStatusChange(status) {
setIsOnline(status.isOnline)
}

useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange)

return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange)
}
})

if (isOnline === null)
return 'Loading...'

return isOnline ? 'Online' : 'Offline'
}

// Mount with { friend: { id: 100 } } props
ChatAPI.subscribeToFriendStatus(100, handleStatusChange) // Run first effect

// Update with { friend: { id: 200 } } props
// Clean up previous effect
ChatAPI.unsubscribeFromFriendStatus(100, handleStatusChange)
ChatAPI.subscribeToFriendStatus(200, handleStatusChange) // Run next effect

// Update with { friend: { id: 300 } } props
// Clean up previous effect
ChatAPI.unsubscribeFromFriendStatus(200, handleStatusChange)
ChatAPI.subscribeToFriendStatus(300, handleStatusChange) // Run next effect

// Unmount
ChatAPI.unsubscribeFromFriendStatus(300, handleStatusChange) // Clean up last effect

UseReducer Hook

  • Use useState whenever manage a JS primitive (e.g. string, boolean, integer).
  • Use useReducer whenever manage an object or array.
  • It’s best to put states together in one state object when they conditionally dependent on each other (useReducer).
  • Using useReducer over useState gives us predictable state transitions. It comes in very powerful when state changes become more complex.

UseReducer Hooks Dispatcher

function mountReducer<S, I, A>(
reducer: (S, A) => S,
initialArg: I,
init?: (I) => S
): [S, Dispatch<A>] {
// 1. Create Hook.
const hook = mountWorkInProgressHook()
let initialState

if (init !== undefined)
initialState = init(initialArg)
else initialState = initialArg

// 2. Setup Hook.
// 2.1 Set hook.memoizedState/hook.baseState.
hook.memoizedState = hook.baseState = initialState
// 2.2 Set hook.queue.
const queue = (hook.queue = {
pending: null,
dispatch: null,
lastRenderedReducer: reducer,
lastRenderedState: initialState,
})
// 2.3 Set hook.dispatch.
const dispatch: Dispatch<A> = (queue.dispatch = dispatchAction.bind(
null,
currentlyRenderingFiber,
queue
))

// 3. Return Hook state and dispatch action.
return [hook.memoizedState, dispatch]
}

function updateReducer<S, I, A>(
reducer: (S, A) => S,
initialArg: I,
init?: (I) => S
): [S, Dispatch<A>] {
// Get workInProgressHook.
const hook = updateWorkInProgressHook()
const queue = hook.queue
queue.lastRenderedReducer = reducer
const current: Hook = currentHook

// The last rebase update that is NOT part of the base state.
let baseQueue = current.baseQueue
// The last pending update that hasn't been processed yet.
const pendingQueue = queue.pending

// Append hook.queue.pending to current.baseQueue.
if (pendingQueue !== null) {
// We have new updates that haven't been processed yet.
// We'll add them to the base queue.
if (baseQueue !== null) {
// Merge the pending queue and the base queue.
const baseFirst = baseQueue.next
const pendingFirst = pendingQueue.next
baseQueue.next = pendingFirst
pendingQueue.next = baseFirst
}

current.baseQueue = baseQueue = pendingQueue
queue.pending = null
}

// Calculate Hook state.
if (baseQueue !== null) {
// We have a queue to process.
const first = baseQueue.next
let newState = current.baseState

let newBaseState = null
let newBaseQueueFirst = null
let newBaseQueueLast = null
let update = first

do {
const updateLane = update.lane

if (!isSubsetOfLanes(renderLanes, updateLane)) {
// 优先级不够: 加入到 baseQueue, 等待下一次 render.
const clone: Update<S, A> = {
lane: updateLane,
action: update.action,
hasEagerState: update.hasEagerState,
eagerState: update.eagerState,
next: null,
}

if (newBaseQueueLast === null) {
newBaseQueueFirst = newBaseQueueLast = clone
newBaseState = newState
} else {
newBaseQueueLast = newBaseQueueLast.next = clone
}

// Update the remaining priority in the queue.
currentlyRenderingFiber.lanes = mergeLanes(
currentlyRenderingFiber.lanes,
updateLane
)
markSkippedUpdateLanes(updateLane)
} else {
// This update does have sufficient priority (优先级足够).
// Merge state.
if (newBaseQueueLast !== null) {
// Update baseQueue
const clone: Update<S, A> = {
lane: NoLane,
action: update.action,
hasEagerState: update.hasEagerState,
eagerState: update.eagerState,
next: null,
}
newBaseQueueLast = newBaseQueueLast.next = clone
}

// Process this update.
if (update.hasEagerState) {
// 性能优化:
// If this update is a state update (not a reducer) and was processed eagerly,
// we can use the eagerly computed state
newState = update.eagerState
} else {
// 调用 Reducer 获取最新状态.
const action = update.action
newState = reducer(newState, action)
}
}

update = update.next
} while (update !== null && update !== first)

if (newBaseQueueLast === null)
newBaseState = newState
else newBaseQueueLast.next = newBaseQueueFirst

// Mark that the fiber performed work,
// but only if the new state is different from the current state.
if (!is(newState, hook.memoizedState))
markWorkInProgressReceivedUpdate()

// 把计算后结果更新到 workInProgressHook.
hook.memoizedState = newState
hook.baseState = newBaseState
hook.baseQueue = newBaseQueueLast
queue.lastRenderedState = newState
}

// Return Hook state and dispatch action.
const dispatch: Dispatch<A> = queue.dispatch
return [hook.memoizedState, dispatch]
}

UseReducer Hooks Usage

Use useState if:

  • manage JavaScript primitives as state
  • have simple state transitions
  • want to have business logic within components
  • have different properties that don’t change in any correlated manner and can be managed by multiple useState hooks
  • state is co-located to your component
  • for a small application

Use useReducer if:

  • manage JavaScript objects or arrays as state
  • have complex state transitions
  • want to move business logic into reducers
  • have different properties that are tied together and should be managed in one state object
  • update state deep down in your component tree
  • for a medium size application
  • for easier testing
  • for more predictable and maintainable state architecture
function App() {
const [state, dispatch] = useState({ count: 0 })

// 等价于
const [state, dispatch] = useReducer(
(state, action) => {
return typeof action === 'function' ? action(state) : action
},
{ count: 0 }
)

// 当需要更新 state 时, 有 2 种方式:
// 1. 直接设置:
dispatch({ count: 1 })
// 2.通过回调函数设置:
dispatch(state => ({ count: state.count + 1 }))
}
function insertToHistory(state) {
if (state && Array.isArray(state.history)) {
// Do not mutate
const newHistory = [...state.history]
newHistory.push(state)
return newHistory
}
console.warn(`
WARNING! The state was attempting capture but something went wrong.
Please check if the state is controlled correctly.
`)
return state.history || []
}

function reducer(state, action) {
switch (action.type) {
case 'set-theme':
return { ...state, theme: action.theme, history: insertToHistory(state) }
case 'add-friend':
return {
...state,
friends: [...state.friends, action.friend],
history: insertToHistory(state),
}
case 'undo': {
const isEmpty = !state.history.length
if (isEmpty)
return state
return { ...state.history[state.history.length - 1] }
}
case 'reset':
return { ...initialState, history: insertToHistory(state) }
default:
return state
}
}

export default function App() {
const [state, dispatch] = useReducer(reducer, initialState)
return <div>App</div>
}

UseMemo Hook

  • Returns a memoized value.
  • Only recompute the memoized value when one of the dependencies has changed.
  • Shallow compare diff.
  • Optimization helps to avoid expensive calculations on every render (avoid re-render problem):
    • Good use for complex objects or expensive calculations.
    • Donn't use for primitive values or simple calculations.

UseMemo Hooks Dispatcher

function mountMemo<T>(
nextCreate: () => T,
deps: Array<mixed> | void | null
): T {
const hook = mountWorkInProgressHook()
const nextDeps = deps === undefined ? null : deps
const nextValue = nextCreate()
hook.memoizedState = [nextValue, nextDeps]
return nextValue
}

function updateMemo<T>(
nextCreate: () => T,
deps: Array<mixed> | void | null
): T {
const hook = updateWorkInProgressHook()
const nextDeps = deps === undefined ? null : deps
const prevState = hook.memoizedState

if (prevState !== null) {
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1]

if (areHookInputsEqual(nextDeps, prevDeps))
return prevState[0]
}
}

const nextValue = nextCreate()
hook.memoizedState = [nextValue, nextDeps]
return nextValue
}

UseMemo Hooks Usage

export default function Button({
color,
children,
}: {
color: string
children: ReactElement
}) {
const textColor = useMemo(
() => slowlyCalculateTextColor(color),
[color] // ✅ Don’t recalculate until `color` changes
)

return (
<button type="button" className={`Button-${color} Button-text-${textColor}`}>
{children}
</button>
)
}

UseCallback Hook

  • Returns a memoized callback.
  • 对事件句柄进行缓存, useState 的第二个返回值是 dispatch, 但是每次都是返回新的函数, 使用 useCallback, 可以让它使用上次的函数. 在虚拟 DOM 更新过程中, 如果事件句柄相同, 那么就不用每次都进行 removeEventListeneraddEventListener.
  • useCallback(fn, deps) is equivalent to useMemo(() => fn, deps).

UseCallback Hooks Dispatcher

function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
const hook = mountWorkInProgressHook()
const nextDeps = deps === undefined ? null : deps
hook.memoizedState = [callback, nextDeps]
return callback
}

function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
const hook = updateWorkInProgressHook()
const nextDeps = deps === undefined ? null : deps
const prevState = hook.memoizedState

if (prevState !== null) {
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1]

if (areHookInputsEqual(nextDeps, prevDeps))
return prevState[0]
}
}

hook.memoizedState = [callback, nextDeps]
return callback
}

UseCallback Hooks Usage

export default function Parent() {
const [query, setQuery] = useState('react')

// ✅ Preserves identity until query changes
const fetchData = useCallback(() => {
const url = `https://hn.algolia.com/api/v1/search?query=${query}`
// ... Fetch data and return it ...
}, [query]) // ✅ Callback deps are OK

return <Child fetchData={fetchData} />
}
export default function Child({ fetchData }) {
const [data, setData] = useState(null)

useEffect(() => {
fetchData().then(setData)
}, [fetchData]) // ✅ Effect deps are OK

// ...
}

UseRef Hook

UseRef Hooks Dispatcher

function mountRef<T>(initialValue: T) {
const hook = mountWorkInProgressHook()
const ref = {
current: initialValue,
}
Object.seal(ref)
hook.memoizedState = ref
return ref
}

function updateRef<T>(initialValue: T) {
const hook = updateWorkInProgressHook()
return hook.memoizedState
}

Refs Features

  • Mutable Value: useRef() is useful for for keeping any mutable value around. Updating reference values inside handlers/useEffect callbacks is good, updating reference values during rendering (outside callbacks) is bad.
  • Lifecycle Persisted Value: useRef() creates a plain JavaScript object, is persisted (stays the same) between component re-renderings.
  • Silent Value: update reference values don't trigger re-renderings.
  • Latest Value: useRef() read rendered props/state from the future. It's good to get latest value of a particular prop or state (the updated reference value is available right away).
export default function Example() {
const [count, setCount] = useState(0)
const latestCount = useRef(count)

useEffect(() => {
// Set the mutable latest value
latestCount.current = count
setTimeout(() => {
// Read the mutable latest value
console.log(`You clicked ${latestCount.current} times`)
}, 3000)
})

return <div>Example</div>
}

Refs Update Mechanism

  • Update a ref, no re-renderings happens.
  • Update a state, the deep rendering mechanism works to re-render components.
  • Store values in refs and have them updated, which is more efficient than useState (which can be expensive) when the values are to be updated multiple times within a second.
export default function UserAvatar({ src }: { src: string }) {
return <img src={src} alt="User Avatar" />
}
export default function Username({ name }: { name: string }) {
return <span>{name}</span>
}
export default function User() {
const user = useRef({
name: 'UserName',
avatarURL: 'https://avatar.com/avatar',
})

useEffect(() => {
setTimeout(() => {
user.current = {
name: 'NewUserName',
avatarURL: 'https://avatar.com/newavatar',
}
}, 5000)
})

// Only output once
console.log('Rendered.')

// Both children won't be re-rendered
// due to shallow rendering mechanism
return (
<div>
<Username name={user.name} />
<UserAvatar src={user.avatarURL} />
</div>
)
}

Refs Usage

If your component needs to store some value, but it doesn’t impact the rendering logic, choose refs:

  • Storing timeout IDs.
  • Storing and manipulating DOM elements (binding to HTMLElement).
  • Storing other objects that aren’t necessary to calculate the JSX: work with external systems or browser APIs.
  • ref can either be a state that does not need to change too often.
  • ref can either be a state that should change as frequently as possible but should not trigger full re-rendering of the component.
import { useRef, useState } from 'react'

export default function Stopwatch() {
const [startTime, setStartTime] = useState(null)
const [now, setNow] = useState(null)
const intervalRef = useRef(null)

function handleStart() {
setStartTime(Date.now())
setNow(Date.now())

clearInterval(intervalRef.current)
intervalRef.current = setInterval(() => {
setNow(Date.now())
}, 10)
}

function handleStop() {
clearInterval(intervalRef.current)
}

let secondsPassed = 0
if (startTime != null && now != null)
secondsPassed = (now - startTime) / 1000

return (
<>
<h1>
Time passed:
{secondsPassed.toFixed(3)}
</h1>
<button type="button" onClick={handleStart}>Start</button>
<button type="button" onClick={handleStop}>Stop</button>
</>
)
}
import { forwardRef, useImperativeHandle, useRef } from 'react'

interface Props {}

function MyInput({ ref, ...props }: Props) {
const realInputRef = useRef(null)
useImperativeHandle(ref, () => ({
// Only expose focus and nothing else
focus() {
realInputRef.current.focus()
},
}))
return <input {...props} ref={realInputRef} />
}

export default function Form() {
const inputRef = useRef(null)

function handleClick() {
inputRef.current.focus()
}

return (
<>
<MyInput ref={inputRef} />
<button type="button" onClick={handleClick}>Focus the input</button>
</>
)
}

UseContext Hook

  • Create custom XXXContextProvider: 一般都不会裸露地使用 Context.Provider, 而是封装为独立的 Provider 组件, 将子组件作为 props.children 传入, 这样当 Context 变化时 Provider 不会重新渲染它的子组件, 由依赖了 context 的子组件自己进行重渲染, 未依赖的子组件不会重新渲染. 使用 useMemo 使得 value 不会导致不必要的重复渲染 (Re-rendering).
  • Create custom useXXXContext hook:
  • Check whether component under XXXContextProvider.
  • Wrap complex context logic and only expose simple API (Facade design pattern).
  • Use useMemo/useCallback to memorize values and functions.
  • Context 中只定义被大多数组件所共用的属性, use context to avoid Prop Drilling.

UseContext Hooks Dispatcher

  • HooksDispatcherOnMount.useContext = readContext.
  • HooksDispatcherOnUpdate.useContext = readContext.
  • HooksDispatcherOnRerender.useContext = readContext.
export function createContext<T>(
defaultValue: T,
calculateChangedBits: ?((a: T, b: T) => number)
): ReactContext<T> {
if (calculateChangedBits === undefined)
calculateChangedBits = null

const context: ReactContext<T> = {
$$typeof: REACT_CONTEXT_TYPE,
_calculateChangedBits: calculateChangedBits,
_currentValue: defaultValue,
_currentValue2: defaultValue,
_threadCount: 0,
Provider: null,
Consumer: null,
}
context.Provider = {
$$typeof: REACT_PROVIDER_TYPE,
_context: context,
}
context.Consumer = context
return context
}

function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes
): Fiber | null {
const updateLanes = workInProgress.lanes
workInProgress.lanes = NoLanes

switch (workInProgress.tag) {
case ContextProvider:
return updateContextProvider(current, workInProgress, renderLanes)
case ContextConsumer:
return updateContextConsumer(current, workInProgress, renderLanes)
}
}

function updateContextProvider(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes
) {
const providerType: ReactProviderType<any> = workInProgress.type
const context: ReactContext<any> = providerType._context

const newProps = workInProgress.pendingProps
const oldProps = workInProgress.memoizedProps
const newValue = newProps.value // <Provider value={}>{children}</Provider>

// 更新 ContextProvider._currentValue:
// workInProgress.type._context._currentValue = newValue;
pushProvider(workInProgress, newValue)

if (oldProps !== null) {
// 更新阶段.
// 对比 newValue 和 oldValue
const oldValue = oldProps.value
const changedBits = calculateChangedBits(context, newValue, oldValue)

if (changedBits === 0) {
// value 没有变动, 进入 Bailout 逻辑.
if (
oldProps.children === newProps.children
&& !hasLegacyContextChanged()
) {
return bailoutOnAlreadyFinishedWork(
current,
workInProgress,
renderLanes
)
}
} else {
// value变动, 查找对应的 Consumers, 并使其能够被更新.
// 向下遍历:
// 从 ContextProvider 节点开始,
// 向下查找所有 fiber.dependencies 依赖该 context 的节点.
// 向上遍历:
// 从 ContextConsumer 节点开始,
// 向上遍历, 修改父路径上所有节点的 fiber.childLanes 属性, 表明其子节点有改动, 子节点会进入更新逻辑.
propagateContextChange(workInProgress, context, changedBits, renderLanes)
}
}

// 生成下级 Fiber.
const newChildren = newProps.children
reconcileChildren(current, workInProgress, newChildren, renderLanes)
return workInProgress.child
}

function updateContextConsumer(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes
) {
const context: ReactContext<any> = workInProgress.type
const newProps = workInProgress.pendingProps
const render = newProps.children

// 读取 context.
prepareToReadContext(workInProgress, renderLanes)
const newValue = readContext(context, newProps.unstable_observedBits)

// 生成下级 Fiber.
const newChildren = render(newValue)
reconcileChildren(current, workInProgress, newChildren, renderLanes)
return workInProgress.child
}

function prepareToReadContext(workInProgress: Fiber, renderLanes: Lanes): void {
// Setup.
currentlyRenderingFiber = workInProgress
lastContextDependency = null
lastContextWithAllBitsObserved = null
const dependencies = workInProgress.dependencies

if (dependencies !== null) {
const firstContext = dependencies.firstContext

if (firstContext !== null) {
if (includesSomeLane(dependencies.lanes, renderLanes)) {
// Context list has a pending update.
// Mark that this fiber performed work.
markWorkInProgressReceivedUpdate()
}

// Reset the work-in-progress list
dependencies.firstContext = null
}
}
}

function readContext<T>(
context: ReactContext<T>,
observedBits: void | number | boolean
): T {
const contextItem = {
context: context as ReactContext<mixed>,
observedBits: resolvedObservedBits,
next: null,
}

// 1. 构造一个 contextItem, 加入到 workInProgress.dependencies 链表.
if (lastContextDependency === null) {
lastContextDependency = contextItem
currentlyRenderingFiber.dependencies = {
lanes: NoLanes,
firstContext: contextItem,
responders: null,
}
} else {
lastContextDependency = lastContextDependency.next = contextItem
}

// 2. 返回 currentValue.
return isPrimaryRenderer ? context._currentValue : context._currentValue2
}

UseContext Hooks Usage

import {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from 'react'

const CountContext = createContext()

export default function CountProvider(props) {
const [count, setCount] = useState(0)

// Use `useMemo`/`useCallback` to memorize values and functions.
const value = useMemo(() => {
return {
count,
setCount,
}
}, [count, setCount])

return <CountContext value={value} {...props} />
}

function useCount() {
const context = useContext(CountContext)

// Check whether component under `XXXContextProvider`.
if (!context)
throw new Error('useCount must be used within a CountProvider')

// Wrap complex context logic, only expose simple API.
const { count, setCount } = context
const increment = useCallback(() => setCount(c => c + 1), [setCount])

return {
count,
increment,
}
}

UseEffect Hook

UseEffect Hooks Dispatcher

function mountEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null
): void {
return mountEffectImpl(
UpdateEffect | PassiveEffect,
HookPassive,
create,
deps
)
}

function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = mountWorkInProgressHook()
const nextDeps = deps === undefined ? null : deps
currentlyRenderingFiber.flags |= fiberFlags // UpdateEffect | PassiveEffect.
hook.memoizedState = pushEffect(
HasEffect | hookFlags, // PassiveHook.
create,
undefined,
nextDeps
)
}

function updateEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null
): void {
return updateEffectImpl(PassiveEffect, HookPassive, create, deps)
}

function updateEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = updateWorkInProgressHook()
const nextDeps = deps === undefined ? null : deps
let destroy

if (currentHook !== null) {
const prevEffect = currentHook.memoizedState
destroy = prevEffect.destroy

if (nextDeps !== null) {
const prevDeps = prevEffect.deps

if (areHookInputsEqual(nextDeps, prevDeps)) {
// 如果依赖不变, 新建 Effect (tag 不含 HookHasEffect).
// Reconciler.Commit 阶段会跳过此 Effect.
pushEffect(hookFlags, create, destroy, nextDeps)
return
}
}
}

// 如果依赖改变, 更改 fiber.flags, 新建 Effect.
// Reconciler.Commit 阶段会再次执行此 Effect.
currentlyRenderingFiber.flags |= fiberFlags
hook.memoizedState = pushEffect(
HasEffect | hookFlags,
create,
destroy,
nextDeps
)
}

function pushEffect(tag, create, destroy, deps) {
const effect = {
tag,
create, // User code: effect callback.
destroy, // User code: destroy callback.
deps, // User code: deps list.
next: null,
}

let componentUpdateQueue = currentlyRenderingFiber.updateQueue

if (componentUpdateQueue === null) {
componentUpdateQueue = createFunctionComponentUpdateQueue()
currentlyRenderingFiber.updateQueue = componentUpdateQueue
componentUpdateQueue.lastEffect = effect.next = effect
} else {
const lastEffect = componentUpdateQueue.lastEffect

if (lastEffect === null) {
componentUpdateQueue.lastEffect = effect.next = effect
} else {
// Circular effect list.
const firstEffect = lastEffect.next
lastEffect.next = effect
effect.next = firstEffect
componentUpdateQueue.lastEffect = effect
}
}

return effect
}

UseEffect Lifecycle

  1. React renders UI for current props/state to screen.
  2. React cleans up the effect for prev props/state.
  3. React runs the effect for current props/state (useEffect got invoked after componentDidMount).

UseEffect Nasty Loop

The effect hook runs when the component mounts but also when the component updates. Because we are setting the state after every data fetch, the component updates and the effect runs again. It fetches the data again and again. That’s a bug and needs to be avoided.

UseEffect Deps List

无论是将组件编写为类还是函数, 都必须为 effect 响应所有 props 和 state 的更新 (Reactive Value). 在传统的 Class Component, 需要编写代码去检测这些 props 和 state 是否变更 (shouldComponentUpdate, componentDidUpdate). 在 Function Component, 借助 useEffect 可以实现自动检测.

If one of deps list changes, the hook runs again. Provide empty array as second argument to the effect hook to avoid activating it on component updates but only for the mounting of the component. For listeners binding, use [] deps list should be better.

UseEffect Omit Deps

Omit stable values from the deps list:

  • set function returned from useState.
  • ref object returned from useRef.
export default function App() {
const [count, setCount] = useState(0)
const countRef = useRef(count)

useEffect(() => {
countRef.current = count
}, [count]) // ✅ Only count is declared.
}

UseEffect Primitive Deps

Primitive values are better:

function ChatRoom({ options }) {
const [message, setMessage] = useState('')
const { roomId, serverUrl } = options

useEffect(() => {
const connection = createConnection({
roomId,
serverUrl,
})
connection.connect()
return () => connection.disconnect()
}, [roomId, serverUrl]) // ✅ All dependencies declared
}

UseEffect Functions Deps

Functions in useEffect:

  • If only use some functions inside an effect, move them directly into that effect.
  • Hoisting functions that don’t need props or state outside of component, and pull the ones that are used only by an effect inside of that effect.
  • For useCallback function, it should be in deps list useEffect(() => {}, [callback])
import axios from 'axios'
// https://www.robinwieruch.de/react-hooks-fetch-data
import { useEffect, useState } from 'react'

function useDataApi(initialUrl, initialData) {
const [data, setData] = useState(initialData)
const [url, setUrl] = useState(initialUrl)
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)

useEffect(() => {
const fetchData = async () => {
setIsError(false)
setIsLoading(true)

try {
const result = await axios(url)

setData(result.data)
} catch (error) {
setIsError(true)
}

setIsLoading(false)
}

fetchData()
}, [url])

const doFetch = (url) => {
setUrl(url)
}

return { data, isLoading, isError, doFetch }
}

UseEffect Compare Deps

import { DependencyList, EffectCallback, useEffect, useRef } from 'react'

const isPrimitive = (val: any) => val !== Object(val)

type DepsEqualFnType<TDeps extends DependencyList>
= (prevDeps: TDeps, nextDeps: TDeps) => boolean

export default function useCustomCompareEffect<TDeps extends DependencyList>(
effect: EffectCallback,
deps: TDeps,
depsEqual: DepsEqualFnType<TDeps>,
) {
const ref = useRef<TDeps | undefined>(undefined)

if (!ref.current || !depsEqual(deps, ref.current))
ref.current = deps

// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(effect, ref.current)
}
import { DependencyList, EffectCallback } from 'react'
import fastDeepEqual from './misc/fastDeepEqual'
import useCustomCompareEffect from './useCustomCompareEffect'

const isPrimitive = (val: any) => val !== Object(val)

export default function useDeepCompareEffect(
effect: EffectCallback,
deps: DependencyList,
) {
useCustomCompareEffect(effect, deps, fastDeepEqual)
}

UseEffect Closure

  • useEffect Hook 会丢弃上一次渲染结果, 它会清除上一次 effect, 再建立下一个 effect (也会创建新的 Closure), 下一个 effect 锁住新的 props 和 state (整个 Counter 函数在 re-render 时会被重复调用一次).
  • setInterval 不会丢弃上一次结果, 会引用旧状态 Closure 中的变量, 导致其与 useEffect 所预期行为不一致.
  • 可以通过 useRef 解决这一现象: get latest value.
// BUG
export default function Counter() {
const [count, setCount] = useState(0)

useEffect(() => {
const id = setInterval(() => {
setCount(count + 1) // always 1 regardless `count` value change
}, 1000)
return () => clearInterval(id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

return <h1>{count}</h1>
}
export default function Counter() {
const [count, setCount] = useState(0)

useInterval(() => {
setCount(count + 1)
}, 1000)

return <h1>{count}</h1>
}

function useInterval(callback, delay) {
const savedCallback = useRef(callback)

// Remember the latest callback if it changes
useEffect(() => {
savedCallback.current = callback
}, [callback])

// Set up the interval
useEffect(() => {
function tick() {
savedCallback.current()
}

const id = setInterval(tick, delay)
return () => clearInterval(id)
}, [delay])
}

UseEffect State

  • UseEffect Closure 所述, 每次调用 useEffect 时, 会捕获那一次 render 时的 props 和 state.
  • Class Component 中的 this.state.xxx 却总是指向最新的 state.
export default function Counter() {
const [count, setCount] = useState(0)

useEffect(() => {
setTimeout(() => {
console.log(`You clicked ${count} times`)
}, 3000)
})

return (
<div>
<p>
You clicked
{' '}
{count}
{' '}
times
</p>
<button type="button" onClick={() => setCount(count + 1)}>Click me</button>
</div>
)
}
// Output:
// Mounted: You clicked 0 times
// Clicked 5 times in 3s
// You clicked 1 times
// You clicked 2 times
// You clicked 3 times
// You clicked 4 times
// You clicked 5 times
class Counter {
componentDidUpdate() {
setTimeout(() => {
console.log(`You clicked ${this.state.count} times`)
}, 3000)
}

render() {
const { count } = this.props

return (
<div>
<p>
You clicked
{' '}
{count}
{' '}
times
</p>
<button type="button" onClick={() => this.setState(count + 1)}>Click me</button>
</div>
)
}
}
// Output:
// Mounted: You clicked 0 times
// Clicked 5 times in 3s
// You clicked 5 times
// You clicked 5 times
// You clicked 5 times
// You clicked 5 times
// You clicked 5 times

UseEffect Cleanup

  • Avoid memory leaks.
  • Prevent unexpected errors.
  • Good user experience.

Cleanup API requests (race condition:

  • Boolean flag.
  • AbortController.
function App({ url }) {
const [results, setResults] = useState([])
const [page, setPage] = useState(1)

// Cleanup with Boolean flag:
useEffect(() => {
let ignore = false
fetchResults(url, page).then((json) => {
if (!ignore)
setResults(json)
})
return () => {
ignore = true
}
}, [url, page])

// Cleanup with AbortController:
useEffect(() => {
const controller = new AbortController()
const { signal } = controller

const fetchData = async () => {
const response = await fetch(url, { signal })
const json = await response.json()
setResults(json)
}

fetchData()

return () => controller.abort()
}, [url])
}

Cleanup connections:

function App() {
useEffect(() => {
const socket = new WebSocket('url', protocols)
// do what you want with the socket

return () => socket.close()
}, [])
}

Cleanup timeouts:

function App() {
useEffect(() => {
const timeoutId = setTimeout(() => {
// do something in the timeout
}, 3000)

return () => clearTimeout(timeoutId)
}, [])
}
React 18 Development Strict Mode

With Strict Mode in React 18, React will simulate unmounting and remounting component in development mode:

  • React mounts component:
    • Layout effects are created.
    • Effect effects are created.
  • React simulates unmounting component:
    • Layout effects are destroyed.
    • Effects are destroyed.
  • React simulates mounting component with previous state:
    • Layout effect setup code runs.
    • Effect setup code runs.

When Strict Mode is on, remounts twice helps find out Effects need cleanup and exposes bugs like race conditions early.

UseEffect Usage

Effects are typically used to synchronize with external system: browser APIs, third-party library, network, and so on.

Effects let you specify side effects that are caused by rendering itself, rather than by a particular event:

  • Sending a message in the chat is an event because it is directly caused by user clicking a specific button: put it in handleClick().
  • However, setting up a server connection is an Effect because it needs to happen regardless of which interaction caused the component to appear: put int in useEffect().

If your effect only adjusts some state based on other state, you might not need effects:

  • You don’t need Effects to transform data for rendering.
  • You don’t need Effects to handle user events.
function handleClick() {
// ✅ Buying is an event because it is caused by a particular interaction.
fetch('/api/buy', { method: 'POST' })
showNotification(`Added ${product.name} to the shopping cart!`)
navigateTo('/checkout')
}

function Form() {
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')

// ✅ Good: calculated during rendering
const fullName = `${firstName} ${lastName}`

// ✅ Good: This logic runs because the component was displayed
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' })
}, [])

function handleSubmit(e) {
e.preventDefault()
// ✅ Good: Event-specific logic is in the event handler
post('/api/register', { firstName, lastName })
}
// ...
}

UseEffect Reference

  • useEffect complete guide.
  • useEffect usage guide.

UseLayoutEffect Hook

  • useLayoutEffect callback called synchronously (fires synchronously after all DOM mutations), substitute for componentDidMount lifecycle function: Update effect flags, HasEffect | Layout hook flags.
  • useEffect got invoked after componentDidMount asynchronously: Update | Passive effect flags, HasEffect | Passive hook flags.
  • Lifecycle of React component:
    • User interacts, props or state change.
    • React updates DOM.
    • useLayoutEffect hook fires.
    • Browser paints: visual changes are displayed to user.
    • useEffect hook fires.
  • If need to mutate DOM directly (visual changes to UI) or need to perform DOM measurements, useLayoutEffect is better than useEffect.
function mountLayoutEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null
): void {
return mountEffectImpl(
UpdateEffect, // Fiber Flags
HookLayout, // Hook Flags
create,
deps
)
}

function mountEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null
): void {
return mountEffectImpl(
UpdateEffect | PassiveEffect, // Fiber Flags
HookPassive, // Hook Flags
create,
deps
)
}

function updateLayoutEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null
): void {
return updateEffectImpl(UpdateEffect, HookLayout, create, deps)
}

function updateEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null
): void {
return updateEffectImpl(PassiveEffect, HookPassive, create, deps)
}

UseInsertionEffect Hook

useInsertionEffect allows CSS-in-JS libraries to address performance issues of injecting styles in render:

useInsertionEffect will run after the DOM is mutated, but before layout effects read the new layout.

function useCSS(rule) {
if (!canUseDOM)
collectedRulesSet.add(rule)

useInsertionEffect(() => {
if (!isInserted.has(rule)) {
isInserted.add(rule)
document.head.appendChild(getStyleForRule(rule))
}
})

return rule
}

export default function Component() {
const className = useCSS(rule)
return <div className={className} />
}

UseImperativeHandle Hook

interface MyInputHandles {
focus: () => void
}

const MyInput: RefForwardingComponent<MyInputHandles, MyInputProps> = (
props,
ref
) => {
const inputRef = useRef<HTMLInputElement>(null)

useImperativeHandle(ref, () => ({
focus: () => {
if (inputRef.current)
inputRef.current.focus()
},
}))

return <input {...props} ref={inputRef} />
}

const ForwardMyInput = React.forwardReft(MyInput)
export default ForwardMyInput

UseDebugValue Hook

function App() {
const date = new Date()
useDebugValue(date, date => date.toISOString())
}

UseDeferredValue Hook

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 Hook

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

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

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>
}

UseId Hook

Generating unique IDs on client and server (每个 ID 代表该组件在组件树中的层级结构):

export default function Checkbox() {
const id = useId()

return (
<>
<label htmlFor={id}>Do you like React?</label>
<input type="checkbox" name="react" id={id} />
</>
)
}

UseSyncExternalStore Hook

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.

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 />)

UseEvent Hook

Extracting non-reactive logic out of useEffect, put them into useEvent, call useEvent from inside useEffect:

import { useCallback, useEffect, useInsertionEffect, useRef } from 'react'

function useEvent(fn) {
const ref = useRef(null)
useInsertionEffect(() => {
ref.current = fn
}, [fn])
return useCallback((...args) => {
const f = ref.current // Get latest snapshot, break out closure.
return f(...args)
}, [])
}

function ChatRoom({ roomId, theme }) {
const onConnected = useEvent((connectedRoomId) => {
// Non-reactive to `theme`.
showNotification(`Welcome to ${connectedRoomId}`, theme)
})

useEffect(() => {
const connection = createConnection(serverUrl, roomId)
connection.on('connected', () => {
onConnected(roomId)
})
connection.connect()
return () => connection.disconnect()
}, [roomId, onConnected]) // Linter will allow [roomId] in the future.
}

Event functions let you split an Effect into reactive parts (which should "react" to reactive values and their changes) and non-reactive parts (which only read their latest values).

UseOptimistic Hook

UseOptimistic Hooks Dispatcher

updateOptimistic():

  • An optimistic update has revertLane of TransitionLaneXX and lane of SyncLane.
  • Update is processed in SyncLane and also in all following renders, but it is NOT skipped and always kept in the next update queue.
  • Update is reverted in revertLane (low priority transition lane), by NOT getting added to the next queue. But if the async action on the transition lane is not yet complete, it suspends by throwing the promise. The revert will be tried again after the async action is done.

Custom Hooks

Custom LifeCycle Hooks

componentDidMount:

function useMount(fn) {
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => fn(), [])
}

componentWillUnmount:

function useUnmount(fn) {
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => fn, [])
}

componentDidUpdate:

function useUpdate(effectCallback) {
const mounting = useRef(true)

useEffect(() => {
// First get called for componentDidMount lifecycle,
// so skip it.
if (mounting.current)
mounting.current = false
else
return effectCallback()
})
}

function useUpdateDeps(effectCallback, deps) {
const mounting = useRef(true)

React.useEffect(() => {
return () => {
mounting.current = true
}
}, [])

React.useEffect(() => {
// Do not execute effectCallback for the first time.
if (mounting.current)
mounting.current = false
else
return effectCallback()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
}

Force Update:

const useUpdate = () => useState(0)[1]
import { useState } from 'react'

interface VoidFunction {
(): void
}

interface VoidFunctionCreator {
(): VoidFunction
}

const max = 9007199254740990 // Number.MAX_SAFE_INTEGER - 1;

const useForceUpdate: VoidFunctionCreator = (): VoidFunction => {
const [, setState] = useState(0)
const forceUpdate: VoidFunction = (): void => {
setState((state: number) => (state + 1) % max)
}
return forceUpdate
}

export default useForceUpdate

isMounted:

function useIsMounted() {
const [isMount, setIsMount] = useState(false)

useEffect(() => {
if (!isMount)
setIsMount(true)

return () => setIsMount(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

return isMount
}

Custom Previous Hook

export default function Counter() {
const [count, setCount] = useState(0)
const prevCount = usePrevious(count)
return (
<h1>
Now:
{' '}
{count}
, before:
{' '}
{prevCount}
</h1>
)
}

function usePrevious(value) {
const ref = useRef()
useEffect(() => {
ref.current = value
})
return ref.current
}

Custom Interval Hook

import { useEffect, useRef } from 'react'

function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback)

// Remember the latest callback if it changes.
useEffect(() => {
savedCallback.current = callback
}, [callback])

// Set up the interval.
useEffect(() => {
// Don't schedule if no delay is specified.
if (delay === null)
return

const id = setInterval(() => savedCallback.current(), delay)

return () => clearInterval(id)
}, [delay])
}

export default useInterval

Custom Debounce Hook

// Hook
function useDebounce(value, delay) {
// State and setters for debounced value
const [debouncedValue, setDebouncedValue] = useState(value)

useEffect(
() => {
// Update debounced value after delay
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)

// Cancel the timeout if value changes (also on delay change or unmount)
// This is how we prevent debounced value
// from updating if value is changed ...
// .. within the delay period. Timeout gets cleared and restarted.
return () => {
clearTimeout(handler)
}
},
[value, delay] // Only re-call effect if value or delay changes
)

return debouncedValue
}
export default function App() {
// Usage
const [searchTerm, setSearchTerm] = useState('')
const debouncedSearchTerm = useDebounce(searchTerm, 500)

useEffect(() => {}, [debouncedSearchTerm])

return <div>App</div>
}

Custom EventListener Hook

import { useCallback, useEffect } from 'react'

export default function useKeydown() {
const handleKeydown = useCallback(() => {
alert('key is pressed.')
}, [])

useMount(() => {
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('keydown', handleKeydown)
}
})
}
import { useEffect } from 'react'

export default function useEventListener({ event, handler }) {
useMount(() => {
document.addEventListener(event, handler)
return () => {
document.removeEventListener(event, handler)
}
})
}

Custom Media Query Hook

export default function useMedia<T>(
queries: string[],
values: T[],
defaultValue: T
) {
// Array containing a media query list for each query
const mediaQueryLists = queries.map(q => window.matchMedia(q))

const getValue = () => {
// Get index of first media query that matches
const index = mediaQueryLists.findIndex(mql => mql.matches)
return values?.[index] || defaultValue
}

// State and setter for matched value
const [value, setValue] = useState<T>(getValue)

useMount(() => {
const handler = () => setValue(getValue)
mediaQueryLists.forEach(mql => mql.addListener(handler))
return () => mediaQueryLists.forEach(mql => mql.removeListener(handler))
})

return value
}

Custom Observer Hook

import type { RefObject } from 'react'
import { useEffect, useState } from 'react'

interface Args extends IntersectionObserverInit {
freezeOnceVisible?: boolean
}

function useIntersectionObserver(
elementRef: RefObject<Element>,
{
threshold = 0,
root = null,
rootMargin = '0%',
freezeOnceVisible = false,
}: Args
): IntersectionObserverEntry | undefined {
const [entry, setEntry] = useState<IntersectionObserverEntry>()

const frozen = entry?.isIntersecting && freezeOnceVisible

// Update first entry
const updateEntry = ([entry]: IntersectionObserverEntry[]): void => {
setEntry(entry)
}

useEffect(() => {
const node = elementRef?.current // DOM Ref
const hasIOSupport = !!window.IntersectionObserver

if (!hasIOSupport || frozen || !node)
return

const observerParams = { threshold, root, rootMargin }
const observer = new IntersectionObserver(updateEntry, observerParams)

observer.observe(node)

return () => observer.disconnect()
}, [elementRef, threshold, root, rootMargin, frozen])

return entry
}

export default useIntersectionObserver
function useComponentSize() {
const [size, setSize] = React.useState({
height: 0,
width: 0,
})
const ref = React.useRef<any>()

const onResize = React.useCallback(() => {
if (!ref.current)
return

const newHeight = ref.current.offsetHeight
const newWidth = ref.current.offsetWidth

if (newHeight !== size.height || newWidth !== size.width) {
setSize({
height: newHeight,
width: newWidth,
})
}
}, [size.height, size.width])

React.useLayoutEffect(() => {
if (!ref || !ref.current)
return

const resizeObserver = new ResizeObserver(onResize)
resizeObserver.observe(ref.current)

return () => resizeObserver.disconnect()
}, [ref, onResize])

return {
ref,
...size,
}
}

Custom Locked Body Hook

import { useEffect, useLayoutEffect, useState } from 'react'

type ReturnType = [boolean, (locked: boolean) => void]

function useLockedBody(initialLocked = false): ReturnType {
const [locked, setLocked] = useState(initialLocked)

// Do the side effect before render
useLayoutEffect(() => {
// Key point 1
if (!locked)
return

// Save initial body style
const originalOverflow = document.body.style.overflow
const originalPaddingRight = document.body.style.paddingRight

// Lock body scroll
document.body.style.overflow = 'hidden'

// Get the scrollBar width
const root = document.getElementById('___gatsby') // or root
const scrollBarWidth = root ? root.offsetWidth - root.scrollWidth : 0

// Avoid width reflow
if (scrollBarWidth)
document.body.style.paddingRight = `${scrollBarWidth}px`

// Key point 2
return () => {
document.body.style.overflow = originalOverflow

if (scrollBarWidth)
document.body.style.paddingRight = originalPaddingRight
}
}, [locked])

// Update state if initialValue changes
useEffect(() => {
if (locked !== initialLocked)
setLocked(initialLocked)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialLocked])

return [locked, setLocked]
}

export default useLockedBody

Custom Mouse Hook

import { useRef, useState } from 'react'

export default function useLongPress(time = 500) {
const [action, setAction] = useState()

const timerRef = useRef()
const isLongPress = useRef()

function startPressTimer() {
isLongPress.current = false
timerRef.current = setTimeout(() => {
isLongPress.current = true
setAction('LongPress')
}, time)
}

function handleClick() {
if (isLongPress.current)
return

setAction('Click')
}

function handleMouseDown() {
startPressTimer()
}

function handleMouseUp() {
clearTimeout(timerRef.current)
}

function handleTouchStart() {
startPressTimer()
}

function handleTouchEnd() {
if (action === 'LongPress')
return

clearTimeout(timerRef.current)
}

return {
action,
handlers: {
onClick: handleClick,
onMouseDown: handleMouseDown,
onMouseUp: handleMouseUp,
onTouchStart: handleTouchStart,
onTouchEnd: handleTouchEnd,
},
}
}

Custom Form Hook

UseState Only Form Hook

  • useState for form entire state and form control data.
  • Custom logic via hooks params function.
import { useState } from 'react'

function useForm(callback) {
const [values, setValues] = useState({})

const handleSubmit = (event) => {
if (event)
event.preventDefault()
callback()
}

const handleChange = (event) => {
event.persist()
setValues(values => ({
...values,
[event.target.name]: event.target.value,
}))
}

return {
handleChange,
handleSubmit,
values,
}
}

export default useForm

UseState and UseRef Form Hook

  • useState for form entire state.
  • useRef for form control data.
  • Custom logic via hooks params function.
export function useField(
name,
form,
{ defaultValue, validations = [], fieldsToValidateOnChange = [name] } = {}
) {
const [value, setValue] = useState(defaultValue)
const [errors, setErrors] = useState([])
const [pristine, setPristine] = useState(true)
const [validating, setValidating] = useState(false)
const validateCounter = useRef(0)

const validate = async () => {
const validateIteration = ++validateCounter.current
setValidating(true)
const formData = form.getFormData()
let errorMessages = await Promise.all(
validations.map(validation => validation(formData, name))
)
errorMessages = errorMessages.filter(Boolean)
if (validateIteration === validateCounter.current) {
// this is the most recent invocation
setErrors(errorMessages)
setValidating(false)
}
return errorMessages.length === 0
}

useEffect(() => {
if (pristine)
return // Avoid validate on mount
form.validateFields(fieldsToValidateOnChange)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])

const field = {
name,
value,
errors,
setErrors,
pristine,
onChange: (e) => {
if (pristine)
setPristine(false)

setValue(e.target.value)
},
validate,
validating,
}
form.addField(field)
return field
}

export function useForm({ onSubmit }) {
const [submitted, setSubmitted] = useState(false)
const [submitting, setSubmitting] = useState(false)
const fields = useRef([])

const validateFields = async (fieldNames) => {
let fieldsToValidate
if (Array.is(fieldNames)) {
fieldsToValidate = fields.current.filter(field =>
fieldNames.includes(field.name)
)
} else {
// If fieldNames not provided, validate all fields.
fieldsToValidate = fields.current
}
const fieldsValid = await Promise.all(
fieldsToValidate.map(field => field.validate())
)
return fieldsValid.every(Boolean)
}

const getFormData = () => {
return fields.current.reduce((formData, f) => {
formData[f.name] = f.value
return formData
}, {})
}

return {
onSubmit: async (e) => {
e.preventDefault()
setSubmitting(true)
setSubmitted(true) // User has attempted to submit form at least once
const formValid = await validateFields()
const returnVal = await onSubmit(getFormData(), formValid)
setSubmitting(false)
return returnVal
},
isValid: () => fields.current.every(f => f.errors.length === 0),
addField: field => fields.current.push(field),
getFormData,
validateFields,
submitted,
submitting,
}
}
interface Props {
label: string
name: string
value: string
onChange: Function
errors: string[]
setErrors: Function[]
pristine: boolean
validating: boolean
validate: Function
formSubmitted: boolean
}

export default function Field({
label,
name,
value,
onChange,
errors,
setErrors,
pristine,
validating,
validate,
formSubmitted,
...other
}: Props) {
const showErrors = (!pristine || formSubmitted) && !!errors.length

return (
<FormControl className="field" error={showErrors}>
<InputLabel htmlFor={name}>{label}</InputLabel>
<Input
id={name}
value={value}
onChange={onChange}
onBlur={() => !pristine && validate()}
endAdornment={(
<InputAdornment position="end">
{validating && <LoadingIcon className="rotate" />}
</InputAdornment>
)}
{...other}
/>
<FormHelperText component="div">
{showErrors
&& errors.map(errorMsg => <div key={errorMsg}>{errorMsg}</div>)}
</FormHelperText>
</FormControl>
)
}
export default function App(props) {
const form = useForm({
onSubmit: async (formData, valid) => {
if (!valid)
return
await timeout(2000) // Simulate network time
if (formData.username.length < 10) {
// Simulate 400 response from server.
usernameField.setErrors(['Make a longer username'])
} else {
// Simulate 201 response from server.
window.alert(
`form valid: ${valid}, form data: ${JSON.stringify(formData)}`
)
}
},
})

const usernameField = useField('username', form, {
defaultValue: '',
validations: [
async (formData) => {
await timeout(2000)
return formData.username.length < 6 && 'Username already exists'
},
],
fieldsToValidateOnChange: [],
})
const passwordField = useField('password', form, {
defaultValue: '',
validations: [
formData =>
formData.password.length < 6
&& 'Password must be at least 6 characters',
],
fieldsToValidateOnChange: ['password', 'confirmPassword'],
})
const confirmPasswordField = useField('confirmPassword', form, {
defaultValue: '',
validations: [
formData =>
formData.password !== formData.confirmPassword
&& 'Passwords do not match',
],
fieldsToValidateOnChange: ['password', 'confirmPassword'],
})

const requiredFields = [usernameField, passwordField, confirmPasswordField]

return (
<div id="form-container">
<form onSubmit={form.onSubmit}>
<Field
{...usernameField}
formSubmitted={form.submitted}
label="Username"
/>
<Field
{...passwordField}
formSubmitted={form.submitted}
label="Password"
type="password"
/>
<Field
{...confirmPasswordField}
formSubmitted={form.submitted}
label="Confirm Password"
type="password"
/>
<Button
type="submit"
disabled={
!form.isValid()
|| form.submitting
|| requiredFields.some(f => f.pristine)
}
>
{form.submitting ? 'Submitting' : 'Submit'}
</Button>
</form>
</div>
)
}

UseState and UseRef with DOM Refs Form Hook

  • useState for form entire state.
  • useRef for form control data.
  • Function Refs bind to native <input /> elements.
  • Custom logic via hooks return function.
// https://github.com/react-hook-form/react-hook-form/blob/v7.29.0/src/logic/createFormControl.ts
function createFormControl() {
return {
register: (name, options = {}) => {
// Register input filed.
let field = get(_fields, name)
const disabledIsDefined = isBoolean(options.disabled)

set(_fields, name, {
_f: {
...(field && field._f ? field._f : { ref: { name } }),
name,
mount: true,
...options,
},
})
_names.mount.add(name)

field
? disabledIsDefined
&& set(
_formValues,
name,
options.disabled
? undefined
: get(_formValues, name, getFieldValue(field._f))
)
: updateValidAndValue(name, true, options.value)

return {
// Bind to Form Input Element.
ref: (ref: HTMLInputElement | null): void => {
if (ref) {
register(name, options)
field = get(_fields, name)

const fieldRef = isUndefined(ref.value)
? ref.querySelectorAll
? (ref.querySelectorAll('input,select,textarea')[0] as Ref)
|| ref
: ref
: ref
const radioOrCheckbox = isRadioOrCheckbox(fieldRef)
const refs = field._f.refs || []

if (
radioOrCheckbox
? refs.find((option: Ref) => option === fieldRef)
: fieldRef === field._f.ref
) {
return
}

set(_fields, name, {
_f: {
...field._f,
...(radioOrCheckbox
? {
refs: [...refs.filter(live), fieldRef],
ref: { type: fieldRef.type, name },
}
: { ref: fieldRef }),
},
})

updateValidAndValue(name, false, undefined, fieldRef)
} else {
field = get(_fields, name, {})

if (field._f)
field._f.mount = false
;(_options.shouldUnregister || options.shouldUnregister)
&& !(isNameInFieldArray(_names.array, name) && _stateFlags.action)
&& _names.unMount.add(name)
}
},
value,
min,
max,
required,
disabled,
...fieldPropValues,
}
},
// Higher order function: onSubmit (Use Code) => onSubmit (Bind to Form Element).
handleSubmit: (onSubmit) => {
return (event: SubmitEvent) => {
onSubmit(this._getFormData())
}
},
}
}

function useForm() {
// Detailed logic handlers: DOM refs, field getter/setter, submit handler.
const formControl = useRef<FormControl>(createFormControl())
// Entire form state: valid, errors etc.
const formState = useState<FormState>()

return {
...formControl.current,
}
}

export default function App() {
const { register, handleSubmit } = useForm()
const onSubmit = data => console.log(data)

return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} type="text" />
<input {...register('password')} type="password" />
</form>
)
}

Custom URL Params Hook

Storing state in the URL:

export default function useStateParams<T>(
initialState: T,
paramsName: string,
serialize: (state: T) => string,
deserialize: (state: string) => T
): [T, (state: T) => void] {
const history = useHistory()
const search = new URLSearchParams(history.location.search)

const existingValue = search.get(paramsName)
const [state, setState] = useState<T>(
existingValue ? deserialize(existingValue) : initialState
)

useEffect(() => {
// Updates state when user navigates backwards or forwards in browser history
if (existingValue && deserialize(existingValue) !== state)
setState(deserialize(existingValue))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [existingValue])

const onChange = (s: T) => {
setState(s)
const searchParams = new URLSearchParams(history.location.search)
searchParams.set(paramsName, serialize(s))
const pathname = history.location.pathname
history.push({ pathname, search: searchParams.toString() })
}

return [state, onChange]
}

Custom Router Hook

import { useContext, useEffect } from 'react'
import { __RouterContext } from 'react-router'
import useForceUpdate from 'use-force-update'

function useReactRouter() {
const forceUpdate = useForceUpdate()
const routerContext = useContext(__RouterContext)

useEffect(
() => routerContext.history.listen(forceUpdate),
[forceUpdate, routerContext]
)

return routerContext
}

Custom History Hook

import { useCallback, useReducer } from 'react'

// Initial state that we pass into useReducer
const initialState = {
// Array of previous state values updated each time we push a new state
past: [],
// Current state value
present: null,
// Will contain "future" state values if we undo (so we can redo)
future: [],
}

// Our reducer function to handle state changes based on action
function reducer(state, action) {
const { past, present, future } = state

switch (action.type) {
case 'UNDO': {
const previous = past[past.length - 1]
const newPast = past.slice(0, past.length - 1)

return {
past: newPast,
present: previous,
future: [present, ...future],
}
}
case 'REDO': {
const next = future[0]
const newFuture = future.slice(1)

return {
past: [...past, present],
present: next,
future: newFuture,
}
}
case 'SET': {
const { newPresent } = action

if (newPresent === present)
return state

return {
past: [...past, present],
present: newPresent,
future: [],
}
}
case 'CLEAR': {
const { initialPresent } = action

return {
...initialState,
present: initialPresent,
}
}
default:
throw new Error('Unsupported action type!')
}
}

// Hook
function useHistory(initialPresent) {
const [state, dispatch] = useReducer(reducer, {
...initialState,
present: initialPresent,
})

const canUndo = state.past.length !== 0
const canRedo = state.future.length !== 0

// Setup our callback functions
// We memoize with useCallback to prevent unnecessary re-renders

const undo = useCallback(() => {
if (canUndo)
dispatch({ type: 'UNDO' })
}, [dispatch, canUndo])

const redo = useCallback(() => {
if (canRedo)
dispatch({ type: 'REDO' })
}, [dispatch, canRedo])

const set = useCallback(
newPresent => dispatch({ type: 'SET', newPresent }),
[dispatch]
)

const clear = useCallback(
() => dispatch({ type: 'CLEAR', initialPresent }),
[dispatch, initialPresent]
)

// If needed we could also return past and future state
return { state: state.present, set, undo, redo, clear, canUndo, canRedo }
}

Custom Script Loading Hook

import { useEffect, useState } from 'react'

export type Status = 'idle' | 'loading' | 'ready' | 'error'
export type ScriptElt = HTMLScriptElement | null

function useScript(src: string): Status {
const [status, setStatus] = useState<Status>(src ? 'loading' : 'idle')

useEffect(
() => {
if (!src) {
setStatus('idle')
return
}

// Fetch existing script element by src
// It may have been added by another instance of this hook
// **Cache mechanism**
let script: ScriptElt = document.querySelector(`script[src="${src}"]`)

if (!script) {
// Create script
script = document.createElement('script')
script.src = src
script.async = true
script.setAttribute('data-status', 'loading')
// Add script to document body
document.body.appendChild(script)

// Store status in attribute on script
// This can be read by other instances of this hook
const setAttributeFromEvent = (event: Event) => {
script?.setAttribute(
'data-status',
event.type === 'load' ? 'ready' : 'error'
)
}

script.addEventListener('load', setAttributeFromEvent)
script.addEventListener('error', setAttributeFromEvent)
} else {
// Grab existing script status from attribute and set to state.
setStatus(script.getAttribute('data-status') as Status)
}

// Script event handler to update status in state
// Note: Even if the script already exists we still need to add
// event handlers to update the state for *this* hook instance.
const setStateFromEvent = (event: Event) => {
setStatus(event.type === 'load' ? 'ready' : 'error')
}

// Add event listeners
script.addEventListener('load', setStateFromEvent)
script.addEventListener('error', setStateFromEvent)

// Remove event listeners on cleanup
return () => {
if (script) {
script.removeEventListener('load', setStateFromEvent)
script.removeEventListener('error', setStateFromEvent)
}
}
},
[src] // Only re-run effect if script src changes
)

return status
}

export default useScript
const cachedScripts = []

function useScript(src) {
// Keeping track of script loaded and error state
const [state, setState] = useState({
loaded: false,
error: false,
})

useEffect(
() => {
// If cachedScripts array already includes src
// that means another instance ...
// ... of this hook already loaded this script, so no need to load again.
if (cachedScripts.includes(src)) {
setState({
loaded: true,
error: false,
})
} else {
cachedScripts.push(src)

// Create script
const script = document.createElement('script')
script.src = src
script.async = true

// Script event listener callbacks for load and error
const onScriptLoad = () => {
setState({
loaded: true,
error: false,
})
}

const onScriptError = () => {
// Remove from cachedScripts we can try loading again
const index = cachedScripts.indexOf(src)
if (index >= 0)
cachedScripts.splice(index, 1)
script.remove()

setState({
loaded: true,
error: true,
})
}

script.addEventListener('load', onScriptLoad)
script.addEventListener('error', onScriptError)

// Add script to document body
document.body.appendChild(script)

// Remove event listeners on cleanup
return () => {
script.removeEventListener('load', onScriptLoad)
script.removeEventListener('error', onScriptError)
}
}
},
[src] // Only re-run effect if script src changes
)

return [state.loaded, state.error]
}
// https://github.com/tylerwolff/useCookie.
import { useState } from 'react'

const isBrowser = typeof window !== 'undefined'

function stringifyOptions(options) {
return Object.keys(options).reduce((acc, key) => {
if (key === 'days') {
// Skip `days`.
return acc
} else {
if (options[key] === false)
return acc
else if (options[key] === true)
return `${acc}; ${key}`
else return `${acc}; ${key}=${options[key]}`
}
}, '')
}

function getCookie(name, initialValue = '') {
return (
(isBrowser
&& document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=')
return parts[0] === name ? decodeURIComponent(parts[1]) : r
}, ''))
|| initialValue
)
}

function setCookie(name, value, options) {
if (!isBrowser)
return

const optionsWithDefaults = {
days: 7,
path: '/',
...options,
}

const expires = new Date(
Date.now() + optionsWithDefaults.days * 864e5
).toUTCString()

document.cookie = `${name}=${encodeURIComponent(
value
)}; expires=${expires}${stringifyOptions(optionsWithDefaults)}`
}

function useCookie(key, initialValue) {
const [item, setItem] = useState(() => {
return getCookie(key, initialValue)
})

const updateItem = (value, options) => {
setItem(value)
setCookie(key, value, options)
}

return [item, updateItem]
}

Custom LocalStorage Hook

// https://www.robinwieruch.de/react-uselocalstorage-hook.
function useLocalStorage(storageKey, fallbackState) {
const [value, setValue] = React.useState(
JSON.parse(localStorage.getItem(storageKey)) || fallbackState
)

// Update logic.
React.useEffect(() => {
localStorage.setItem(storageKey, JSON.stringify(value))
}, [value, storageKey])

return [value, setValue]
}

export default function App() {
const [isOpen, setOpen] = useLocalStorage('is-open', false)

const handleToggle = () => {
setOpen(!isOpen)
}

return (
<div>
<button type="button" onClick={handleToggle}>Toggle</button>
{isOpen && <div>Content</div>}
</div>
)
}

Custom Async Data Hook

  • useState to store url and data.
  • useEffect to trigger async fetch actions.
import { useEffect, useState } from 'react'

function useFriendStatus(friendID) {
const [isOnline, setIsOnline] = useState(null)

function handleStatusChange(status) {
setIsOnline(status.isOnline)
}

useEffect(() => {
ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange)
return () => {
ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange)
}
})

return isOnline
}
export default function FriendStatus(props) {
const isOnline = useFriendStatus(props.friend.id)

if (isOnline === null)
return 'Loading...'

return isOnline ? 'Online' : 'Offline'
}
interface Props {
friend: {
id: number
name: string
}
}
export default function FriendListItem({ friend }: Props) {
const isOnline = useFriendStatus(friend.id)

return (
<li style={{ color: isOnline ? 'green' : 'black' }}>{friend.name}</li>
)
}
import axios from 'axios'
import { Fragment, useEffect, useState } from 'react'

function useDataApi(initialUrl, initialData) {
const [data, setData] = useState(initialData)
const [url, setUrl] = useState(initialUrl)
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)

const fetchData = useCallback(async () => {
setIsError(false)
setIsLoading(true)

try {
const result = await axios(url)

setData(result.data)
} catch (error) {
setIsError(true)
}

setIsLoading(false)
}, [url])

useEffect(() => {
fetchData()
}, [fetchData])

const doGet = (event, url) => {
setUrl(url)
event.preventDefault()
}

return { data, isLoading, isError, doGet }
}
function App() {
const [query, setQuery] = useState('redux')
const { data, isLoading, isError, doGet } = useDataApi(
'http://hn.algolia.com/api/v1/search?query=redux',
{ hits: [] }
)

return (
<>
<form
onSubmit={event =>
doGet(event, `http://hn.algolia.com/api/v1/search?query=${query}`)}
>
<input
type="text"
value={query}
onChange={event => setQuery(event.target.value)}
/>
<button type="submit">Search</button>
</form>

{isError && <div>Something went wrong ...</div>}

{isLoading
? (
<div>Loading ...</div>
)
: (
<ul>
{data.hits.map(item => (
<li key={item.objectID}>
<a href={item.url}>{item.title}</a>
</li>
))}
</ul>
)}
</>
)
}

export default App

TypeScript fetch hook with caches:

import type { AxiosRequestConfig } from 'axios'

import axios from 'axios'
import { useEffect, useReducer, useRef } from 'react'

// State & hook output
interface State<T> {
status: 'init' | 'fetching' | 'error' | 'fetched'
data?: T
error?: string
}

type Cache<T> = Record<string, T>

// discriminated union type
type Action<T> =
| { type: 'request' }
| { type: 'success', payload: T }
| { type: 'failure', payload: string }

function useFetch<T = unknown>(
url?: string,
options?: AxiosRequestConfig
): State<T> {
const cache = useRef<Cache<T>>({})
const cancelRequest = useRef<boolean>(false)

const initialState: State<T> = {
status: 'init',
error: undefined,
data: undefined,
}

// Keep state logic separated
const fetchReducer = (state: State<T>, action: Action<T>): State<T> => {
switch (action.type) {
case 'request':
return { ...initialState, status: 'fetching' }
case 'success':
return { ...initialState, status: 'fetched', data: action.payload }
case 'failure':
return { ...initialState, status: 'error', error: action.payload }
default:
return state
}
}

const [state, dispatch] = useReducer(fetchReducer, initialState)

useEffect(() => {
if (!url)
return

const fetchData = async () => {
dispatch({ type: 'request' })

if (cache.current[url]) {
dispatch({ type: 'success', payload: cache.current[url] })
} else {
try {
const response = await axios(url, options)
cache.current[url] = response.data

if (cancelRequest.current)
return

dispatch({ type: 'success', payload: response.data })
} catch (error) {
if (cancelRequest.current)
return

dispatch({ type: 'failure', payload: error.message })
}
}
}

fetchData()

return () => {
cancelRequest.current = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url])

return state
}

export default useFetch

Custom Data Query Hook

import type { UseQueryOptions } from 'react-query'
import firebase from 'firebase/app'
import { useEffect } from 'react'
import { useQuery, useQueryClient } from 'react-query'
import 'firebase/auth'
import 'firebase/database'

// This value is the default 403 code from firebase
const PERMISSION_DENIED_STATUS_CODE = 'PERMISSION_DENIED'

export interface RealTimeFetchParams {
path: string
}

export interface RealTimeSubscribeParams<T> {
path: string
event?: firebase.database.EventType
callback: (value: T) => void
}

export interface RealTimeUnsubscribeParams {
path: string
event?: firebase.database.EventType
}

export class RealTimeApi {
private firebase: firebase.app.App

constructor() {
this.handleAuthenticationErrors = this.handleAuthenticationErrors.bind(this)

this.firebase = firebase.initializeApp({
apiKey: process.env.REACT_APP_FIREBASE_WEB_API_KEY,
databaseURL: process.env.REACT_APP_FIREBASE_DATABASE_URL,
projectId: process.env.REACT_APP_FIREBASE_DATABASE_URL,
messagingSenderId: process.env.REACT_APP_FIREBASE_SENDER_ID,
appId: process.env.REACT_APP_FIREBASE_APP_ID,
})
}

private handleAuthenticationErrors(error: firebase.FirebaseError) {
if (error.code === PERMISSION_DENIED_STATUS_CODE) {
// handle logout any way you want. For example, if you were using
// AWS Cognito, you'd call `Auth.logout()`
}
}

public connect(token: string) {
return this.firebase.auth().signInWithCustomToken(token)
}

public disconnect() {
return this.firebase.auth().signOut()
}

public fetch<T>({ path }: RealTimeFetchParams) {
return new Promise<T>((resolve) => {
this.firebase
.database()
.ref(path)
.once(
'value',
(snapshot) => {
resolve(snapshot.val())
},
this.handleAuthenticationErrors
)
})
}

public subscribe<T>({
path,
callback,
event = 'value',
}: RealTimeSubscribeParams<T>) {
const ref = this.firebase.database().ref(path)
const cb = (snapshot: firebase.database.DataSnapshot) => {
callback(snapshot.val() as T)
}

ref.on(event, cb, this.handleAuthenticationErrors)
return () => ref.off(event, cb)
}

public unsubscribe({ path, event = 'value' }: RealTimeUnsubscribeParams) {
this.firebase.database().ref(path).off(event)
}
}

const realTimeApi = new RealTimeApi()

function useRealTimeQuery<Data>(
firebasePathKey: string,
useQueryOptions: UseQueryOptions<Data> = {}
) {
const queryClient = useQueryClient()

useEffect(() => {
const unsubscribe = realTimeApi.subscribe<Data>({
path: firebasePathKey,
callback: (val) => {
queryClient.setQueryData(firebasePathKey, val)
},
})

return () => unsubscribe()
}, [queryClient, firebasePathKey])

return useQuery<Data, Error>(
firebasePathKey,
() => new Promise<Data>(() => {}),
useQueryOptions
)
}

export default useRealTimeQuery

Custom Store Hook

Simple implementation:

import { useState } from 'react'

export const store = {
state: {},
setState(value) {
this.state = value
this.setters.forEach(setter => setter(this.state))
},
setters: [],
}

// Bind the setState function to the store object so
// we don't lose context when calling it elsewhere
store.setState = store.setState.bind(store)

// this is the custom hook we'll call on components.
export default function useStore() {
const [state, set] = useState(store.state)

if (!store.setters.includes(set))
store.setters.push(set)

return [state, store.setState]
}

Complex implementation:

import type {
Action,
ActionId,
ActionTree,
KBarOptions,
KBarProviderProps,
KBarState,
} from './types'
import { deepEqual } from 'fast-equals'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { VisualState } from './types'

type useStoreProps = KBarProviderProps

export default function useStore(props: useStoreProps) {
if (!props.actions) {
throw new Error(
'You must define a list of `actions` when calling KBarProvider'
)
}

const [state, setState] = useState<KBarState>({
searchQuery: '',
currentRootActionId: null,
visualState: VisualState.hidden,
actions: props.actions.reduce((acc, current) => {
acc[current.id] = current
return acc
}, {}),
})

const currentState = useRef(state)
currentState.current = state

const getState = useCallback(() => currentState.current, [])
const publisher = useMemo(() => new Publisher(getState), [getState])

useEffect(() => {
currentState.current = state
publisher.notify()
}, [publisher, state])

const optionsRef = useRef((props.options || {}) as KBarOptions)

const registerActions = useCallback((actions: Action[]) => {
const actionsByKey: ActionTree = actions.reduce((acc, current) => {
acc[current.id] = current
return acc
}, {})

setState(state => ({
...state,
actions: {
...actionsByKey,
...state.actions,
},
}))

return function unregister() {
setState((state) => {
const actions = state.actions
const removeActionIds = Object.keys(actionsByKey)
removeActionIds.forEach(actionId => delete actions[actionId])
return {
...state,
actions: {
...state.actions,
...actions,
},
}
})
}
}, [])

return useMemo(() => {
return {
getState,
query: {
setCurrentRootAction: (actionId: ActionId | null | undefined) => {
setState(state => ({
...state,
currentRootActionId: actionId,
}))
},
setVisualState: (
cb: ((vs: VisualState) => VisualState) | VisualState
) => {
setState(state => ({
...state,
visualState: typeof cb === 'function' ? cb(state.visualState) : cb,
}))
},
setSearch: (searchQuery: string) =>
setState(state => ({
...state,
searchQuery,
})),
registerActions,
},
options: optionsRef.current,
subscribe: (
collector: <C>(state: KBarState) => C,
cb: <C>(collected: C) => void
) => publisher.subscribe(collector, cb),
}
}, [getState, publisher, registerActions])
}

class Publisher {
getState
subscribers: Subscriber[] = []

constructor(getState: () => KBarState) {
this.getState = getState
}

subscribe<C>(
collector: (state: KBarState) => C,
onChange: (collected: C) => void
) {
const subscriber = new Subscriber(
() => collector(this.getState()),
onChange
)
this.subscribers.push(subscriber)
return this.unsubscribe.bind(this, subscriber)
}

unsubscribe(subscriber: Subscriber) {
if (this.subscribers.length) {
const index = this.subscribers.indexOf(subscriber)
if (index > -1)
return this.subscribers.splice(index, 1)
}
}

notify() {
this.subscribers.forEach(subscriber => subscriber.collect())
}
}

class Subscriber {
collected: any // Previous state cache.
collector
onChange

constructor(collector: () => any, onChange: (collected: any) => any) {
this.collector = collector
this.onChange = onChange
}

collect() {
try {
// Grab latest state.
const recollect = this.collector()
if (!deepEqual(recollect, this.collected)) {
this.collected = recollect
if (this.onChange)
this.onChange(this.collected)
}
} catch (error) {
console.warn(error)
}
}
}

Custom Recoil Hook

Recoil minimal implementation:

  • Atom: collect children callbacks as listeners, notify children when value changed.
  • Selector: collect parent Atoms as deps, update value when parent Atoms notified.
interface Disconnector {
disconnect: () => void
}

class Stateful<T> {
private listeners = new Set<(value: T) => void>()

constructor(private value: T) {}

protected _update(value: T) {
this.value = value
this.notify()
}

snapshot(): T {
return this.value
}

notify() {
for (const listener of this.listeners) listener(this.snapshot())
}

subscribe(callback: (value: T) => void): Disconnector {
this.listeners.add(callback)
return {
disconnect: () => {
this.listeners.delete(callback)
},
}
}
}

class Atom<T> extends Stateful<T> {
update(value: T) {
super._update(value)
}
}

interface GeneratorContext {
get: <V>(dependency: Stateful<V>) => V
}

type SelectorGenerator<T> = (context: GeneratorContext) => T

export class Selector<T> extends Stateful<T> {
private registeredDeps = new Set<Stateful>()

constructor(private readonly generate: SelectorGenerator<T>) {
super(undefined as any)
const context = { get: dep => this.getDep(dep) }
this.value = generate(context)
}

private getDep<V>(dep: Stateful<V>): V {
if (!this.registeredDeps.has(dep)) {
// Update when parent Atom changed.
dep.subscribe(() => this.updateSelector())
this.registeredDeps.add(dep)
}

return dep.snapshot()
}

private updateSelector() {
const context = { get: dep => this.getDep(dep) }
this.update(this.generate(context))
}
}

export function atom<V>(value: { key: string, default: V }): Atom<V> {
return new Atom(value.default)
}

export function selector<V>(value: {
key: string
get: SelectorGenerator<V>
}): Selector<V> {
return new Selector(value.get)
}

// This hook will re-render whenever supplied `Stateful` value changes.
// It can be used with `Selector` or `Atom`.
export function useCoiledValue<T>(value: Stateful<T>): T {
const [, updateState] = useState({})

// Force update when value changed.
useEffect(() => {
const { disconnect } = value.subscribe(() => updateState({}))
return () => disconnect()
}, [value])

return value.snapshot()
}

// Similar to above method, but it also lets set state.
// It only can be used with `Atom`.
export function useCoiledState<T>(atom: Atom<T>): [T, (value: T) => void] {
const value = useCoiledValue(atom)
return [value, useCallback(value => atom.update(value), [atom])]
}
function generate(context) {
// Register NameAtom as a dependency and get its snapshot value:
// get(nameAtom) => selector.getDep(nameAtom)
// => nameAtom.subscribe(() => selector.updateSelector) + selector.deps.add(nameAtom)
const name = context.get(nameAtom)
// Do the same for AgeAtom
const age = context.get(ageAtom)

// Return new value using parent atoms.
// E.g. 'Bob is 20 years old'.
return `${name} is ${age} years old.`
}

Custom Atom Hook

Simple global store based on:

  • Subscribe pattern.
  • UseState hook.
  • Atomic state library: e.g Jotai.
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import { nanoid } from 'nanoid'
import { useEffect, useRef, useState } from 'react'

const store = new Map<string, any>()

class Atom<T> {
key = nanoid()
subscribers = new Map<
MutableRefObject<boolean>,
Dispatch<SetStateAction<T>>
>()

private _current: T

constructor(initialState: T) {
store.set(this.key, initialState)
this._current = initialState
}

subscribe(
ref: MutableRefObject<boolean>,
action: Dispatch<SetStateAction<T>>
) {
this.subscribers.set(ref, action)
}

unsubscribe(ref: MutableRefObject<boolean>) {
this.subscribers.delete(ref)
}

setState(nextState: T) {
this._current = nextState
store.set(this.key, nextState)
this.subscribers.forEach(action => action(nextState))
}

get current() {
return this._current
}
}

export const atom = <T>(initialState: T) => new Atom(initialState)

export function useAtomValue<T>(atom: Atom<T>) {
const ref = useRef(false)
const [state, setState] = useState(atom.current)

if (ref.current === false) {
ref.current = true
atom.subscribe(ref, setState)
}

useMount(() => () => atom.unsubscribe(ref))

return state
}

export function setAtomValue<T>(atom: Atom<T>) {
return (nextState: T) => atom.setState(nextState)
}

Custom Zustand Hook

Zustand internals:

/**
* For more on the useSyncExternalStore hook.
* @see https://react.dev/reference/react/useSyncExternalStore
*/
import { useSyncExternalStore } from 'react'

// https://github.com/pmndrs/zustand/blob/fe47d3e6c6671dbfb9856fda52cb5a3a855d97a6/src/vanilla.ts#L57-L94
function createStore(createState) {
let state
let initialState
const listeners = new Set()

const setState = (partial) => {
const nextState = typeof partial === 'function' ? partial(state) : partial

if (!Object.is(nextState, state)) {
const previousState = state
state = Object.assign({}, state, nextState)
listeners.forEach(listener => listener(state, previousState))
}
}

const getState = () => state
const getInitialState = () => initialState

const subscribe = (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
}

const api = { setState, getState, getInitialState, subscribe }
initialState = state = createState(setState, getState, api)

return api
}

// https://github.com/pmndrs/zustand/blob/fe47d3e6c6671dbfb9856fda52cb5a3a855d97a6/src/react.ts#L21
const identity = state => state

// https://github.com/pmndrs/zustand/blob/fe47d3e6c6671dbfb9856fda52cb5a3a855d97a6/src/react.ts#L29-L40
function useStore(api, selector = identity) {
const slice = useSyncExternalStore(
api.subscribe,
() => selector(api.getState()),
() => selector(api.getInitialState())
)

return slice
}

// https://github.com/pmndrs/zustand/blob/fe47d3e6c6671dbfb9856fda52cb5a3a855d97a6/src/react.ts#L56-L64
function create(createState) {
const api = createStore(createState)
const useBoundStore = selector => useStore(api, selector)
Object.assign(useBoundStore, api)

return useBoundStore
}

// Usage
const useCountStore = create(set => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 })),
decrement: () => set(state => ({ count: state.count - 1 })),
}))
function App() {
return (
<div>
<Counter1 />
<Counter2 />
</div>
)
}

function Counter1() {
const { count, increment, decrement } = useCountStore()

return (
<div>
<h2>Counter1</h2>
<div>{count}</div>
<button type="button" onClick={decrement}>-</button>
<button type="button" onClick={increment}>+</button>
</div>
)
}

function Counter2() {
const { count, increment, decrement } = useCountStore()

return (
<div>
<h2>Counter2</h2>
<div>{count}</div>
<button type="button" onClick={decrement}>-</button>
<button type="button" onClick={increment}>+</button>
</div>
)
}

Hooks Best Practice

如果将一个函数任意地将其放在 useEffect Deps List 中 可能会导致重复无意义的 useEffect 执行 (因为每次 render 期间的此函数都会重新定义). 有两个解决办法:

  • 对于被多次复用 Utils 函数 (且不依赖组件的任何值), 应该提到组件外面的公共区域去定义.
  • 对于只被特定 Effect Hook 调用的 Utils 函数, 可以放到 useEffect 内部定义.
  • 对于其他需要在组件内(或自定义 Hooks 内)定义的函数, 可使用 useCallback 包裹函数, 并设置正确的 Deps List, 尽可能地减少 render 时重新定义此函数.
// ✅ Not affected by the data flow
function getFetchUrl(query) {
return `https://hn.algolia.com/api/v1/search?query=${query}`
}

function SearchResults() {
useEffect(() => {
const url = getFetchUrl('react')
// ... Fetch data and do something ...
}, []) // ✅ Deps are OK

useEffect(() => {
const url = getFetchUrl('redux')
// ... Fetch data and do something ...
}, []) // ✅ Deps are OK

// ...
}
  • Don't forget to cleanup side effects (return function in useEffect)
  • Set correct deps list for useEffect:
    • avoid object deps (should use object property).
    • enough deps list to avoid infinite loop rendering pitfall.
    • enough deps list to avoid stale closure.
  • setState(state => state + 1) is better (avoid outdated state).
  • Change useState to useRef when values not for rendering.
  • Don't put any if statement before hooks function.
  • Only call Hooks at the top level (don't inside loops, conditions or nested functions).
  • Only call Hooks from React function components.