Skip to main content
doc0blade47/honodocumentation home
Sign in

Client DOM and Hooks

Last updated Oct 12, 2026100% grounded
Relevant source files

The following files were used as context for generating this wiki page:

Overview

Hono provides a lightweight, React-compatible client DOM rendering and reconciliation engine via hono/jsx/dom that manages browser mounting, state management, and updates. It exposes mounting APIs such as createRoot and hydrateRoot to attach JSX element trees to document containers and orchestrate tree reconciliation. Core state and lifecycle primitives like useState, useEffect, and useMemo handle component reactivity and effect execution, while advanced hooks including useTransition, useDeferredValue, and useSyncExternalStore coordinate concurrency and external data stores. Additionally, browser context propagation, error boundaries, client-specific intrinsic elements, and specialized hooks support interactive form handling and metadata management.

Sources: src/jsx/dom/client.ts:23-84, src/jsx/dom/render.ts:497-516, src/jsx/hooks/index.ts:119-176, src/jsx/dom/hooks/index.ts:41-90

Client Entry Points and Roots

Public Mounting APIs and Root Management

Hono exposes browser client mounting functions through src/jsx/dom/client.ts, providing compatibility with react-dom/client. The core API centers on createRoot, which accepts an HTMLElement or DocumentFragment render target and an optional RootOptions record. If any options are supplied, a development warning is emitted via console.warn('createRoot options are not supported yet').

export const createRoot = (
  element: HTMLElement | DocumentFragment,
  options: RootOptions = {}
): Root => {
  let setJsxNode:
    | undefined // initial state
    | ((jsxNode: unknown) => void) // rendered
    | null = // unmounted
    undefined
 
  if (Object.keys(options).length > 0) {
    console.warn('createRoot options are not supported yet')
  }
 
  return {
    render(jsxNode: unknown) {
      if (setJsxNode === null) {
        // unmounted
        throw new Error('Cannot update an unmounted root')
      }
      if (setJsxNode) {
        // rendered
        setJsxNode(jsxNode)
      } else {
        renderNode(
          buildNode({
            tag: () => {
              const [_jsxNode, _setJsxNode] = useState(jsxNode)
              setJsxNode = _setJsxNode
              return _jsxNode
            },
            props: {},
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
          } as any) as NodeObject,
          element
        )
      }
    },
    unmount() {
      setJsxNode?.(null)
      setJsxNode = null
    },
  }
}

Sources: src/jsx/dom/client.ts:23-65

The returned Root object exposes two primary methods: render(children: Child) and unmount(). The internal lifecycle state tracked by setJsxNode transitions through three phases:

  • undefined: Initial unrendered state, triggering node construction and DOM insertion via renderNode and buildNode.
  • Function: Active rendered state where subsequent calls to root.render() invoke setJsxNode(jsxNode) to schedule updates.
  • null: Unmounted state, where invoking render() throws an error (Cannot update an unmounted root), and calling unmount() sets the node value to null.
export const hydrateRoot = (
  element: HTMLElement | DocumentFragment,
  reactNode: Child,
  options: RootOptions = {}
): Root => {
  const root = createRoot(element, options)
  root.render(reactNode)
  return root
}

Sources: src/jsx/dom/client.ts:76-84

Note

In hono/jsx/dom, hydrateRoot is functionally equivalent to createRoot followed immediately by root.render(reactNode).

Sources: src/jsx/dom/client.ts:68-70, src/jsx/dom/client.ts:76-84

JSX Runtime and Compilation Setup

The package provides two JSX runtimes under src/jsx/dom/jsx-runtime.ts and src/jsx/dom/jsx-dev-runtime.ts. Both modules export jsxDEV as the underlying factory function for jsx, jsxs, and development fragments.

export const jsxDEV = (tag: string | Function, props: Props, key?: string): JSXNode => {
  if (typeof tag === 'string' && intrinsicElementTags[tag as keyof typeof intrinsicElementTags]) {
    tag = intrinsicElementTags[tag as keyof typeof intrinsicElementTags]
  }
  return {
    tag,
    type: tag,
    props,
    key,
    ref: props.ref,
  } as JSXNode
}
 
