Skip to main content
Hono’s hono/jsx/dom module provides client-side rendering capabilities with a React-compatible API. It allows you to build interactive user interfaces that run in the browser.

Setup

Configure your tsconfig.json for client-side JSX:
tsconfig.json
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "hono/jsx/dom"
  }
}

Basic Rendering

Use the render() function to mount components to the DOM:
import { render } from 'hono/jsx/dom'

const App = () => {
  return (
    <div>
      <h1>Hello from Hono JSX DOM!</h1>
      <p>This is rendered on the client side.</p>
    </div>
  )
}

// Render to the DOM
const root = document.getElementById('root')
if (root) {
  render(<App />, root)
}
Source: src/jsx/dom/render.ts:763-766

Using createRoot (React 18+ API)

For better control over rendering, use the createRoot API:
import { createRoot } from 'hono/jsx/dom/client'

const App = () => {
  return (
    <div>
      <h1>My App</h1>
    </div>
  )
}

const root = createRoot(document.getElementById('root')!)
root.render(<App />)

// Later, you can update or unmount
// root.render(<UpdatedApp />)
// root.unmount()
Source: src/jsx/dom/client.ts:23-66

Hooks

Hono JSX DOM supports all major React hooks:

useState

Manage component state:
import { useState } from 'hono/jsx/dom'

const Counter = () => {
  const [count, setCount] = useState(0)
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
      <button onClick={() => setCount(count - 1)}>
        Decrement
      </button>
      <button onClick={() => setCount(0)}>
        Reset
      </button>
    </div>
  )
}
Source: src/jsx/hooks/index.ts:182-243

useEffect

Perform side effects:
import { useState, useEffect } from 'hono/jsx/dom'

const Timer = () => {
  const [seconds, setSeconds] = useState(0)
  
  useEffect(() => {
    const interval = setInterval(() => {
      setSeconds(s => s + 1)
    }, 1000)
    
    // Cleanup function
    return () => clearInterval(interval)
  }, []) // Empty deps array means run once on mount
  
  return <div>Elapsed: {seconds}s</div>
}
Source: src/jsx/hooks/index.ts:288-289

useRef

Access DOM elements directly:
import { useRef, useEffect } from 'hono/jsx/dom'

const FocusInput = () => {
  const inputRef = useRef<HTMLInputElement>(null)
  
  useEffect(() => {
    // Focus input on mount
    inputRef.current?.focus()
  }, [])
  
  return (
    <div>
      <input ref={inputRef} type="text" placeholder="Auto-focused" />
    </div>
  )
}
Source: src/jsx/hooks/index.ts:319-330

useCallback

Memoize callback functions:
import { useState, useCallback } from 'hono/jsx/dom'

const TodoList = () => {
  const [todos, setTodos] = useState<string[]>([])
  const [input, setInput] = useState('')
  
  const addTodo = useCallback(() => {
    if (input.trim()) {
      setTodos([...todos, input])
      setInput('')
    }
  }, [input, todos])
  
  return (
    <div>
      <input 
        value={input} 
        onChange={(e) => setInput(e.target.value)}
        onKeyPress={(e) => e.key === 'Enter' && addTodo()}
      />
      <button onClick={addTodo}>Add</button>
      <ul>
        {todos.map((todo, i) => <li key={i}>{todo}</li>)}
      </ul>
    </div>
  )
}
Source: src/jsx/hooks/index.ts:299-316

useMemo

Memoize expensive computations:
import { useState, useMemo } from 'hono/jsx/dom'

const ExpensiveComponent = ({ items }: { items: number[] }) => {
  const [multiplier, setMultiplier] = useState(1)
  
  const sum = useMemo(() => {
    console.log('Computing sum...')
    return items.reduce((acc, item) => acc + item, 0) * multiplier
  }, [items, multiplier])
  
  return (
    <div>
      <p>Sum: {sum}</p>
      <button onClick={() => setMultiplier(m => m + 1)}>
        Multiply by {multiplier + 1}
      </button>
    </div>
  )
}
Source: src/jsx/hooks/index.ts:348-363

