Доброго времени суток, друзья!

Хочу поделиться с вами некоторыми наблюдениями относительно того, как работает React, а именно: предположениями о том, почему хуки нельзя использовать в if, циклах, обычных функциях и т.д. И действительно ли их нельзя использовать подобным образом?

Вопрос звучит следующим образом: почему хуки можно использовать только на верхнем уровне? Вот что по этому поводу говорит официальная документация.

Начнем с правил использования хуков.

Используйте хуки только на верхнем уровне (выделил ключевые моменты, на которые следует обратить внимание):

«Не вызывайте хуки внутри циклов, условных операторов или вложенных функций. Вместо этого всегда используйте хуки только внутри React-функций, до возврата какого-либо значения из них. Исполнение этого правила гарантирует, что хуки вызываются в одинаковой последовательности при каждом рендере компонента. Это позволит React правильно сохранять состояние хуков между множественными вызовами useState и useEffect. (Если вам интересно, подробное объяснение ниже.)»

Нам интересно, смотрим ниже.

Объяснение (примеры опущены для краткости):

"… как же React сопоставляет переменные состояния с вызовами useState? Ответ таков: React полагается на порядок вызова хуков.… До тех пор пока порядок вызова хуков одинаков в каждом рендере, React может сопоставить некое внутреннее состояние с каждым из них. Но что случится, если мы поместим вызов хука внутрь условного оператора?… во время рендера хук будет пропущен и порядок вызовов хуков изменится. React не будет знать, что вернуть для второго вызова хука useState. React ожидал, что второй вызов хука в этом компоненте соответствует эффекту persistForm, так же как при предыдущем рендере, но это больше не так. Начиная с этого момента, вызов каждого хука, следующего за пропущенным, также будет сдвинут на один назад, что приведёт к ошибкам.… Вот почему хуки должны вызываться на верхнем уровне компонента.… теперь вы знаете, почему хуки работают таким образом ..."

Понятно? Да как-то не очень. Что значит «React полагается на порядок вызова хуков»? Как он это делает? Что за «некое внутреннее состояние»? К каким ошибкам приводит пропуск хука при повторном рендере? Являются ли эти ошибки критическими для работы приложения?

Есть ли в документации что-нибудь еще по этому поводу? Есть специальный раздел «Хуки: ответы на вопросы». Там мы находим следующее.

Как React связывает вызовы хуков с компонентом?

«React следит за тем, какой компонент рендерится в данный момент.… Существует внутренний список ячеек памяти, связанных с каждым компонентом. Они являются JavaScript-объектами, в которых мы можем хранить некоторые данные. Когда вызывается некий хук, например useState(), он читает значение текущей ячейки (или инициализирует её во время первого рендера) и двигает указатель на следующую. Таким способом каждый вызов useState() получит своё независимое состояние.»

Уже кое-что. Внутренний список ячеек памяти, связанных с компонентами и содержащих некоторые данные. Хук читает значение текущей ячейки и двигает указатель на следующую. Какую структуру данных вам это напоминает? Возможно, речь идет о связанном (связном) списке.

Если это и в самом деле так, то последовательность хуков, формируемая React при первом рендеринге, выглядит следующим образом (представим, что прямоугольники — это хуки, каждый хук содержит указатель на следующий):


Отлично, у нас есть рабочая гипотеза, которая выглядит более-менее разумно. Как нам ее проверить? Гипотеза гипотезой, но хочется фактов. А за фактами придется идти на GitHub, в репозиторий с исходниками React.

Не думайте, что я сразу решился на такой отчаянный шаг. Разумеется, сначала в поисках ответов на интересующие меня вопросы я обратился к всеведущему Гуглу. Вот что удалось обнаружить:


Все эти источники отсылают к исходникам React. Пришлось немного в них покопаться. Итак, тезисно и на примере «useState».

Реализация useState() и других хуков находится в ReactHooks.js:

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

Для вызова useState() (и других хуков) используется некий диспетчер. В начале того же файла видим следующее:

import ReactCurrentDispatcher from './ReactCurrentDispatcher'

function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current

  return ((dispatcher: any): Dispatcher)
}

Диспетчер, который используется для вызова useState() (и других хуков), является значением свойства «current» объекта «ReactCurrentDispatcher», который импортируется из ReactCurrentDispatcher.js:

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

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

export default ReactCurrentDispatcher

ReactCurrentDispatcher — это пустой объект со свойством «current». Значит, инициализируется он где-то в другом месте. Но где именно? Подсказка: импорт типа «Dispatcher» указывает на то, что текущий диспетчер как-то связан с «внутренностями» React. И действительно, вот что мы находим в ReactFiberHooks.new.js (число в комментарии — это номер строки):

// 118
const { ReactCurrentDispatcher, ReactCurrentBatchConfig } = ReactSharedInternals

Однако в ReactSharedInternals.js мы упираемся в «секретные внутренние данные, за использование которых можно быть уволенным»:

const ReactSharedInternals =
  React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED

export default ReactSharedInternals

И что, это все? Неужели наши поиски, не успев начаться, подошли к концу? Не совсем. Деталей внутренней реализации React мы не узнаем, но нам это и не нужно для понимания того, как React управляет хуками. Возвращаемся в ReactFiberHooks.new.js:

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

В качестве диспетчера, который используется для вызова хуков, фактически используются два разных диспетчера — HooksDispatcherOnMount (при монтировании) и HooksDispatcherOnUpdate (при обновлении, повторном рендеринге).

// 2086
const HooksDispatcherOnMount: Dispatcher = {
  useState: mountState,
  // другие хуки и еще кое-что
}