export const Fragment = (props: Record<string, unknown>): JSXNode => jsxDEV('', props, undefined)

Sources: src/jsx/dom/jsx-dev-runtime.ts:10-22

When jsxDEV processes an intrinsic element tag supplied as a string, it checks against intrinsicElementTags. If a matching component definition exists in src/jsx/dom/intrinsic-element/components, the string tag is resolved directly to that component function. The resulting JSXNode bundles the resolved tag, type, props, optional key, and ref extracted from props.ref.

Sources: src/jsx/dom/jsx-dev-runtime.ts:8-20, src/jsx/dom/jsx-runtime.ts:1-8

DOM Rendering and Node Reconciliation

Reconciliation Engine and Node Construction

The reconciliation engine manages DOM trees through applyNodeObject, which coordinates node creation, updates, and removals by evaluating virtual node objects against a container element. It processes next children arrays and cleanup lists, mapping virtual representations to live DOM nodes.

const applyNodeObject = (node: NodeObject, container: Container, isNew: boolean): void => {
  const next: Node[] = []
  const remove: Node[] = []
  const callbacks: EffectData[] = []
  getNextChildren(node, container, next, remove, callbacks)
  remove.forEach(removeNode)
  // ...
}

Sources: src/jsx/dom/render.ts:387-392

When constructing or updating DOM elements, applyNodeObject verifies whether nodes can be reused or if text nodes and element structures need instantiation. String nodes update their textContent when flagged as dirty (child.d), or get created via document.createTextNode. Elements utilize document.createElement or document.createElementNS for namespace-aware tags such as SVG and MathML, retrieving namespaces via nameSpaceMap.

Namespace KeyURI Value
svg2000/svg
math1998/Math/MathML

Sources: src/jsx/dom/render.ts:24-27, src/jsx/dom/render.ts:420-435

Note

Event aliases are normalized during property application; Change maps to Input and DoubleClick maps to DblClick.

Sources: src/jsx/dom/render.ts:19-22

Child Indexing and DOM Mutation

Child node positioning relies on findChildNodeIndex, which scans container child node collections to locate matching reference nodes. During inserts and updates, offsets are resolved relative to existing child nodes or insertion bounds, placing nodes via container.appendChild or container.insertBefore.

const findChildNodeIndex = (
  childNodes: NodeListOf<ChildNode>,
  child: ChildNode | undefined
): number | undefined => {
  if (!child) {
    return
  }
 
  for (let i = 0, len = childNodes.length; i < len; i++) {
    if (childNodes[i] === child) {
      return i
    }
  }
 
  return
}

Sources: src/jsx/dom/render.ts:369-384

Portals use the internal marker _hp (HONO_PORTAL_ELEMENT) to adjust tracking offsets, decrementing the offset counter so portal contents bypass standard container index assumptions. Once element trees and insertions complete, accumulated lifecycle callbacks are executed. Layout effects (useLayoutEffect) and insertion effects (useInsertionEffect) fire synchronously, while standard effects (useEffect) are scheduled asynchronously via requestAnimationFrame.

    if (child.tag === HONO_PORTAL_ELEMENT) {
      offset--
    } else if (isNew) {
      if (!el.parentNode) {
        container.appendChild(el)
      }
    } else if (childNodes[offset] !== el && childNodes[offset - 1] !== el) {
      if (childNodes[offset + 1] === el) {
        container.appendChild(childNodes[offset])
      } else {
        container.insertBefore(el, insertBeforeNode || childNodes[offset] || null)
      }
    }

Sources: src/jsx/dom/render.ts:444-457

Core State and Lifecycle Hooks

Stash Indices and State Hooks

Client hook execution relies on internal stash arrays mapped to each component node via the DOM_STASH symbol constant. Within src/jsx/hooks/index.ts, stashes are categorized using specific numeric indices to segregate different hook types.