useReducer

Manage complex state logic:
import { useReducer } from 'hono/jsx/dom'

type State = { count: number }
type Action = { type: 'increment' | 'decrement' | 'reset' }

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 }
    case 'decrement':
      return { count: state.count - 1 }
    case 'reset':
      return { count: 0 }
    default:
      return state
  }
}

const Counter = () => {
  const [state, dispatch] = useReducer(reducer, { count: 0 })
  
  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
    </div>
  )
}
Source: src/jsx/hooks/index.ts:245-258

useContext

Access context values:
import { createContext, useContext, useState } from 'hono/jsx/dom'

type Theme = 'light' | 'dark'

const ThemeContext = createContext<Theme>('light')

const ThemeToggle = () => {
  const theme = useContext(ThemeContext)
  
  return (
    <button className={`btn-${theme}`}>
      Current theme: {theme}
    </button>
  )
}

const App = () => {
  const [theme, setTheme] = useState<Theme>('light')
  
  return (
    <ThemeContext.Provider value={theme}>
      <div>
        <ThemeToggle />
        <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
          Toggle Theme
        </button>
      </div>
    </ThemeContext.Provider>
  )
}

useLayoutEffect

Perform DOM measurements before paint:
import { useLayoutEffect, useRef, useState } from 'hono/jsx/dom'

const MeasuredDiv = () => {
  const ref = useRef<HTMLDivElement>(null)
  const [height, setHeight] = useState(0)
  
  useLayoutEffect(() => {
    if (ref.current) {
      setHeight(ref.current.offsetHeight)
    }
  }, [])
  
  return (
    <div>
      <div ref={ref}>Content to measure</div>
      <p>Height: {height}px</p>
    </div>
  )
}
Source: src/jsx/hooks/index.ts:290-293

useId

Generate unique IDs for accessibility:
import { useId } from 'hono/jsx/dom'

const FormField = ({ label }: { label: string }) => {
  const id = useId()
  
  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input id={id} type="text" />
    </div>
  )
}
Source: src/jsx/hooks/index.ts:366

Event Handling

Handle user interactions with event handlers:
import { useState } from 'hono/jsx/dom'

const FormExample = () => {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: ''
  })
  
  const handleSubmit = (e: Event) => {
    e.preventDefault()
    console.log('Form submitted:', formData)
  }
  
  const handleChange = (e: Event) => {
    const target = e.target as HTMLInputElement | HTMLTextAreaElement
    setFormData({
      ...formData,
      [target.name]: target.value
    })
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        name="name"
        value={formData.name}
        onChange={handleChange}
        placeholder="Name"
      />
      <input
        type="email"
        name="email"
        value={formData.email}
        onChange={handleChange}
        placeholder="Email"
      />
      <textarea
        name="message"
        value={formData.message}
        onChange={handleChange}
        placeholder="Message"
      />
      <button type="submit">Submit</button>
    </form>
  )
}
Source: src/jsx/dom/render.ts:115-133

Hydration

Hydrate server-rendered HTML:
import { hydrateRoot } from 'hono/jsx/dom/client'

const App = () => {
  const [count, setCount] = useState(0)
  
  return (
    <div>
      <h1>Server + Client Rendered</h1>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  )
}

// Hydrate server-rendered content
const root = document.getElementById('root')!
hydrateRoot(root, <App />)
Source: src/jsx/dom/client.ts:76-84
In Hono’s implementation, hydrateRoot is equivalent to createRoot().render(). The actual DOM is rendered from scratch.

Refs and DOM Access

Callback Refs

const CallbackRefExample = () => {
  const setInputRef = (element: HTMLInputElement | null) => {
    if (element) {
      element.focus()
    }
  }
  
  return <input ref={setInputRef} type="text" />
}

Ref Objects

import { createRef } from 'hono/jsx/dom'

const RefObjectExample = () => {
  const inputRef = createRef<HTMLInputElement>()
  
  const focusInput = () => {
    inputRef.current?.focus()
  }
  
  return (
    <div>
      <input ref={inputRef} type="text" />
      <button onClick={focusInput}>Focus Input</button>
    </div>
  )
}
Source: src/jsx/hooks/index.ts:371-373

