A note on how hooks work in React





Good day, friends!



I want to share with you some insights into how React works, namely, assumptions about why hooks cannot be used in ifs, loops, regular functions, etc. And can they really not be used in this way?



The question is: why can hooks be used only at the top level? Here's what the official documentation says about it.



Let's start with the rules for using hooks .



Use hooks only at the top level (highlighted the key points to pay attention to):



“Don't call hooks inside loops, conditionals, or nested functions. Instead, always use hooks only inside React functions, before returning any value from them. This rule ensures that the hooks are called in the same sequence each time the component is rendered . This will allow React to properly persist the hook state between multiple calls to useState and useEffect. (If you are interested, a detailed explanation is below.) "



We are interested, see below.



Explanation (examples omitted for brevity):



"… React useState? : React .… , React . , ?… . React , useState. React , persistForm, , . , , , , .… .… , ..."



Clear? Yes, somehow not very much. What do you mean, "React relies on the order in which hooks are called"? How he does it? What is this “some kind of inner state”? What are the errors caused by missing a hook on re-render? Are these errors critical for the application to work?



Is there anything else in the documentation about this? There is a special section "Hooks: Answers to Questions" . There we find the following.



How does React bind hook calls to a component?



«React , .… , . JavaScript-, . , useState(), ( ) . useState() .»



Already something. An internal list of memory locations associated with components and containing some data. The hook reads the value of the current cell and moves the pointer to the next. What data structure does this remind you of? Perhaps we are talking about a linked (linked) list .



If this is indeed the case, then the sequence of hooks that React generates when it first renders looks like this (imagine the rectangles are hooks, each hook contains a pointer to the next one):





Great, we have a working hypothesis that looks more or less reasonable. How do we check it? A hypothesis is a hypothesis, but I want facts. And for the facts you have to go to GitHub, to the React source repository .



Do not think that I immediately decided to take such a desperate step. Of course, first, in search of answers to my questions, I turned to the omniscient Google. Here's what we found:





All of these sources refer to the React sources. I had to dig a little in them. So, the thesis and the example of "useState".



The useState () and other hooks are implemented in ReactHooks.js :



export function useState<S>(
  initialState: (() => S) | S
): [S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher()
  return dispatcher.useState(initialState)
}

      
      





A dispatcher is used to call useState () (and other hooks). At the beginning of the same file, we see the following:



import ReactCurrentDispatcher from './ReactCurrentDispatcher'

function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current

  return ((dispatcher: any): Dispatcher)
}

      
      





The dispatcher used to call useState () (and other hooks) is the value of the "current" property of the "ReactCurrentDispatcher" object, which is imported from ReactCurrentDispatcher.js :



import type { Dispatcher } from 'react-reconciler/src/ReactInternalTypes'

const ReactCurrentDispatcher = {
  current: (null: null | Dispatcher)
}

export default ReactCurrentDispatcher

      
      





ReactCurrentDispatcher is an empty object with a "current" property. This means that it is initialized somewhere else. But where exactly? Hint: imports of type "Dispatcher" indicate that the current dispatcher has something to do with React internals. Indeed, this is what we find in ReactFiberHooks.new.js (the number in the comment is the line number):



// 118
const { ReactCurrentDispatcher, ReactCurrentBatchConfig } = ReactSharedInternals

      
      





However, in ReactSharedInternals.js we run into "secret internal data that could be fired for using":



const ReactSharedInternals =
  React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED

export default ReactSharedInternals

      
      





Is that all? Has our quest come to an end before it can begin? Not really. We won't know the details of React's internal implementation, but we don't need it to understand how React handles hooks. Back in ReactFiberHooks.new.js:



// 405
ReactCurrentDispatcher.current =
  current === null || current.memoizedState === null
    ? HooksDispatcherOnMount
    : HooksDispatcherOnUpdate

      
      





The dispatcher used to call the hooks is actually two different dispatchers - HooksDispatcherOnMount (on mount) and HooksDispatcherOnUpdate (on update, re-render).



// 2086
const HooksDispatcherOnMount: Dispatcher = {
  useState: mountState,
  //     -
}

// 2111
const HooksDispatcherOnUpdate: Dispatcher = {
  useState: updateState,
  //     -
}

      
      





The mount / update separation is maintained at the hook level.



function mountState<S>(
  initialState: (() => S) | S
): [S, Dispatch<BasicStateAction<S>>] {
  //   
  const hook = mountWorkInProgressHook()
  //      
  if (typeof initialState === 'function') {
    initialState = initialState()
  }
  //       
  //          
  hook.memoizedState = hook.baseState = initialState
  //        
  //     
  const queue = (hook.queue = {
    pending: null,
    interleaved: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any)
  })
  //   -     (setState)
  const dispatch: Dispatch<
    BasicStateAction<S>
  > = (queue.dispatch = (dispatchAction.bind(
    null,
    currentlyRenderingFiber,
    queue
  ): any))
  //  ,     ,      
  return [hook.memoizedState, dispatch]
}