Stash Index ConstantNumerical ValueTarget Hook Type
STASH_SATE0State hooks (useState)
STASH_EFFECT1Effect hooks (useEffect, useLayoutEffect, useInsertionEffect)
STASH_CALLBACK2Callback hooks (useCallback)
STASH_MEMO3Memoization hooks (useMemo)
STASH_REF4Reference hooks (useRef)

Sources: src/jsx/constants.ts:3, src/jsx/hooks/index.ts:8-12

The useState implementation resolves its initial state by evaluating initializer functions if provided, checking whether buildDataStack contains an active node object context. If no active build data exists, it returns the initial state paired with a no-op updater. Otherwise, it retrieves or initializes the state array inside the node stash, increments the hook index counter, and stores a state-updater tuple.

export const useState: UseStateType = <T>(
  initialState?: T | (() => T)
): [T, UpdateStateFunction<T>] => {
  const resolveInitialState = () =>
    typeof initialState === 'function' ? (initialState as () => T)() : (initialState as T)
 
  const buildData = buildDataStack.at(-1) as [unknown, NodeObject]
  if (!buildData) {
    return [resolveInitialState(), () => {}]
  }
  const [, node] = buildData
 
  const stateArray = (node[DOM_STASH][1][STASH_SATE] ||= [])
  const hookIndex = node[DOM_STASH][0]++
 
  return (stateArray[hookIndex] ||= [
    resolveInitialState(),
    (newState: T | ((currentState: T) => T)) => {
      const localUpdateHook = updateHook
      const stateData = stateArray[hookIndex]
      if (typeof newState === 'function') {
        newState = (newState as (currentState: T) => T)(stateData[0])
      }
 

Sources: src/jsx/hooks/index.ts:182-205

Dependency Resolution and Effect Scheduling

Dependency arrays are validated via isDepsChanged, which evaluates whether previous dependencies are undefined, length mismatches occur, or any individual dependency reference fails strict equality checks (dep !== prevDeps[i]).

const isDepsChanged = (
  prevDeps: readonly unknown[] | undefined,
  deps: readonly unknown[] | undefined
): boolean =>
  !prevDeps ||
  !deps ||
  prevDeps.length !== deps.length ||
  deps.some((dep, i) => dep !== prevDeps[i])
 

Sources: src/jsx/hooks/index.ts:27-35

Effect metadata is encapsulated within EffectData tuples, holding dependency arrays alongside callbacks for layout effects, cleanups, standard effects, and insertion effects.

export type EffectData = [
  readonly unknown[] | undefined, // deps
  (() => void | (() => void)) | undefined, // layout effect
  (() => void) | undefined, // cleanup
  (() => void) | undefined, // effect
  (() => void) | undefined, // insertion effect
]

Sources: src/jsx/hooks/index.ts:14-20

Note

When applyNodeObject flushes accumulated callbacks, insertion effects and layout effects execute synchronously before paint, while standard useEffect callbacks are deferred using requestAnimationFrame.

Sources: src/jsx/dom/render.ts:462-480

Concurrent and Store Hooks

Transitions and View Transitions

Advanced concurrency in Hono's JSX Runtime coordinates asynchronous renders via startTransition, useTransition, and view transitions. The runCallback helper wraps asynchronous execution by pushing a [type, promise] tuple onto the module-level pendingStack. Specifically, startTransition invokes runCallback with pending type 1, whereas startTransitionHook registers pending type 2.

// PendingType is defined in "../dom/render", 3 is used for useDeferredValue
const pendingStack: [PendingType | 3, Promise<void>][] = []
const runCallback = (type: PendingType, callback: Function): void => {
  let resolve: (() => void) | undefined
  const promise = new Promise<void>((r) => (resolve = r))
  pendingStack.push([type, promise])
  try {
    const res = callback()
    if (res instanceof Promise) {
      res.then(resolve, resolve)
    } else {
      resolve!()
    }
  } finally {
    pendingStack.pop()
  }
}
 
export const startTransition = (callback: () => void): void => {
  runCallback(1, callback)
}
const startTransitionHook = (callback: () => void | Promise<void>): void => {
  runCallback(2, callback)
}

Sources: src/jsx/hooks/index.ts:101-124

The useTransition hook reads the current active build stack frame to determine execution context. It maintains local error and boolean state variables via useState. When a state update occurs within a transition callback, startTransitionLocalHook triggers an internal boolean toggle and catches any asynchronous errors to propagate them into the component's error boundary stack.

export const useTransition = (): [boolean, (callback: () => void | Promise<void>) => void] => {
  const buildData = buildDataStack.at(-1) as [Context, NodeObject]
  if (!buildData) {
    return [false, () => {}]
  }
 
  const [error, setError] = useState<[Error]>()
  const [state, updateState] = useState<boolean>()
  if (error) {
    throw error[0]
  }
  const startTransitionLocalHook = useCallback<typeof startTransitionHook>(
    (callback) => {
      startTransitionHook(() => {
        updateState((state) => !state)
        let res = callback()
        if (res instanceof Promise) {
          res = res.catch((e) => {
            setError([e])
          })
        }
        return res
      })
    },
    [state]
  )
 
  const [context] = buildData
  return [context[0] === 2, startTransitionLocalHook]
}

Sources: src/jsx/hooks/index.ts:126-155

View transitions leverage browser-native document.startViewTransition APIs where supported, falling back to immediate synchronous execution and resolved promise stubs if the API is absent.

const documentStartViewTransition: (cb: () => void) => { finished: Promise<void> } = (cb) => {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  if ((document as any)?.startViewTransition) {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    return (document as any).startViewTransition(cb)
  } else {
    cb()
    return { finished: Promise.resolve() }
  }
}

Sources: src/jsx/hooks/index.ts:43-52

The viewTransitionHook coordinates two-phase updates, checking node version stamps (node.vC) before and after DOM commitment to safely execute transition callbacks while mutating viewTransitionState.

Sources: src/jsx/hooks/index.ts:55-77

Note

The useViewTransition hook returns a boolean indicating transition activity along with the startViewTransition dispatcher, returning [false, () => {}] safely if called outside an active render tree build.

Sources: src/jsx/hooks/index.ts:89-99

Deferred Values

The useDeferredValue hook delays value updates during concurrent rendering passes. It accepts a target value and optional initial state, maintaining a tuple of [cachedValue, currentValue] in internal state. If the incoming value matches the cached secondary value via Object.is, it returns immediately. Otherwise, it pushes pending type 3 onto pendingStack, assigns a temporary updateHook to update the deferred reference once children finish rendering, and updates state.

type UseDeferredValue = <T>(value: T, initialValue?: T) => T
export const useDeferredValue: UseDeferredValue = <T>(value: T, ...rest: [T | undefined]): T => {
  const [values, setValues] = useState<[T, T]>(
    (rest.length ? [rest[0], rest[0]] : [value, value]) as [T, T]
  )
  if (Object.is(values[1], value)) {
    return values[1]
  }
 
  pendingStack.push([3, Promise.resolve()])
  updateHook = async (context: Context, _, cb: (context: Context) => void) => {
    cb(context)
    values[0] = value
  }
  setValues([values[0], value])
  updateHook = undefined
  pendingStack.pop()
 
  return values[0]
}

Sources: src/jsx/hooks/index.ts:157-176

Client Context and Error Handling

Scoped Context Propagation

Context propagation in Hono's DOM JSX implementation relies on factory-generated provider functions created via createContextProviderFunction. When a context provider renders, it pushes its current value onto the module-level values array and appends an internal marker node equipped with DOM_INTERNAL_TAG to execute values.push(value).

export const createContextProviderFunction =
  <T>(values: T[]): Function =>
  ({ value, children }: { value: T; children: Child[] }) => {
    if (!children) {
      return undefined
    }
 
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const props: { children: any } = {
      children: [
        {
          tag: setInternalTagFlag(() => {
            values.push(value)
          }),
          props: {},
        },
      ],
    }

Sources: src/jsx/dom/context.ts:7-24

After processing the children array, the provider appends a closing internal node that pops the value off the stack (values.pop()). Furthermore, the returned fragment object attaches a DOM_ERROR_HANDLER symbol property to intercept failures, ensuring that if rendering throws an exception within the provider subtree, the context stack value is correctly popped before the error bubbles upward.

  props.children.push({
    tag: setInternalTagFlag(() => {
      values.pop()
    }),
    props: {},
  })
  const res = { tag: '', props, type: '' }
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  ;(res as any)[DOM_ERROR_HANDLER] = (err: unknown) => {
    values.pop()
    throw err
  }
  return res
}

Sources: src/jsx/dom/context.ts:30-43

The base createContext function initializes this values array with the provided defaultValue, wraps it in createContextProviderFunction, appends the resulting context object to the global globalContexts registry, and exposes .Provider pointing directly to the provider function.

export const createContext = <T>(defaultValue: T): Context<T> => {
  const values = [defaultValue]
  const context: Context<T> = createContextProviderFunction(values) as Context<T>
  context.values = values
  context.Provider = context
  globalContexts.push(context as Context<unknown>)
  return context
}

Sources: src/jsx/dom/context.ts:45-52

Note

The DOM_INTERNAL_TAG flag is set via setInternalTagFlag to mark push and pop operations, distinguishing context boundary nodes from regular intrinsic elements during tree construction.

Sources: src/jsx/dom/context.ts:18-31, src/jsx/dom/utils.ts:3-6

Error Boundary and Suspense Coordination

Component error management and asynchronous chunk loading are coordinated by specialized wrapper components that attach error interception hooks through the DOM_ERROR_HANDLER symbol. The ErrorBoundary component wraps its children in a fragment and assigns an error handler that checks whether the caught error is a Promise. If it represents a promise, it rethrows it to propagate asynchronous suspension; otherwise, it invokes any custom onError callback and renders either the fallbackRender result or the static fallback node.

export const ErrorBoundary: FC<
  PropsWithChildren<{
    fallback?: Child
    fallbackRender?: FallbackRender
    onError?: ErrorHandler
  }>
> = (({ children, fallback, fallbackRender, onError }: any) => {
  const res = Fragment({ children })
  ;(res as any)[DOM_ERROR_HANDLER] = (err: any) => {
    if (err instanceof Promise) {
      throw err
    }
    onError?.(err)
    return fallbackRender?.(err) || fallback
  }
  return res
}) as any

Sources: src/jsx/dom/components.ts:7-23

Conversely, the Suspense component expects thrown values to be promises. Its DOM_ERROR_HANDLER implementation validates that the caught error is an instance of Promise; if a non-promise error is thrown, it bypasses suspension and rethrows the error. When a valid promise is caught, it attaches a .finally(retry) listener to trigger a re-render attempt once the asynchronous resource resolves, returning the fallback element immediately.

export const Suspense: FC<PropsWithChildren<{ fallback: any }>> = (({
  children,
  fallback,
}: any) => {
  const res = Fragment({ children })
  ;(res as any)[DOM_ERROR_HANDLER] = (err: any, retry: () => void) => {
    if (!(err instanceof Promise)) {
      throw err
    }
    err.finally(retry)
    return fallback
  }
  return res
}) as any

Sources: src/jsx/dom/components.ts:25-38

Component SymbolTarget PropertyError Type HandlingFallback Resolution
ErrorBoundaryDOM_ERROR_HANDLERRethrows if err instanceof Promise; invokes onError(err) for synchronous errors.Returns fallbackRender?.(err) or static fallback.
SuspenseDOM_ERROR_HANDLERThrows if !(err instanceof Promise); chains err.finally(retry).Returns static fallback.

Sources: src/jsx/dom/components.ts:7-38, src/jsx/constants.ts:2

DOM Specific Intrinsic Elements

Document Metadata Hoisting

Hono provides client-side functional components for document metadata tags—specifically title, script, style, link, and meta—that intercept standard rendering to manage head elements dynamically. The internal helper documentMetadataTag queries document.head to locate existing tags and checks for deduplication keys defined in deDupeKeyMap.

const documentMetadataTag = (
  tag: string,
  props: Props,
  preserveNodeType: PreserveNodeType | undefined,
  supportSort: boolean,
  supportBlocking: boolean
) => {
  if (props?.itemProp) {
    return {
      tag,
      props,
      type: tag,
      ref: props.ref,
    }
  }
 
  const head = document.head
 
  let { onLoad, onError, precedence, blocking, ...restProps } = props
  let element: HTMLElement | null = null
  let created = false
 
  const deDupeKeys = deDupeKeyMap[tag]
  const deDupeByKey = shouldDeDupeByKey(tag, supportSort)

Sources: src/jsx/dom/intrinsic-element/components.ts:58-81

When deduplication matches an existing element, the component updates or reuses the node instead of appending a duplicate. For stylesheets and scripts, options like precedence and blocking='render' can coordinate asset insertion order and suspend rendering via use(promise) until stylesheets or blocking resources load.

if (supportBlocking && blocking === 'render') {
  const key = deDupeKeyMap[tag][0]
  if (key && props[key]) {
    const value = props[key]
    const promise = (blockingPromiseMap[value] ||= new Promise<Event>((resolve, reject) => {
      insert(element as HTMLElement)
      element!.addEventListener('load', resolve)
      element!.addEventListener('error', reject)
    }))
    use(promise)
  }
}

Sources: src/jsx/dom/intrinsic-element/components.ts:223-234

Intrinsic ComponentDeduplication StrategyPreserve Node Type (preserveNodeType)Blocking Support
titleChecked via documentMetadataTag (non-SVG)undefinedNo
scriptChecked if src and async are present1 (preserve self)Yes
styleChecked if href and precedence are present2 (preserve self and children)Yes
linkChecked if stylesheet without disable/precedence omissions1 (preserve self)Yes
metaChecked via documentMetadataTagundefinedNo

Sources: src/jsx/dom/intrinsic-element/components.ts:257-316

Note

If a metadata component specifies an itemProp attribute, it bypasses head-hoisting and deduplication entirely, returning a standard intrinsic element node.

Sources: src/jsx/dom/intrinsic-element/components.ts:65-72

Form Actions and Dedicated Hooks

Client DOM hooks provide state management primitives tailored for forms and asynchronous action handlers. The FormContext object maintains a FormStatus state containing a pending boolean flag, submitted data, HTTP method (get or post), and the action handler.

type FormStatus =
  | {
      pending: false
      data: null
      method: null
      action: null
    }
  | {
      pending: true
      data: FormData
      method: 'get' | 'post'
      action: string | ((formData: FormData) => void | Promise<void>)
    }
export const FormContext: Context<FormStatus> = createContext<FormStatus>({
  pending: false,
  data: null,
  method: null,
  action: null,
})

Sources: src/jsx/dom/hooks/index.ts:11-29

Active actions are tracked in a module-level actions set via registerAction, ensuring pending promises can be awaited to coordinate optimistic updates. The useFormStatus hook consumes FormContext, while useOptimistic uses the active actions list to automatically revert optimistic states once all pending promises settle.

const actions: Set<Promise<unknown>> = new Set()
export const registerAction = (action: Promise<unknown>) => {
  actions.add(action)
  action.finally(() => actions.delete(action))
}
 
/**
 * This hook returns the current form status
 * @returns FormStatus
 */
export const useFormStatus = (): FormStatus => {
  return useContext(FormContext)
}
 
/**
 * This hook returns the current state and a function to update the state optimistically
 * The current state is updated optimistically and then reverted to the original state when all actions are resolved
 * @param state
 * @param updateState
 * @returns [T, (action: N) => void]
 */
export const useOptimistic = <T, N>(
  state: T,
  updateState: (currentState: T, action: N) => T
): [T, (action: N) => void] => {
  const [optimisticState, setOptimisticState] = useState(state)
  if (actions.size > 0) {
    Promise.all(actions).finally(() => {
      setOptimisticState(state)
    })
  } else {
    setOptimisticState(state)
  }

Sources: src/jsx/dom/hooks/index.ts:31-63

Warning

The useActionState hook assigns the PERMALINK symbol property onto the returned action wrapper function to track permalink strings across form submissions.

Sources: src/jsx/dom/hooks/index.ts:79-90

Was this page helpful?