forwardRef

Forward refs to child components:
import { forwardRef } from 'hono/jsx/dom'
import type { RefObject } from 'hono/jsx/dom'

type InputProps = {
  placeholder?: string
}

const FancyInput = forwardRef<HTMLInputElement, InputProps>(
  ({ placeholder }, ref) => {
    return (
      <input
        ref={ref}
        type="text"
        placeholder={placeholder}
        className="fancy-input"
      />
    )
  }
)

const App = () => {
  const inputRef = useRef<HTMLInputElement>(null)
  
  return (
    <div>
      <FancyInput ref={inputRef} placeholder="Fancy input" />
      <button onClick={() => inputRef.current?.focus()}>
        Focus
      </button>
    </div>
  )
}
Source: src/jsx/hooks/index.ts:375-382

Suspense and Error Boundaries

Suspense

Handle async operations:
import { Suspense } from 'hono/jsx/dom'

const AsyncComponent = async () => {
  const data = await fetchData()
  return <div>{data}</div>
}

const App = () => {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <AsyncComponent />
    </Suspense>
  )
}

ErrorBoundary

Catch and handle errors:
import { ErrorBoundary } from 'hono/jsx/dom'

const ProblematicComponent = () => {
  throw new Error('Oops!')
}

const App = () => {
  return (
    <ErrorBoundary 
      fallback={<div>Something went wrong</div>}
      onError={(error) => console.error(error)}
    >
      <ProblematicComponent />
    </ErrorBoundary>
  )
}

Transitions

useTransition

Mark updates as transitions:
import { useState, useTransition } from 'hono/jsx/dom'

const SearchResults = ({ query }: { query: string }) => {
  const [isPending, startTransition] = useTransition()
  const [results, setResults] = useState<string[]>([])
  
  const handleSearch = (query: string) => {
    startTransition(() => {
      // This update is marked as non-urgent
      const newResults = performSearch(query)
      setResults(newResults)
    })
  }
  
  return (
    <div>
      <input onChange={(e) => handleSearch(e.target.value)} />
      {isPending && <div>Searching...</div>}
      <ul>
        {results.map((result, i) => <li key={i}>{result}</li>)}
      </ul>
    </div>
  )
}
Source: src/jsx/hooks/index.ts:126-155

useDeferredValue

Defer rendering of non-critical updates:
import { useState, useDeferredValue } from 'hono/jsx/dom'

const SearchComponent = () => {
  const [input, setInput] = useState('')
  const deferredInput = useDeferredValue(input)
  
  return (
    <div>
      <input 
        value={input} 
        onChange={(e) => setInput(e.target.value)}
      />
      {/* This renders with the deferred value */}
      <ExpensiveList query={deferredInput} />
    </div>
  )
}
Source: src/jsx/hooks/index.ts:158-176

Portals

Render children outside the parent hierarchy:
import { createPortal } from 'hono/jsx/dom'

const Modal = ({ children, isOpen }: { children: any; isOpen: boolean }) => {
  if (!isOpen) return null
  
  const modalRoot = document.getElementById('modal-root')!
  
  return createPortal(
    <div className="modal-overlay">
      <div className="modal-content">
        {children}
      </div>
    </div>,
    modalRoot
  )
}

const App = () => {
  const [isOpen, setIsOpen] = useState(false)
  
  return (
    <div>
      <button onClick={() => setIsOpen(true)}>Open Modal</button>
      <Modal isOpen={isOpen}>
        <h2>Modal Title</h2>
        <p>Modal content</p>
        <button onClick={() => setIsOpen(false)}>Close</button>
      </Modal>
    </div>
  )
}
Source: src/jsx/dom/render.ts:782-792

flushSync

Force synchronous updates:
import { flushSync } from 'hono/jsx/dom'