// 1266
function updateState<S>(
  initialState: (() => S) | S
): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, (initialState: any))
}

      
      





The "updateReducer" function is used to update the state, so we say that useState internally uses useReducer or that useReducer is a lower-level implementation of useState.



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

  const current: Hook = (currentHook: any)

  //   , ,     
  let baseQueue = current.baseQueue

  //        
  if (baseQueue !== null) {
    const first = baseQueue.next
    let newState = current.baseState

    let newBaseState = null
    let newBaseQueueFirst = null
    let newBaseQueueLast = null
    let update = first
    do {
      //    
    } while (update !== null && update !== first)

    //     
    hook.memoizedState = newState
    hook.baseState = newBaseState
    hook.baseQueue = newBaseQueueLast

    //         
    queue.lastRenderedState = newState
  }

  //  
  const dispatch: Dispatch<A> = (queue.dispatch: any)
  //     
  return [hook.memoizedState, dispatch]
}

      
      





So far, we've only seen how the hooks themselves work. Where is the list? Hint: mount / update hooks are created using the "mountWorkInProgressHook" and "updateWorkInProgressHook" functions, respectively.



// 592
function mountWorkInProgressHook(): Hook {
  //  
  const hook: Hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,

    //     (?!)
    next: null
  }

  //  workInProgressHook  null, ,      
  if (workInProgressHook === null) {
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook
  } else {
    //   ,     
    workInProgressHook = workInProgressHook.next = hook
  }
  return workInProgressHook
}

// 613
function updateWorkInProgressHook(): Hook {
  //      ,     
  //  ,      (current hook),    (. ),  workInProgressHook   ,
  //     
  //    ,    ,   
  let nextCurrentHook: null | Hook
  if (currentHook === null) {
    const current = currentlyRenderingFiber.alternate
    if (current !== null) {
      nextCurrentHook = current.memoizedState
    } else {
      nextCurrentHook = null
    }
  } else {
    nextCurrentHook = currentHook.next
  }

  let nextWorkInProgressHook: null | Hook
  if (workInProgressHook === null) {
    nextWorkInProgressHook = currentlyRenderingFiber.memoizedState
  } else {
    nextWorkInProgressHook = workInProgressHook.next
  }

  if (nextWorkInProgressHook !== null) {
    //   workInProgressHook
    workInProgressHook = nextWorkInProgressHook
    nextWorkInProgressHook = workInProgressHook.next

    currentHook = nextCurrentHook
  } else {
    //   

    //     ,     ,    
    // ,   ,      ,   
    //    ,        ?
    //      ,   "" ?
    invariant(
      nextCurrentHook !== null,
      '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
    }

    //  workInProgressHook  null, ,      
    if (workInProgressHook === null) {
      currentlyRenderingFiber.memoizedState = workInProgressHook = newHook
    } else {
      //     
      workInProgressHook = workInProgressHook.next = newHook
    }
  }
  return workInProgressHook
}

      
      





I believe our hypothesis that a linked list is used to control hooks has been confirmed. We found out that each hook has a "next" property, the value of which is a link to the next hook. Here's a good illustration of this list from the above article:







For those of you wondering, here's what a basic JavaScript implementation of a unidirectional linked list looks like:



A little bit of code
class Node {
  constructor(data, next = null) {
    this.data = data
    this.next = next
  }
}

class LinkedList {
  constructor() {
    this.head = null
  }

  insertHead(data) {
    this.head = new Node(data, this.head)
  }

  size() {
    let counter = 0
    let node = this.head

    while (node) {
      counter++
      node = node.next
    }

    return counter
  }

  getHead() {
    return this.head
  }

  getTail() {
    if (!this.head) return null

    let node = this.head

    while (node) {
      if (!node.next) return node
      node = node.next
    }
  }

  clear() {
    this.head = null
  }

  removeHead() {
    if (!this.head) return
    this.head = this.head.next
  }

  removeTail() {
    if (!this.head) return

    if (!this.head.next) {
      this.head = null
      return
    }

    let prev = this.head
    let node = this.head.next

    while (node.next) {
      prev = node
      node = node.next
    }

    prev.next = null
  }

  insertTail(data) {
    const last = this.getTail()

    if (last) last.next = new Node(data)
    else this.head = new Node(data)
  }

  getAt(index) {
    let counter = 0
    let node = this.head

    while (node) {
      if (counter === index) return node
      counter++
      node = node.next
    }
    return null
  }