// 2111
const HooksDispatcherOnUpdate: Dispatcher = {
  useState: updateState,
  // другие хуки и еще кое-что
}

Разделение «монтирование/обновление» сохраняется на уровне хуков.

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

Для обновления состояния используется функция «updateReducer», поэтому мы говорим, что useState внутренне использует useReducer или что useReducer — более низкоуровневая реализация 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]
}

Пока что мы увидели только, как работают сами хуки. Где же список? Подсказка: хуки при монтировании/обновлении создаются с помощью функций «mountWorkInProgressHook» и «updateWorkInProgressHook», соответственно.

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

Полагаю, наша гипотеза о том, что для управления хуками используется связный список, нашла свое подтверждение. Мы выяснили, что каждый хук имеет свойство «next», значением которого является ссылка на следующий хук. Вот хорошая иллюстрация этого списка из указанной выше статьи:



Для тех, кому интересно, вот как выглядит простейшая реализация однонаправленного связного списка на JavaScript:

Немного много кода
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


Получается, что при повторном рендеринге с меньшим (или большим) количеством хуков, updateWorkInProgressHook() возвращает хук, не соответствующий своей позиции в предыдущем списке, т.е. в новом списке будет недоставать узла (или появится дополнительный узел). И в дальнейшем для вычисления нового состояния будет использовано неправильное мемоизированное состояние. Безусловно, это серьезная проблема, но насколько она критична? Неужели React не умеет перестраивать список хуков на лету? И существует ли какой-то способ реализовать условное использование хуков? Давайте это выясним.

Да, пока мы не ушли из исходников, поищем линтер, обеспечивающий соблюдение правил использования хуков. 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 })
  }
}

Не будет вдаваться в подробности того, как определяется разница между количеством хуков. А вот как определяется, что функция — это хук:

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

Набросаем компонент, в котором имеет место условное использование хуков, и посмотрим, что произойдет при его рендеринге.

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

В приведенном примере у нас имеется два пользовательских хука — useText() и useCount(). Мы пытаемся использовать тот или иной хук в зависимости от состояния переменной «active». Рендерим. Получаем ошибку «React Hook 'useText' is called conditionally. React Hooks must be called in the exact same order in every component render», которая говорит о том, что хуки должны вызываться в одинаковом порядке при каждом рендеринге.

Может быть, дело не столько в React, сколько в ESLint. Попробуем его отключить. Для этого добавляем /* eslint-disable */ в начале файла. Теперь компонент «Content» рендерится, но переключение между хуками не работает. Значит, дело все-таки в React. Что еще можно сделать?

Что если сделать пользовательские хуки обычными функциями? Пробуем:

function getText() {
  // ...
}

function getCount() {
  // ...
}

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

Результат такой же. Компонент рендерится с getCount(), но переключиться между функциями не получается. К слову, без /* eslint-disable */ мы получим ошибку «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», которая говорит о том, что хук вызывается внутри функции, которая не является ни компонентом, ни пользовательским хуком. В этой ошибке кроется подсказка.

Что если сделать наши функции компонентами?

function Text() {
  // ...
}

function Count() {
  // ...
}

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

Теперь все работает, как ожидается, причем, даже с включенным линтером. Это объясняется тем, что мы фактически реализовали условный рендеринг компонентов. Очевидно, для реализации условного рендеринга компонентов React использует другой механизм. Почему этот механизм нельзя было применить в отношении хуков?

Проведем еще один эксперимент. Мы знаем, что в случае с рендерингом списка элементов, каждому элементу добавляется атрибут «key», позволяющий React отслеживать состояние списка. Что если использовать этот атрибут в нашем примере?

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

С линтером получаем ошибку. Без линтера… все работает! Но почему? Возможно, React считает Content с useText() и Content с useCount() двумя разными компонентами и выполняет условный рендеринг компонентов в зависимости от состояния active. Как бы то ни было, мы нашли обходной путь. Другой пример:

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

В приведенном примере у нас имеется пользовательский хук «useNum», каждую секунду возвращающий случайное целое число в диапазоне от 100 до 1000. Мы заворачиваем его в компонент «NumWrapper», который ничего не возвращает (точнее, возвращает null), но… за счет использования setNum из родительского компонента происходит подъем состояния. Конечно, фактически мы снова реализовали условный рендеринг компонента. Тем не менее, это показывает, что, при желании, добиться условного использования хуков все-таки можно.

Код примеров находится здесь.

Песочница:


Подведем итоги. Для управления хуками React использует связный список. Каждый (текущий) хук содержит указатель на следующий хук или null (в свойстве «next»). Вот почему важно соблюдать порядок вызова хуков при каждом рендеринге.

Несмотря на то, что добиться условного использования хуков через условный рендеринг компонентов можно, делать этого не следует: последствия могут быть непредсказуемыми.

Еще парочка наблюдений, связанных с исходниками React: классы практически не используются, а функции и их композиции являются максимально простыми (даже тернарный оператор используется редко); названия функций и переменных являются довольно информативными, хотя из-за большого количества переменных возникает необходимость использования префиксов «base», «current» и т.д., что приводит к некоторой путанице, но, учитывая размер кодовой базы, такая ситуация является вполне закономерной; присутствуют развернутые комментарии, включая TODO.

На правах саморекламы: для тех, кто хочет изучить или получше разобраться в инструментах, используемых при разработке современных веб-приложений (React, Express, Mongoose, GraphQL и т.д.), предлагаю взглянуть на этот репозиторий.

Надеюсь, вам было интересно. Конструктивные замечания в комментариях приветствуются. Благодарю за внимание и хорошего дня.