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:
{
"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
Keep components small and focused
Break down complex components into smaller, reusable pieces.
Use TypeScript for type safety
Define proper types for props, state, and event handlers.
Optimize with useMemo and useCallback
Memoize expensive computations and callbacks to prevent unnecessary re-renders.
Clean up effects properly
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