  removeAt(index) {
    if (!this.head) return

    if (index === 0) {
      this.head = this.head.next
      return
    }

    const prev = this.getAt(index - 1)

    if (!prev || !prev.next) return

    prev.next = prev.next.next
  }

  insertAt(index, data) {
    if (!this.head) {
      this.head = new Node(data)
      return
    }

    const prev = this.getAt(index - 1) || this.getTail()

    const node = new Node(data, prev.next)

    prev.next = node
  }

  forEach(fn) {
    let node = this.head
    let index = 0

    while (node) {
      fn(node, index)
      node = node.next
      index++
    }
  }

  *[Symbol.iterator]() {
    let node = this.head

    while (node) {
      yield node
      node = node.next
    }
  }
}

//  
const chain = new LinkedList()

chain.insertHead(1)
console.log(
  chain.head.data, // 1
  chain.size(), // 1
  chain.getHead().data // 1
)

chain.insertHead(2)
console.log(chain.getTail().data) // 1

chain.clear()
console.log(chain.size()) // 0

chain.insertHead(1)
chain.insertHead(2)
chain.removeHead()
console.log(chain.size()) // 1

chain.removeTail()
console.log(chain.size()) // 0

chain.insertTail(1)
console.log(chain.getTail().data) // 1

chain.insertHead(2)
console.log(chain.getAt(0).data) // 2

chain.removeAt(0)
console.log(chain.size()) // 1

chain.insertAt(0, 2)
console.log(chain.getAt(1).data) // 2

chain.forEach((node, index) => (node.data = node.data + index))
console.log(chain.getTail().data) // 3

for (const node of chain) node.data = node.data + 1
console.log(chain.getHead().data) // 2

//   
function middle(list) {
  let one = list.head
  let two = list.head

  while (two.next && two.next.next) {
    one = one.next
    two = two.next.next
  }

  return one
}

chain.clear()
chain.insertHead(1)
chain.insertHead(2)
chain.insertHead(3)
console.log(middle(chain).data) // 2

//   
function circular(list) {
  let one = list.head
  let two = list.head

  while (two.next && two.next.next) {
    one = one.next
    two = two.next.next

    if (two === one) return true
  }

  return false
}

chain.head.next.next.next = chain.head
console.log(circular(chain)) // true

      
      







It turns out that when re-rendering with fewer (or more) hooks, updateWorkInProgressHook () returns a hook that does not match its position in the previous list, i.e. the new list will be missing a node (or an additional node will appear). And in the future, the wrong memoized state will be used to calculate the new state. Of course, this is a serious problem, but how critical is it? Doesn't React know how to rebuild the list of hooks on the fly? And is there some way to implement conditional hooks? Let's find this out.



Yes, before we go from source, we will look for a linter that enforces the rules for using hooks. RulesOfHooks.js :



if (isDirectlyInsideComponentOrHook) {
  if (!cycled && pathsFromStartToEnd !== allPathsFromStartToEnd) {
    const message =
      `React Hook "${context.getSource(hook)}" is called ` +
      'conditionally. React Hooks must be called in the exact ' +
      'same order in every component render.' +
      (possiblyHasEarlyReturn
        ? ' Did you accidentally call a React Hook after an' + ' early return?'
        : '')
    context.report({ node: hook, message })
  }
}

      
      





I won't go into details on how the difference between the number of hooks is determined. And here's how to define that a function is a hook:



function isHookName(s) {
  return /^use[A-Z0-9].*$/.test(s)
}

function isHook(node) {
  if (node.type === 'Identifier') {
    return isHookName(node.name)
  } else if (
    node.type === 'MemberExpression' &&
    !node.computed &&
    isHook(node.property)
  ) {
    const obj = node.object
    const isPascalCaseNameSpace = /^[A-Z].*/
    return obj.type === 'Identifier' && isPascalCaseNameSpace.test(obj.name)
  } else {
    return false
  }
}

      
      





Let's sketch out a component in which the conditional use of hooks takes place, and see what happens when it is rendered.



import { useEffect, useState } from 'react'

//   
function useText() {
  const [text, setText] = useState('')

  useEffect(() => {
    const id = setTimeout(() => {
      setText('Hello')
      const _id = setTimeout(() => {
        setText((text) => text + ' World')
        clearTimeout(_id)
      }, 1000)
    }, 1000)
    return () => {
      clearTimeout(id)
    }
  }, [])

  return text
}

//   
function useCount() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const id = setInterval(() => {
      setCount((count) => count + 1)
    }, 1000)
    return () => {
      clearInterval(id)
    }
  }, [])

  return count
}

