Hono and React full stack dev
↑english ver
Hono は ultra-fast で軽量な Web 標準フレームワークです。
Hono 単体での SPA 開発は大変ですが、React と組み合わせることで Next や Remix のようなフルスタック機能を実現できます。
Hono はツリー構造のルーティングなど、React だけでは不可能な表現を可能にします。
また、小さなバンドルサイズや Node.js に依存しないため、Cloudflare のようなプラットフォームで低コストかつ簡単にデプロイ可能です。
いくつかのプロダクトで Hono をつかったときは Server-Sent Events や JWT 認証などを AWS Lambda に短期間でデプロイできました。
examples
- honojs/honox: HonoX
- yusukebe/honox-playground
- yusukebe/honox-examples: HonoX examples
- yusukebe (yusukebe) / Repositories
articles
- hono / honox について
- d1 について
- hono + d1 について
getting started
Hono 環境を構築するには、以下のコマンドを実行します。今回は file based rooting を使いたいため x-basic テンプレートを選択しましたが、他のテンプレートを選ぶことで、Cloudflare などでさらに速く全世界にデプロイすることができます。
$ npm create hono@latest
create-hono version 0.7.0
✔ Target directory … hono
✔ Which template do you want to use? › x-basic
cloned honojs/starter#main to $/glre/examples/hono
? Do you want to install project dependencies? no
🎉 Copied project files
Get started with: cd hono
ここまでの diff
setup
react や vite を使うために、honox-playground/projects/react を参考にしました。package.json
の dependencies に react や tailwind などを追加します。
npm i @hono/react-renderer @types/react @types/react-dom autoprefixer postcss react@18 react-dom@18 tailwindcss
// package.json "@cloudflare/workers-types": "4", + "@hono/react-renderer": "latest", "@hono/vite-cloudflare-pages": "latest", + "@types/react": "18", + "@types/react-dom": "18", + "autoprefixer": "10", + "postcss": "8", + "react": "18", + "react-dom": "18", + "tailwindcss": "3",
tsconfig.json
から "jsxImportSource": "hono/jsx"
を削除し、Hono の default の設定の依存を減らします。(お好みですがいつも通り Next.js 同様の設定にしました。)
// tsconfig.json { "compilerOptions": { "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", "jsxImportSource": "react", "incremental": true, }, "include": ["**/*.ts", "**/*.tsx"], "exclude": ["node_modules"] }
React の SSR でエラーが発生しないように、vite.config.ts に ssr.external
を追加します。(サーバーで実行できないパッケージをすべて指定する必要があります。)
// vite.config.ts
...
export default defineConfig(() => {
return {
+ ssr: {
+ external: ['react', 'react-dom'],
+ },
plugins: [honox(), pages()],
}
});
support react
hono/jsx
から React への移行のため、_renderer.tsx
に @hono/react-renderer
を適応します。(react だけでなく、preact や solid のような任意の UI ライブラリも使うこともできます。)
// app/routes/_renderer.tsx
import { reactRenderer } from '@hono/react-renderer'
export default reactRenderer(({ children, title }) => {
const src = import.meta.env.PROD
? '/static/client.js'
: '/app/client.ts'
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script type="module" src={src}></script>
</head>
<body>{children}</body>
</html>
)
})
React case
You can define a renderer using
@hono/react-renderer
. Install the modules first.npm i @hono/react-renderer react react-dom hono npm i -D @types/react @types/react-dom
Define the Props that the renderer will receive in
global.d.ts
.// global.d.ts import '@hono/react-renderer' declare module '@hono/react-renderer' { interface Props { title?: string } }
...
ドキュメント通り、app/client.tsx
を変更して React hydration と rendering を有効にします。(現状 Type Error がたくさん出力されるので、今後 honox の コードを修正する必要があります。)
// app/client.tsx
import { createClient } from 'honox/client'
- createClient()
+ createClient({
+ hydrate: async (elem, root) => {
+ const { hydrateRoot } = await import('react-dom/client')
+ hydrateRoot(root, elem)
+ },
+ createElement: async (type, props) => {
+ const { createElement } = await import('react')
+ return createElement(type, props)
+ },
+ })
代わりに tailwind を採用しますので routes/index.ts
から hono/css
を取り除きます。
// app/routes/index.ts
- import { css } from 'hono/css'
import { createRoute } from 'honox/factory'
import Counter from '../islands/counter'
- const className = css`
- font-family: sans-serif;
- `
export default createRoute((c) => {
const name = c.req.query('name') ?? 'Hono'
return c.render(
- <div class={className}>
+ <div>
<h1>Hello, {name}!</h1>
<Counter />
</div>,
counter.tsx
で hono/jsx
から React への切り替えを行い、既存のデモを React を使用して実行可能にします。 yarn dev
でサーバーを起動すると、これらの変更が確認できます。
// app/islands/counter.tsx
- import { useState } from 'hono/jsx'
+ import { useState } from 'react'
ここまでの diff
support tailwind
Hono と組み合わせて Tailwind CSS を使用するために、tailwind.config.js
, postcss.config.js
, app/style.css
を作成します。(Next.js 同様いつも通りですが、document にも方法が書いてあります)
Using Tailwind CSS
Given that HonoX is Vite-centric, if you wish to utilize Tailwind CSS, simply adhere to the official instructions.
Prepare
tailwind.config.js
andpostcss.config.js
:// tailwind.config.js export default { content: ['./app/**/*.tsx'], theme: { extend: {}, }, plugins: [], }
// postcss.config.js export default { plugins: { tailwindcss: {}, autoprefixer: {}, }, }
Write
app/style.css
:/* app/style.css */ @tailwind base; @tailwind components; @tailwind utilities;
...
_renderer.tsx
を修正して tailwind css が読み込まれるようにします。
// app/routes/_renderer.tsx
import { reactRenderer } from '@hono/react-renderer'
export default reactRenderer(({ children, title }) => {
+ const href = import.meta.env.PROD
+ ? 'static/assets/style.css'
+ : '/app/style.css'
...
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
+ <link href={href} rel="stylesheet" />
<script type="module" src={src}></script>
...
routes/index.ts
についてhono/css
を既に消したので、 試しに代わりの Tailwind クラスを追加してみます。
// app/routes/index.ts
export default createRoute((c) => {
const name = c.req.query('name') ?? 'Hono'
return c.render(
- <div>
+ <div className="font-sans">
vite.config.js
を修正し、プロダクションのビルド時に tailwind が build されるように設定を追加します。
// vite.config.js
export default defineConfig(({ mode }) => {
if (mode === 'client') {
return {
+ build: {
+ rollupOptions: {
+ input: ['/app/style.css'],
+ output: {
+ assetFileNames: 'static/assets/[name].[ext]'
+ }
+ }
+ },
plugins: [client()],
}
...
ここまでの diff
add d1 sqlite
Build a Comments API · Cloudflare D1 docs の公式 docs と honox-playground/projects/cloudflare-bindings のコードを参考に実装できそうです。
wrangler のターミナルにログインするなどしてローカルでセットアップした後、以下のコマンドを実行します。
テキストが生成されますので、wrangler.toml
として保存します。
npx wrangler d1 create xxx
# wrangler.toml [[ d1_databases ]] binding = "DB" # i.e. available in your Worker on env.DB database_name = "xxx" database_id = "yyyyyyyyyyyyyyyyyyyyyyyy"
dump.sql
で Cloudflare D1 のデータベーススキーマを定義し、テーブルと初期データ設定をします。
(本番環境の Cloudflare Pages / Worker から D1 をつかうには、 Cloudflare の Console から、 Settings/Function/D1 database bindings
を選択し、Variable name
property に DB
, D1 database
property に作成した database_name = xxx
を指定してバインディングする必要があります。)
-
npx wrangler d1 execute xxx --local --file=./app/schemas/dump.sql
(xxx は指定した名前で、--local
を外すと Cloudflare に deploy されます。) -
npx wrangler d1 execute xxx --local --command='SELECT * FROM creation'
(テーブル内をチェックできます。)
/* app/schemas/dump.sql */
DROP TABLE IF EXISTS `creation`;
CREATE TABLE `creation` (
`id` TEXT PRIMARY KEY,
`title` TEXT DEFAULT NULL,
`content` TEXT DEFAULT NULL,
`created_at` TEXT DEFAULT (datetime('now')),
`updated_at` TEXT DEFAULT (datetime('now'))
);
INSERT INTO `creation` (id, title, content) VALUES ('a_id', 'a_title', 'a_content');
INSERT INTO `creation` (id, title, content) VALUES ('b_id', 'b_title', 'b_content');
INSERT INTO `creation` (id, title, content) VALUES ('c_id', 'c_title', 'c_content');
┌──────┬─────────┬───────────┬─────────────────────┬─────────────────────┐ │ id │ title │ content │ created_at │ updated_at │ ├──────┼─────────┼───────────┼─────────────────────┼─────────────────────┤ │ a_id │ a_title │ a_content │ 2024-04-30 11:59:59 │ 2024-04-30 11:59:59 │ ├──────┼─────────┼───────────┼─────────────────────┼─────────────────────┤ │ b_id │ b_title │ b_content │ 2024-04-30 11:59:59 │ 2024-04-30 11:59:59 │ ├──────┼─────────┼───────────┼─────────────────────┼─────────────────────┤ │ c_id │ c_title │ c_content │ 2024-04-30 11:59:59 │ 2024-04-30 11:59:59 │ └──────┴─────────┴───────────┴─────────────────────┴─────────────────────┘
vite.config.ts
を修正して開発ビルド用に wrangler を設定します。
(server.watch.ignored
に .mf
を追加しないと vite がクラッシュするバグがあったのですが、
修正 PR をだしたらすぐに merge されました 🎉)
// vite.config.ts
...
+ import { getPlatformProxy } from 'wrangler'
- export default defineConfig(({ mode }) => {
+ export default defineConfig(async ({ mode }) => {
if (mode === 'client') {
...
} else {
+ const { env, dispose } = await getPlatformProxy();
return {
ssr: {
external: ['react', 'react-dom'],
},
- plugins: [honox(), pages()],
+ plugins: [
+ honox({
+ devServer: {
+ env,
+ plugins: [{ onServerClose: dispose }],
+ },
+ }),
+ pages(),
+ ],
}
ここまでの diff
make service
Query D1 from Hono · Cloudflare D1 docs の公式 docs と honox-examples/projects/blog at main · yusukebe/honox-examples のコードを参考に実装できそうです!
テキストを作成・更新・削除するための API として、routes/index.tsx
, routes/new.tsx
, routes/[userId]/[id]/index.tsx
の 3 つの route を作成しました。
// app/routes/index.tsx import { cors } from 'hono/cors' import { createRoute } from 'honox/factory' import App from '../islands/home' export const GET = createRoute(cors(), async (c) => { const { results } = await c.env.DB.prepare(`select * from creation`).all() const creationItems = results return c.render(<App creationItems={creationItems} />) })
// app/routes/new.tsx import { z } from 'zod' import { cors } from 'hono/cors' import { createRoute } from 'honox/factory' import { zValidator } from '@hono/zod-validator' import App from '../islands/new' export const GET = createRoute(cors(), async (c) => { return c.render(<App />) }) const schema = z.object({ title: z.string().min(1), content: z.string().min(1), }) export const POST = createRoute( cors(), zValidator('json', schema, (result, c) => { if (!result.success) return c.render('Error') }), async (c) => { const { title, content } = c.req.valid('json') const id = crypto.randomUUID() const { success } = await c.env.DB.prepare( `INSERT INTO creation (id, title, content) VALUES (?, ?, ?)` ) .bind(id, title, content) .run() if (success) { c.status(201) return c.json({ id }) } else { c.status(500) return c.json({ message: 'Something went wrong' }) } } ) export type CreateAppType = typeof POST
// app/routes/[userId]/[id]/index.tsx import { z } from 'zod' import { createRoute } from 'honox/factory' import { basicAuth } from 'hono/basic-auth' import { zValidator } from '@hono/zod-validator' import { cors } from 'hono/cors' import App from '../../../islands/edit' const AUTH = basicAuth({ username: 'username', password: 'password', }) export const GET = createRoute(cors(), AUTH, async (c) => { const { id } = c.req.param() const { results } = await c.env.DB.prepare( `select * from creation where id = ?` ) .bind(id) .all() const item = results[0] return c.render( <App creationId={id} creationTitle={item.title} creationContent={item.content} /> ) }) const schema = z.object({ title: z.string().min(1), content: z.string().min(1), }) export const PUT = createRoute( cors(), zValidator('json', schema, (result, c) => { if (!result.success) { return c.render('Error', { hasScript: true, }) } }), async (c) => { const { id } = c.req.param() const { title, content } = c.req.valid('json') const { success } = await c.env.DB.prepare( `UPDATE creation SET title = ?, content = ? WHERE id = ?` ) .bind(title, content, id) .run() if (success) { c.status(201) return c.json({ id }) } else { c.status(500) return c.json({ message: 'Something went wrong' }) } } ) export const DELETE = createRoute(cors(), async (c) => { const { id } = c.req.param() const { success } = await c.env.DB.prepare( `DELETE FROM creation WHERE id = ?` ) .bind(id) .run() if (success) { c.status(201) return c.json({ message: 'Deleted' }) } else { c.status(500) return c.json({ message: 'Something went wrong' }) } })
vercel の v0.dev という AI で UI を生成してくれるサービスを使ってベースデザインを作りました。
v0.dev のプロンプトは chatgpt に教えてもらい、出力されたコードをいい感じにするといい感じになります!
あとは npm run deploy
を実行しアプリケーションをデプロイして完了です!
Cloudflare のデプロイ速度が AWS のような他サービスが数分かかるのに対して爆速なのもすごく好きです!
↑ play.glre.dev であそべます 💪
Discussion