Skip to content

JSX

hono/jsx를 사용하면 JSX 문법으로 HTML을 작성할 수 있다.

hono/jsx는 클라이언트 측에서도 동작하지만, 주로 서버 측에서 콘텐츠를 렌더링할 때 사용하게 될 것이다. 서버와 클라이언트 모두에 공통적으로 적용되는 JSX 관련 내용을 살펴보자.

설정

JSX를 사용하려면 tsconfig.json을 다음과 같이 수정한다:

tsconfig.json:

json
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "hono/jsx"
  }
}

또는 프라그마 지시자를 사용할 수도 있다:

ts
/** @jsx jsx */
/** @jsxImportSource hono/jsx */

Deno를 사용하는 경우, tsconfig.json 대신 deno.json을 수정해야 한다:

json
{
  "compilerOptions": {
    "jsx": "precompile", 
    "jsxImportSource": "hono/jsx"
  }
}

사용법

index.tsx:

tsx
import { Hono } from 'hono'
import type { FC } from 'hono/jsx'

const app = new Hono()

const Layout: FC = (props) => {
  return (
    <html>
      <body>{props.children}</body>
    </html>
  )
}

const Top: FC<{ messages: string[] }> = (props: {
  messages: string[]
}) => {
  return (
    <Layout>
      <h1>Hello Hono!</h1>
      <ul>
        {props.messages.map((message) => {
          return <li>{message}!!</li>
        })}
      </ul>
    </Layout>
  )
}

app.get('/', (c) => {
  const messages = ['Good Morning', 'Good Evening', 'Good Night']
  return c.html(<Top messages={messages} />)
})

export default app

프래그먼트

여러 엘리먼트를 그룹화할 때 추가 노드를 생성하지 않으려면 프래그먼트를 사용한다:

tsx
import { Fragment } from 'hono/jsx'

const List = () => (
  <Fragment>
    <p>첫 번째 자식</p>
    <p>두 번째 자식</p>
    <p>세 번째 자식</p>
  </Fragment>
)

또는 설정이 되어 있다면 <></>를 사용해 작성할 수도 있다.

tsx
const List = () => (
  <>
    <p>첫 번째 자식</p>
    <p>두 번째 자식</p>
    <p>세 번째 자식</p>
  </>
)

PropsWithChildren

PropsWithChildren을 사용하면 함수형 컴포넌트에서 자식 엘리먼트를 올바르게 추론할 수 있다.

tsx
import { PropsWithChildren } from 'hono/jsx'

type Post = {
  id: number
  title: string
}

function Component({ title, children }: PropsWithChildren<Post>) {
  return (
    <div>
      <h1>{title}</h1>
      {children}
    </div>
  )
}

Raw HTML 직접 삽입

HTML을 직접 삽입하려면 dangerouslySetInnerHTML을 사용한다:

tsx
app.get('/foo', (c) => {
  const inner = { __html: 'JSX &middot; SSR' }
  const Div = <div dangerouslySetInnerHTML={inner} />
})

메모이제이션

memo를 사용해 계산된 문자열을 메모이제이션하여 컴포넌트를 최적화한다:

tsx
import { memo } from 'hono/jsx'

const Header = memo(() => <header>Welcome to Hono</header>)
const Footer = memo(() => <footer>Powered by Hono</footer>)
const Layout = (
  <div>
    <Header />
    <p>Hono is cool!</p>
    <Footer />
  </div>
)

Context

useContext를 사용하면 props를 통해 값을 전달하지 않고도 컴포넌트 트리의 어느 레벨에서나 데이터를 전역적으로 공유할 수 있다.

tsx
import type { FC } from 'hono/jsx'
import { createContext, useContext } from 'hono/jsx'

const themes = {
  light: {
    color: '#000000',
    background: '#eeeeee',
  },
  dark: {
    color: '#ffffff',
    background: '#222222',
  },
}

const ThemeContext = createContext(themes.light)

const Button: FC = () => {
  const theme = useContext(ThemeContext)
  return <button style={theme}>Push!</button>
}

const Toolbar: FC = () => {
  return (
    <div>
      <Button />
    </div>
  )
}

// ...