// ,           
const Content = ({ active }) => <p>{active ? useText() : useCount()}</p>

function ConditionalHook() {
  const [active, setActive] = useState(false)

  return (
    <>
      <button onClick={() => setActive(!active)}> </button>
      <Content active={active} />
    </>
  )
}

export default ConditionalHook

      
      





In the above example, we have two custom hooks - useText () and useCount (). We are trying to use this or that hook depending on the state of the "active" variable. Render. We get the error "React Hook 'useText' is called conditionally. React Hooks must be called in the exact same order in every component render ", which says that the hooks must be called in the same order on every render.



Maybe it's not so much about React as about ESLint. Let's try to disable it. To do this, add / * eslint-disable * / at the beginning of the file. The Content component is now rendering, but switching between hooks does not work. So it's React, after all. What else can you do?



What if we make custom hooks regular functions? Trying:



function getText() {
  // ...
}

function getCount() {
  // ...
}

const Content = ({ active }) => <p>{active ? getText() : getCount()}</p>

      
      





The result is the same. The component is rendered with getCount (), but it is not possible to switch between functions. By the way, without / * eslint-disable * / we get the error “React Hook“ useState ”is called in function“ getText ”that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter ", which says that the hook is called inside a function that is neither a component nor a custom hook. There is a hint in this error.



What if we make our functions components?



function Text() {
  // ...
}

function Count() {
  // ...
}

const Content = ({ active }) => <p>{active ? <Text /> : <Count />}</p>

      
      





Now everything works as expected, even with the linter turned on. This is because we actually implemented conditional rendering of the components. Obviously, React uses a different mechanism to implement conditional rendering on components. Why couldn't this mechanism be applied to hooks?



Let's do one more experiment. We know that in the case of rendering a list of items, a "key" attribute is added to each item, allowing React to keep track of the state of the list. What if we use this attribute in our example?



function useText() {
  // ...
}

function useCount() {
  // ...
}

const Content = ({ active }) => <p>{active ? useText() : useCount()}</p>

function ConditionalHook() {
  const [active, setActive] = useState(false)

  return (
    <>
      <button onClick={() => setActive(!active)}> </button>
      {/*  key */}
      <Content key={active} active={active} />
    </>
  )
}

      
      





We get an error with the linter. Without a linter ... everything works! But why? Perhaps React considers Content with useText () and Content with useCount () as two different components and conditionally rendering the components based on the active state. Be that as it may, we found a workaround. Another example:



import { useEffect, useState } from 'react'

const getNum = (min = 100, max = 1000) =>
  ~~(min + Math.random() * (max + 1 - min))

//  
function useNum() {
  const [num, setNum] = useState(getNum())

  useEffect(() => {
    const id = setInterval(() => setNum(getNum()), 1000)
    return () => clearInterval(id)
  }, [])

  return num
}

// -
function NumWrapper({ setNum }) {
  const num = useNum()

  useEffect(() => {
    setNum(num)
  }, [setNum, num])

  return null
}

function ConditionalHook2() {
  const [active, setActive] = useState(false)
  const [num, setNum] = useState(0)

  return (
    <>
      <h3>  ? <br /> ,  </h3>
      <button onClick={() => setActive(!active)}>  </button>
      <p>{active && num}</p>
      {active && <NumWrapper setNum={setNum} />}
    </>
  )
}

export default ConditionalHook2

      
      





In the above example, we have a custom hook "useNum" that every second returns a random integer in the range from 100 to 1000. We wrap it in the "NumWrapper" component, which does not return anything (more precisely, it returns null), but ... due to the use setNum from the parent component, the state is raised. Of course, in fact, we have implemented conditional rendering of the component again. Nevertheless, this shows that, if desired, it is still possible to achieve conditional use of hooks.



The example code is here .



Sandbox:





Let's summarize. React uses a linked list to manage hooks. Each (current) hook contains a pointer to the next hook, or null (in the "next" property). This is why it is important to follow the order in which the hooks are called on each render.



Although you can achieve conditional use of hooks through conditional rendering of components, you should not do this: the consequences can be unpredictable.



A couple more observations related to the React sources: classes are practically not used, and functions and their compositions are as simple as possible (even the ternary operator is rarely used); the names of functions and variables are quite informative, although due to the large number of variables it becomes necessary to use the prefixes "base", "current", etc., which leads to some confusion, but given the size of the code base, this situation is quite natural ; there are detailed comments, including TODO.



For self-promotion: for those who want to learn or better understand the tools used in the development of modern web applications (React, Express, Mongoose, GraphQL, etc.), I suggest taking a look at this repository .



Hope you found it interesting. Constructive comments in the comments are welcome. Thank you for your attention and have a nice day.



All Articles