フルスタック Web フレームワーク HonoX を使ってみる
HonoX は Hono と Vite をベースにしたフルスタック Web フレームワークです。Hono が提供するサーバーサイドやクライアントサイドの機能を使いつつ、ファイルベースルーティングや Islands Architecture などの新しい機能を使うことができます。
HonoX は 2024 年 2 月現在アルファステージとなっています。セマンティックバージョンに従わずに破壊的な変更が行われる可能性があります。
HonoX は Hono と Vite をベースにしたフルスタック Web フレームワークです。以下のような特徴があります。
- ファイルベースのルーティング
- 高速な SSR
- BYOR(Bring Your Own Rerender):レンダリングエンジンを自由に選択できます
- islands ハイドレーション
- ミドルウェア:バックエンドは Hono として動作するため、多くの Hono のミドルウェア を使用できます
HonoX を始める
新しい HonoX のプロジェクトを作成するためには、hono-create
コマンドを使用します。
npm create hono@latest
? Which template do you want to use?
という質問に対して x-basic
を選択します。
create-hono version 0.3.2
✔ Target directory … hono-blog
? Which template do you want to use? › - Use arrow-keys. Return to submit.
↑ cloudflare-pages
cloudflare-workers
deno
fastly
lambda-edge
netlify
nextjs
nodejs
vercel
❯ x-basic
hono-create
コマンドが完了すると、以下のようなディレクトリ構造が作成されます。
.
├── app
│ ├── islands
│ │ ├── counter.tsx
│ ├── routes
│ │ ├── \_renderer.tsx
│ │ └── index.tsx
│ ├── client.ts
│ ├── global.d.ts
│ └── server.ts
├── package.json
├── tsconfig.json
└── vite.config.ts
作成されたディレクトリに移動して、npm install
と npm run dev
を実行します。
cd hono-blog
npm install
npm run dev
http://localhost:5173 にアクセスすると、以下のような画面が表示されます。
プロジェクトの構造
HonoX のプロジェクトは以下のような構造になっています。
app
:サーバーのエントリーポイント、やクライアントのすべてのコードが含まれていますapp/islands
:インタラクションを行う islands コンポーネントを含むディレクトリapp/routes
:ファイルベースのルーティングのためのディレクトリ。index.tsx
は/
に、about.tsx
は/about
にマッチするapp/routes/_renderer.tsx
:Hono の JSX Renderer Middleware が呼ばれるファイル。レイアウトとして機能するapp/client.ts
:クライアントエントリーポイントapp/server.ts
:サーバーエントリーポイント
クライアントルートを作成する
それでは、どのようにページを作成しているのか見ていきましょう。app/routes/index.tsx
は以下のようになっています。
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}>
<h1>Hello, {name}!</h1>
<Counter />
</div>,
{ title: name }
)
})
HonoX ではそれぞれのルートで Handler | MiddlewareHandler
の配列を default export
する必要があります。createRoute
はそのためのヘルパー関数です。createRoute
に渡すコールバック関数では引数として Context
を受け取ります。これは Hono の Context と同じです。ここでは c.req.query('name')
でクエリパラメータを取得しています。
c.render
関数を使ってコンポーネントをレンダリングしています。ここで使われているのは HonoX のデフォルトのレンダリングエンジンである hono/jsx です。基本的には React と同じように使えます。
c.render
の第 2 引数にはページのカスタムデータを指定できます。ここでは title
を指定して、ページのタイトルをカスタマイズしています。ここで指定した title
は _renderer.tsx
で引数として受け取り使われます。
import { Style } from 'hono/css'
import { jsxRenderer } from 'hono/jsx-renderer'
import { Script } from 'honox/server'
export default jsxRenderer(({ children, title }) => {
return (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<Script src="/app/client.ts" />
<Style />
</head>
<body>{children}</body>
</html>
)
})
c.render
の第 2 引数の型定義は app/global.d.ts
で以下のようになっています。
import {} from 'hono'
type Head = {
title?: string
}
declare module 'hono' {
interface Env {
Variables: {}
Bindings: {}
}
interface ContextRenderer {
(content: string | Promise<string>, head?: Head): Response | Promise<Response>
}
}
title
以外の description
や keywords
などのメタデータを指定したい場合には、ここの型定義と _renderer.tsx
を変更する必要があります。
CSS ライブラリとして hono/css が使われています。これは Hono のビルドインの CSS in JS ライブラリです。CSS をテンプレートリテラルで書くことができます。
import { css } from 'hono/css'
const className = css`
font-family: sans-serif;
`
hono/css
を使う場合には、_renderer.tsx
で必ず <Style>
コンポーネントを挿入する必要があります。
import { Style } from 'hono/css'
import { jsxRenderer } from 'hono/jsx-renderer'
import { Script } from 'honox/server'
export default jsxRenderer(({ children, title }) => {
return (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<Script src="/app/client.ts" />
<Style />
</head>
<body>{children}</body>
</html>
)
})
islands コンポーネント
app/routes/index.tsx
で使われている <Counter>
コンポーネントは islands コンポーネントです。islands コンポーネントとは Islands Architecture に基づいて作られたコンポーネントです。
Islans Architecture とは、ページ内でサーバー側でレンダリングされる静的な部分とインタラクティブな部分を分離する手法です。以下の図のように、サーバーでレンダリングされる大部分を海に対して、インタラクティブな部分は独立した島に見立てられることから Islands Architecture と呼ばれています。
Islands Architecture - JASON Format より引用。
Islands Architecture のメリットはパフォーマンスです。インタラクションのために必要なクライアント JavaScript の読み込みを待たずに、サーバーでレンダリングされた静的な部分をすぐに表示できることです。従来の SPA ではすべての JavaScript が読み込まれるまでページが表示されないため、ユーザーが待たされることがありました。JavaScript が必要ない大部分を先に表示することで、ユーザーエクスペリエンスを向上させることができます。
またそれぞれの islands コンポーネント同士も独立しているため、例えば優先度の高い Header は多くの JavaScript を必要とする Image Carousel の読み込みを待つ必要がありません。
Islands Architecture は HonoX の他にも Astro や Fresh などでも採用されています。
HonoX では islands コンポーネントを app/islands
ディレクトリに配置します。app/islands/counter.tsx
は以下のようになっています。
import { useState } from 'hono/jsx'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
)
}
hono/jsx
の useState
は React の useState
と同じように使えます。<button>
がクリックされると count
がインクリメントされるいった簡単な状態管理を行っているコンポーネントです。
hono/jsx
では以下のフックをサポートしています。
- useContext
- useEffect
- useState
- useCallback
- use
- startTransition
- useDeferredValue
- useMemo
- useLayoutEffect
- Memo
- isValidElement
この <Counter>
コンポーネントは routes/
ディレクトリ内のファイルから使われると、まずはサーバー側でレンダリングされます。その後クライアント側に JavaScript が送信されインタラクションが可能になります。
ブログアプリケーションのチュートリアル
HonoX を使って簡単なブログアプリケーションを作成してみましょう。以下のような機能を持つブログアプリケーションを作成します。
- 記事の作成
- 記事の一覧表示
- 記事の詳細表示
- 記事の削除
完成したコードは以下のリポジトリで確認できます。
レイアウトコンポーネントの作成
ページを作り始める前に、まずはそれぞれのページで共通して使うレイアウトコンポーネントを作成しましょう。app/components
ディレクトリを作成し、app/components/Layout.tsx
を作成します。
import { css, cx } from "hono/css";
import type { FC } from "hono/jsx";
import { useRequestContext } from "hono/jsx-renderer";
const className = css``;
const headerClass = css`
border-bottom: 1px solid #ddd;
display: flex;
gap: 1rem;
align-items: center;
padding: 0.5rem 1rem;
justify-content: space-between;
`;
const titleClass = css`
font-size: 1.5rem;
margin: 0;
`;
const navClass = css`
display: flex;
gap: 1rem;
`;
// & nesting selector https://developer.mozilla.org/en-US/docs/Web/CSS/Nesting_selector
// を使って疑似クラスをそ指定する
const linkClass = css`
&:hover {
background-color: #f4f4f4;
}
text-decoration: none;
padding: 0.5rem 1rem;
color: #262626;
border-radius: 3px;
`;
const activeLinkClass = css`
background-color: #f4f4f4;
`;
const containerClass = css`
max-width: 800px;
margin: 0 auto;
padding: 1rem;
`;
const links = [
{ href: "/articles", text: "Articles" },
{ href: "/articles/create", text: "Create" },
];
export const Layout: FC = ({ children }) => {
const c = useRequestContext();
const current = c.req.path;
return (
<div class={className}>
<header class={headerClass}>
<h1 class={titleClass}>
<a href="/" class={linkClass}>
HonoX Blog 🔥
</a>
</h1>
<nav class={navClass}>
{links.map((link) => (
<a
href={link.href}
// cx は clsx のように複数のクラスを結合する関数
class={cx(linkClass, current === link.href && activeLinkClass)}
>
{link.text}
</a>
))}
</nav>
</header>
<main class={containerClass}>{children}</main>
</div>
);
};
いくつかポイントを見ていきましょう。app/routes
ディレクトリ配下にあるファイルでは createRoute
の引数からコンテキスト(c
)を受け取っていました。ページコンポーネントから使われることが前提のコンポーネントでは、useRequestContext
を使って直接コンテキストを取得できます。
ここでは、現在のパスを取得するために c.req.path
の値を使っています。
import { useRequestContext } from "hono/jsx-renderer";
// ...
export const Layout: FC = ({ children }) => {
const c = useRequestContext();
const current = c.req.path;
// ...
};
現在の値と一致する場合には activeLinkClass
を適用するようにしています。条件によってクラス名をつける場合には cx
関数を使うとスッキリとしたコードになります。cx
関数は classnames や clsx と同じように、複数のクラスを結合する関数です。複数の引数を受け取り、falsy な値を除外して結合します。
import { css, cx } from "hono/css";
// ...
export const Layout: FC = ({ children }) => {
const c = useRequestContext();
const current = c.req.path;
return (
// ...
<nav class={navClass}>
{links.map((link) => (
<a
href={link.href}
class={cx(linkClass, current === link.href && activeLinkClass)}
>
{link.text}
</a>
))}
</nav>
// ...
);
};
作成したレイアウトコンポーネントは app/routes/_renderer.tsx
で使われます。jsxRenderer 関数はレイアウトとして機能するミドルウェアです。
import { Style } from "hono/css";
import { jsxRenderer, useRequestContext } from "hono/jsx-renderer";
import { Script } from "honox/server";
import { Layout } from "../components/Layout";
export default jsxRenderer(({ children, title }) => {
return (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<Script src="/app/client.ts" />
<Style />
</head>
<body>
<Layout>{children}</Layout>
</body>
</html>
);
});
続いてグローバル CSS を追加します。hono/css
を使ってグローバル CSS を追加するには以下の 2 つの方法があります。
:hono-global
疑似セレクタを使う<Style>
コンポーネントの中でcss
関数を使う
今回は後者の方法を使ってグローバル CSS を追加してみましょう。app/routes/_renderer.tsx
に以下のように追記します。
import { Style, css } from "hono/css";
import { jsxRenderer } from "hono/jsx-renderer";
import { Script } from "honox/server";
import { Layout } from "../components/Layout";
export default jsxRenderer(({ children, title }) => {
return (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<Script src="/app/client.ts" />
<Style>
{css`
html {
font-size: 16px;
font-family: system-ui, sans-serif;
}
body {
min-height: 100vh;
color: #262626;
background-color: #f4f4f4;
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
}`}
</Style>
</head>
<body>
<Layout>{children}</Layout>
</body>
</html>
);
});
ここまでの実装が完了したら、http://localhost:5173 にアクセスして、レイアウトと CSS が適用されていることを確認しましょう。
記事の作成
まずは記事の作成機能を作成します。app/routes
ディレクトリに articles/create.tsx
を作成します。このファイルは /articles/create
というパスにマッチするルートです。
import { css } from "hono/css";
import { createRoute } from "honox/factory";
import type { FC } from "hono/jsx";
const titleClass = css`
font-size: 1.5rem;
margin-bottom: 1rem;
`;
const formClass = css`
display: flex;
flex-direction: column;
gap: 1rem;
`;
const labelClass = css`
display: flex;
flex-direction: column;
font-weight: bold;
gap: 0.5rem;
`;
const inputClass = css`
width: 100%;
padding: 0.5rem 0.25em;
border-radius: 3px;
border: 2px solid #ddd;
`;
const textareaClass = css`
width: 100%;
border: 2px solid #ddd;
border-radius: 3px;
padding: 0.5rem;
min-height: 10rem;
resize: vertical;
`;
const buttonClass = css`
padding: 0.5rem;
background-color: #007bff;
color: white;
border: none;
cursor: pointer;
border-radius: 999px;
`;
const Page: FC = () => {
return (
<div>
<h1 class={titleClass}>Create an article</h1>
<form class={formClass} method="post">
<label class={labelClass}>
Title
<input name="title" class={inputClass} type="text" />
</label>
<label class={labelClass}>
Content
<textarea name="content" class={textareaClass} />
</label>
<button class={buttonClass} type="submit">
Create
</button>
</form>
</div>
);
};
export default createRoute((c) => {
return c.render(<Page />, {
title: "Create an article",
});
});
タイトルとコンテンツを入力するための簡単なフォームです。http://localhost:5173/articles/create にアクセスして、フォームが表示されることを確認しましょう。
POST リクエストの処理
続いてフォームの送信を処理するために、app/routes/articles/create.tsx
で POST リクエストを受け取るためのハンドラを追加します。
export default
で Handler | MiddlewareHandler
の配列(createRoute
)を返した場合にはそのハンドラは GET リクエストのハンドラとして使われます。GET リクエスト以外のリクエストを受け取るためには、対応するメソッド名の変数を名前付き export します。
今回の例では POST リクエストを受け取るために POST
という名前で変数を定義して export します。
export const POST = createRoute(async (c) => {
const body = await c.req.formData();
console.log("title:", body.get("title"));
console.log("content:", body.get("content"));
return c.redirect("/articles");
});
リクエストボディは c.req.formData()
メソッドで FormData
オブジェクトとして受け取ります。リクエストを受け取ったら c.redirect("/articles")
で記事の一覧画面へリダイレクトします(まだ記事の一覧画面は作成していないので、404 画面が表示されます)。
フォームになにか入力してサブミットボタンを押した時に、コンソールに入力した値が表示されることを確認しましょう。
title: フルスタック Web フレームワーク HonoX を使ってみる
content: HonoX は [Hono](https://hono.dev/) と [Vite](https://vitejs.dev/) をベースにしたフルスタック Web フレームワークです。 以下のような特徴があります。
ミドルウェアによるバリデーション
HonoX では Hono のミドルウェアを使うことができます。ここでは Zod Validator ミドルウェアを使ってフォームの値のバリデーションを行ってみましょう。
以下のコマンドでミドルウェアをインストールします。
npm install @hono/zod-validator zod
ミドルウェアを使うには createRoute()
の第 1 引数にミドルウェアを渡します。ミドルウェアはルートハンドラと同じように Handler | MiddlewareHandler
の配列を返す関数です。
import { z } from "zod";
import { zValidator } from "@hono/zod-validator";
const Article = z.object({
title: z.string().min(1).max(255),
content: z.string().min(1),
});
export const POST = createRoute(
zValidator("form", Article, (result, c) => {
if (result.success) {
// TODO DB に保存する
console.log(result.data);
return c.redirect("/articles");
}
return c.render(<Page />, {
title: "Create an article",
});
})
);
まずは zod
でバリデーションスキーマを定義します。Article
は title
と content
という 2 つのプロパティを持つオブジェクトです。title
は 1 文字以上 255 文字以下、content
は 1 文字以上であることをバリデーションしています。
次に zValidator
ミドルウェアを使ってバリデーションを行います。zValidator
は 3 つの引数を受け取ります。
- バリデーションの名前:バリデーションのターゲット。
form
の他にjson
,query
,header
,param
,cookie
がある - バリデーションスキーマ:
zod
で定義したバリデーションスキーマ - コールバック関数:通常のルートハンドラに追加してバリデーションの結果を引数として受け取る関数
コールバック関数の第 1 引数のオブジェクトは Zod でバリデーションした結果です。result.success
が true
の場合にはバリデーションに成功しています。result.data
にはバリデーションに成功したデータが入っています。バリデーションに失敗した場合には再度フォームを表示するようにしています。
バリデーションに失敗した場合にはエラーメッセージを表示し、前回入力した値をフォームに表示するように変更してみましょう。まずは <Page>
コンポーネントでバリデーション結果を Props として受け取れるようにします。
type Data = {
title: {
value: string;
error: string[] | undefined;
};
content: {
value: string;
error: string[] | undefined;
};
};
type Props = {
data?: Data;
};
const Page: FC<Props> = ({ data }) => {
// ...
};
この data
Props が存在する場合には、フォームにエラーメッセージと前回入力した値を表示するように変更します。
const errorClass = css`
color: red;
font-size: 0.75rem;
`;
const Page: FC<Props> = ({ data }) => {
return (
<div>
<h1 class={titleClass}>Create an article</h1>
<form class={formClass} method="post">
<label class={labelClass}>
Title
<input
name="title"
class={inputClass}
type="text"
value={data?.title.value}
/>
</label>
{data?.title.error &&
data.title.error.map((error) => <p class={errorClass}>{error}</p>)}
<label class={labelClass}>
Content
<textarea
name="content"
class={textareaClass}
value={data?.content.value}
/>
</label>
{data?.content.error &&
data.content.error.map((error) => <p class={errorClass}>{error}</p>)}
<button class={buttonClass} type="submit">
Create
</button>
</form>
</div>
);
};
そして、POST リクエストのハンドラでバリデーションに失敗した場合には、c.render()
で <Page>
コンポーネントをレンダリングする際に Props としてバリデーション結果を渡すように変更します。
export const POST = createRoute(
zValidator("form", Article, (result, c) => {
if (result.success) {
// TODO DB に保存する
console.log(result.data);
return c.redirect("/articles");
}
const { title, content } = result.data;
const data: Data = {
title: {
value: title,
error: result.error.flatten().fieldErrors.title,
},
content: {
value: content,
error: result.error.flatten().fieldErrors.content,
},
};
return c.render(<Page data={data} />, {
title: "Create an article",
});
})
);
フォームの値を空にしてサブミットボタンを押してみると、エラーメッセージが表示されることを確認できます。
データの保存
後回しにしていたデータを保存する処理を実装します。ここでは簡単のため、JSON ファイルに保存することにします。data/articles.json
というファイルに記事のデータを保存します。
mkdir data
echo '[]' > data/articles.json
app/lib/db.ts
というファイルを作成し、記事を保存するための関数を実装します。
import fs from "fs/promises";
export type Article = {
id: string;
title: string;
content: string;
created_at: string;
updated_at: string;
};
export const createArticle = async ({
title,
content,
}: Pick<Article, "title" | "content">) => {
const articlesJSON = await fs.readFile("./data/articles.json", {
encoding: "utf-8",
});
const articles: Article[] = JSON.parse(articlesJSON);
const id = crypto.randomUUID();
const created_at = new Date().toISOString();
const updated_at = created_at;
const article: Article = { id, title, content, created_at, updated_at };
articles.push(article);
await fs.writeFile("./data/articles.json", JSON.stringify(articles));
return article;
};
作成した createArticle
関数を app/routes/articles/create.tsx
で使うように修正します。
export const POST = createRoute(
zValidator("form", Article, async (result, c) => {
if (result.success) {
const { title, content } = result.data;
await createArticle({ title, content });
return c.redirect("/articles");
}
// ...
})
);
ここまでの実装が完了したら、フォームに何か入力して記事を作成してみましょう。うまくいけば、data/articles.json
に記事が追加されているはずです。
記事の一覧表示
記事の作成ができたら、次は記事の一覧表示機能を作成します。今回はまず記事の一覧を取得する関数から実装します。app/lib/db.ts
に getArticles
という関数を追加します。
export const getArticles = async () => {
const articlesJSON = await fs.readFile("./data/articles.json", {
encoding: "utf-8",
});
return JSON.parse(articlesJSON);
};
app/routes/articles/index.tsx
というファイルを作成し、記事の一覧を表示するページを作成します。
import { FC } from "hono/jsx";
import { createRoute } from "honox/factory";
import { Article, getArticles } from "../../lib/db";
import { css } from "hono/css";
type Props = {
articles: Article[];
};
const titleClass = css`
font-size: 1.5rem;
margin-bottom: 1rem;
`;
const cards = css`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
`;
const card = css`
border: 1px solid #ddd;
border-radius: 3px;
padding: 1rem;
width: 100%;
list-style: none;
`;
const Page: FC<Props> = ({ articles }) => {
return (
<div>
<h1 class={titleClass}>Articles</h1>
<ul class={cards}>
{articles.map((article) => (
<li class={card}>
<a href={`/articles/${article.id}`}>{article.title}</a>
</li>
))}
</ul>
</div>
);
};
export default createRoute(async (c) => {
const articles = await getArticles();
return c.render(<Page articles={articles} />, { title: "Articles" });
});
記事の一覧をサーバーサイドで取得して表示してる簡単なページです。http://localhost:5173/articles にアクセスして、記事の一覧が表示されることを確認しましょう。
記事の詳細表示
続いて記事の詳細画面を作りましょう。ID を指定して記事を取得する関数を app/lib/db.ts
に追加します。
export const getArticleById = async (id: string) => {
const articlesJSON = await fs.readFile("./data/articles.json", {
encoding: "utf-8",
});
const articles = JSON.parse(articlesJSON) as Article[];
return articles.find((article) => article.id === id);
};
記事のマークダウンを変換するために必要な @markdoc/markdoc パッケージをインストールします。
npm i @markdoc/markdoc
マークダウンを HTML に変換する関数を app/lib/markdown.ts
というファイルに追加します。
import Markdoc from "@markdoc/markdoc";
export const parseMarkdown = (source: string) => {
const ast = Markdoc.parse(source);
const content = Markdoc.transform(ast);
const html = Markdoc.renderers.html(content);
return html;
};
app/routes/articles/[id].tsx
というファイルを作成し、記事の詳細を表示するページを作成します。[]
で囲まれた部分は動的なパスパラメータを表します。[id].tsx
は /articles/:id
というパスにマッチするルートです。
import { FC } from "hono/jsx";
import { Article, getArticleById } from "../../lib/db";
import { css } from "hono/css";
import create from "./create";
import { createRoute } from "honox/factory";
import { parseMarkdown } from "../../lib/markdown";
const cardClass = css`
border: 1px solid #ddd;
border-radius: 3px;
padding: 1rem;
width: 100%;
`;
const titleClass = css`
font-size: 1.5rem;
margin-bottom: 1rem;
`;
const contentClass = css`
margin-top: 2rem;
p + p {
margin-top: 1rem;
}
ul {
margin-top: 1rem;
}
ul li {
list-style: disc;
margin-left: 1rem;
margin-bottom: 0.5rem;
}
`;
type Props = {
article: Article;
content: string;
};
const Page: FC<Props> = ({ article, content }) => {
return (
<article class={cardClass}>
<header>
<h1 class={titleClass}>{article.title}</h1>
</header>
<div
class={contentClass}
id="contents"
dangerouslySetInnerHTML={{ __html: content }}
/>
</article>
);
};
export default createRoute(async (c) => {
const { id } = c.req.param();
const article = await getArticleById(id);
if (!article) {
return c.notFound();
}
const content = parseMarkdown(article.content);
return c.render(<Page article={article} content={content} />, {
title: article.title,
});
});
パスパラメータを取得するために c.req.param()
を使っています。もし指定された ID の記事が存在しない場合には c.notFound()
を使って 404 エラーを返します。
export default createRoute(async (c) => {
const { id } = c.req.param();
const article = await getArticleById(id);
if (!article) {
return c.notFound();
}
const content = parseMarkdown(article.content);
return c.render(<Page article={article} content={content} />, {
title: article.title,
});
});
hono/jsx
では dangerouslySetInnerHTML
を使って HTML をエスケープせずにレンダリングできます。
記事の一覧画面から詳細画面に遷移して、記事の内容が表示されることを確認しましょう。
404 ページ
記事が存在しない時に表示される 404 ページはデフォルトではそっけない画面が表示されます。この画面をカスタマイズするために app/routes/_404.tsx
というファイルを作成します。
import { NotFoundHandler } from "hono";
const handler: NotFoundHandler = (c) => {
return c.render(<h1>Sorry, Not Found...</h1>);
};
export default handler;
エラーページ
404 ページと同じように、エラーが発生した際のエラーページをカスタマイズするために app/routes/_error.tsx
というファイルを作成します。
// app/routes/_error.ts
import { ErrorHandler } from 'hono'
const handler: ErrorHandler = (e, c) => {
return c.render(<h1>Error! {e.message}</h1>)
}
export default handler
記事の削除
最後の集大成として、記事の削除機能を作成します。まずは app/lib/db.ts
に deleteArticle
という関数を追加します。
export const deleteArticle = async (id: string) => {
const articlesJSON = await fs.readFile("./data/articles.json", {
encoding: "utf-8",
});
const articles = JSON.parse(articlesJSON) as Article[];
const newArticles = articles.filter((article) => article.id !== id);
await fs.writeFile("./data/articles.json", JSON.stringify(newArticles));
};
記事を削除するためのボタンを追加しましょう。ここではボタンをクリックした時に削除するかどうか確認するダイアログを表示し、OK をクリックしたときには <form>
でリクエストを送信して削除を実行するようにします。
ボタンをクリックした時にダイアログを表示するというインタラクションを含むため、このコンポーネントは Islands コンポーネントとして実装する必要があります。app/islands/DeleteButton.tsx
というファイルを作成し、以下のように実装します。
import { css } from "hono/css";
import { FC, useRef } from "hono/jsx";
const dialogClass = css`
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: 1px solid #ddd;
padding: 1rem;
border-radius: 3px;
background-color: white;
&::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
`;
const dialogButtons = css`
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1rem;
`;
const cancelButton = css`
padding: 0.5rem;
background-color: #ddd;
border: none;
border-radius: 3px;
cursor: pointer;
`;
const buttonClass = css`
background-color: red;
color: white;
border: none;
border-radius: 3px;
padding: 0.5rem 1rem;
cursor: pointer;
`;
export default function DeleteButton({ articleId }: { articleId: string }) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const handleClickDelete = () => {
dialogRef.current?.showModal();
};
return (
<div>
<dialog class={dialogClass} ref={dialogRef}>
<form method="POST" action={`/articles/${articleId}/delete`}>
<p>Are you sure you want to delete this article?</p>
<div class={dialogButtons}>
<button value="cancel" formmethod="dialog" class={cancelButton}>
Cancel
</button>
<button type="submit" class={buttonClass}>
OK
</button>
</div>
</form>
</dialog>
<button type="button" class={buttonClass} onClick={handleClickDelete}>
Delete
</button>
</div>
);
}
ここでは HTML 標準の <dialog>
要素を使ってダイアログを表示しています。ダイアログを表示するには、ダイアログ要素の ref
を取得し、ボタンがクリックされた時に showModal()
メソッドを呼び出します。
ダイアログのキャンセルボタンが押された時に閉じる処理は、form
要素を使って実装できます。フォームのメソッドが dialog
の場合には、サブミットボタンがダイアログを閉じるボタンとして機能するのです。
OK ボタンが押されたときには、/articles/:id/delete
に POST リクエストを送信するようにしています。app/routes/articles/[id]/delete.ts
というファイルを作成し、記事の削除を行うハンドラを実装します。
import { createRoute } from "honox/factory";
import { deleteArticle } from "../../../lib/db";
export const POST = createRoute(async (c) => {
const { id } = c.req.param();
await deleteArticle(id);
return c.redirect("/articles");
});
最後に、記事の一覧画面に DeleteButton
コンポーネントを追加します。
const card = css`
border: 1px solid #ddd;
border-radius: 3px;
padding: 1rem;
width: 100%;
list-style: none;
display: flex;
flex-direction: column;
justify-content: space-between;
`;
const Page: FC<Props> = ({ articles }) => {
return (
<div>
<h1 class={titleClass}>Articles</h1>
<ul class={cards}>
{articles.map((article) => (
<li class={card}>
<a href={`/articles/${article.id}`}>
{article.title}
</a>
<DeleteButton articleId={article.id} />
</li>
))}
</ul>
</div>
);
};
記事の一覧画面に削除ボタンが表示されています。削除ボタンをクリックしてダイアログが表示され、OK ボタンを押すと記事が削除されることを確認しましょう。
感想
HonoX を使ってフルスタック簡単なブログアプリケーションを作ってみました。多くの API は Hono と同じように使うことができるため、すでに Hono を使ったことがある人にとっては馴染みやすいフレームワークだと思います。
Next.js と比較して、軽量のフレームワークとして使用する分には十分な機能を持っていると感じました。アルファ版であるため行いくつか不安定な部分があるものの(HMR で追加したルートが反映されないなど)、今後のバージョンアップでのさらなる進化を期待しています。