app.get('/', (c) => {
  return c.html(
    <div>
      <ThemeContext.Provider value={themes.dark}>
        <Toolbar />
      </ThemeContext.Provider>
    </div>
  )
})

비동기 컴포넌트

hono/jsx는 비동기 컴포넌트를 지원한다. 따라서 컴포넌트 내에서 async/await를 사용할 수 있다. c.html()로 렌더링하면 자동으로 await 처리가 된다.

tsx
const AsyncComponent = async () => {
  await new Promise((r) => setTimeout(r, 1000)) // 1초 대기
  return <div>Done!</div>
}

app.get('/', (c) => {
  return c.html(
    <html>
      <body>
        <AsyncComponent />
      </body>
    </html>
  )
})

Suspense Experimental

React와 유사한 Suspense 기능을 사용할 수 있다. 비동기 컴포넌트를 Suspense로 감싸면, fallback에 있는 내용이 먼저 렌더링되고, Promise가 해결되면 기다렸던 내용이 표시된다. renderToReadableStream()과 함께 사용할 수 있다.

tsx
import { renderToReadableStream, Suspense } from 'hono/jsx/streaming'

//...

app.get('/', (c) => {
  const stream = renderToReadableStream(
    <html>
      <body>
        <Suspense fallback={<div>loading...</div>}>
          <Component />
        </Suspense>
      </body>
    </html>
  )
  return c.body(stream, {
    headers: {
      'Content-Type': 'text/html; charset=UTF-8',
      'Transfer-Encoding': 'chunked',
    },
  })
})

ErrorBoundary Experimental

ErrorBoundary를 사용하면 자식 컴포넌트에서 발생한 오류를 잡을 수 있다.

아래 예제에서는 오류가 발생할 경우 fallback에 지정한 내용을 보여준다.

tsx
function SyncComponent() {
  throw new Error('Error')
  return <div>Hello</div>
}

app.get('/sync', async (c) => {
  return c.html(
    <html>
      <body>
        <ErrorBoundary fallback={<div>Out of Service</div>}>
          <SyncComponent />
        </ErrorBoundary>
      </body>
    </html>
  )
})

ErrorBoundary는 비동기 컴포넌트와 Suspense와 함께 사용할 수도 있다.

tsx
async function AsyncComponent() {
  await new Promise((resolve) => setTimeout(resolve, 2000))
  throw new Error('Error')
  return <div>Hello</div>
}

app.get('/with-suspense', async (c) => {
  return c.html(
    <html>
      <body>
        <ErrorBoundary fallback={<div>Out of Service</div>}>
          <Suspense fallback={<div>Loading...</div>}>
            <AsyncComponent />
          </Suspense>
        </ErrorBoundary>
      </body>
    </html>
  )
})

HTML 미들웨어와의 통합

JSX와 HTML 미들웨어를 결합해 강력한 템플릿 기능을 활용할 수 있다. 더 자세한 내용은 HTML 미들웨어 문서를 참고한다.

tsx
import { Hono } from 'hono'
import { html } from 'hono/html'

const app = new Hono()

interface SiteData {
  title: string
  children?: any
}

const Layout = (props: SiteData) =>
  html`<!doctype html>
    <html>
      <head>
        <title>${props.title}</title>
      </head>
      <body>
        ${props.children}
      </body>
    </html>`

const Content = (props: { siteData: SiteData; name: string }) => (
  <Layout {...props.siteData}>
    <h1>Hello {props.name}</h1>
  </Layout>
)

app.get('/:name', (c) => {
  const { name } = c.req.param()
  const props = {
    name: name,
    siteData: {
      title: 'JSX with html sample',
    },
  }
  return c.html(<Content {...props} />)
})

export default app

JSX 렌더러 미들웨어 활용

JSX 렌더러 미들웨어를 사용하면 JSX를 통해 HTML 페이지를 더 쉽게 만들 수 있다.

타입 정의 재정의

여러분은 커스텀 엘리먼트와 속성을 추가하기 위해 타입 정의를 재정의할 수 있다.

ts
declare module 'hono/jsx' {
  namespace JSX {
    interface IntrinsicElements {
      'my-custom-element': HTMLAttributes & {
        'x-event'?: 'click' | 'scroll'
      }
    }
  }
}

Released under the MIT License.