const Counter = () => {
  const [count, setCount] = useState(0)
  
  const incrementSync = () => {
    flushSync(() => {
      setCount(c => c + 1)
    })
    // DOM is updated synchronously at this point
    console.log('Count updated:', count + 1)
  }
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={incrementSync}>Increment Sync</button>
    </div>
  )
}
Source: src/jsx/dom/render.ts:768-780

Advanced Hooks

useSyncExternalStore

Subscribe to external stores:
import { useSyncExternalStore } from 'hono/jsx/dom'

const store = {
  listeners: new Set<() => void>(),
  value: 0,
  subscribe(listener: () => void) {
    this.listeners.add(listener)
    return () => this.listeners.delete(listener)
  },
  getSnapshot() {
    return this.value
  },
  increment() {
    this.value++
    this.listeners.forEach(l => l())
  }
}

const Counter = () => {
  const value = useSyncExternalStore(
    store.subscribe.bind(store),
    store.getSnapshot.bind(store)
  )
  
  return (
    <div>
      <p>Store value: {value}</p>
      <button onClick={() => store.increment()}>Increment</button>
    </div>
  )
}
Source: src/jsx/hooks/index.ts:397-425

use (React 19)

Unwrap promises in render:
import { use } from 'hono/jsx/dom'

const fetchUser = async (id: number) => {
  const res = await fetch(`/api/users/${id}`)
  return res.json()
}

const UserProfile = ({ userPromise }: { userPromise: Promise<User> }) => {
  const user = use(userPromise)
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  )
}

const App = () => {
  const userPromise = fetchUser(1)
  
  return (
    <Suspense fallback={<div>Loading user...</div>}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  )
}
Source: src/jsx/hooks/index.ts:332-346

Full Example: Todo App

import { render } from 'hono/jsx/dom'
import { useState, useCallback } from 'hono/jsx/dom'

type Todo = {
  id: number
  text: string
  completed: boolean
}

const TodoApp = () => {
  const [todos, setTodos] = useState<Todo[]>([])
  const [input, setInput] = useState('')
  const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all')
  
  const addTodo = useCallback(() => {
    if (input.trim()) {
      setTodos([
        ...todos,
        { id: Date.now(), text: input, completed: false }
      ])
      setInput('')
    }
  }, [input, todos])
  
  const toggleTodo = useCallback((id: number) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ))
  }, [todos])
  
  const deleteTodo = useCallback((id: number) => {
    setTodos(todos.filter(todo => todo.id !== id))
  }, [todos])
  
  const filteredTodos = todos.filter(todo => {
    if (filter === 'active') return !todo.completed
    if (filter === 'completed') return todo.completed
    return true
  })
  
  return (
    <div className="todo-app">
      <h1>Todo List</h1>
      
      <div className="input-section">
        <input
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyPress={(e) => e.key === 'Enter' && addTodo()}
          placeholder="Add a todo..."
        />
        <button onClick={addTodo}>Add</button>
      </div>
      
      <div className="filter-section">
        <button onClick={() => setFilter('all')}>All</button>
        <button onClick={() => setFilter('active')}>Active</button>
        <button onClick={() => setFilter('completed')}>Completed</button>
      </div>
      
      <ul className="todo-list">
        {filteredTodos.map(todo => (
          <li key={todo.id} className={todo.completed ? 'completed' : ''}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
            />
            <span>{todo.text}</span>
            <button onClick={() => deleteTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
      
      <div className="stats">
        <p>{todos.filter(t => !t.completed).length} items left</p>
      </div>
    </div>
  )
}

// Render the app
const root = document.getElementById('root')
if (root) {
  render(<TodoApp />, root)
}

Best Practices

Break down complex components into smaller, reusable pieces.
Define proper types for props, state, and event handlers.
Memoize expensive computations and callbacks to prevent unnecessary re-renders.
Always return cleanup functions from useEffect to prevent memory leaks.
Always provide unique keys when rendering lists to help with reconciliation.

Next Steps

Server Rendering

Learn about server-side JSX rendering

Streaming

Stream HTML with Suspense patterns

JSX Overview

Back to JSX overview

Build docs developers (and LLMs) love