はじめに
近年、フロントエンド・バックエンドの垣根を超えた型安全な開発が注目を集めています。
本記事では、Next.jsをベースに、oRPC・TanStack Form・Valibot・を組み合わせた、次世代型の型安全アプリケーション開発について紹介します。
これらのライブラリを使うことで、
- クライアント・サーバー間の型整合性の確保
- シンプルで強力なフォームバリデーション
- よりスケーラブルで安全なAPI設計
といったメリットを享受することができます。
後半では、実際にTodoアプリの一部機能を作りながら、それぞれの使い方や相互連携について詳しく解説していきます。
型の力を最大限活かした、未来志向の開発スタイルを体験してみましょう!
oRPCとは
oRPC (OpenAPI リモート プロシージャ コール) は、RPC (リモート プロシージャ コール) と OpenAPI を組み合わせ、OpenAPI 仕様に準拠しながら、タイプ セーフ API を介してリモート (またはローカル) プロシージャを定義および呼び出すことを可能にします。
公式ドキュメントによるとoRPCはOpenAPI仕様に基づくRPCの実装を可能にするライブラリです。
簡単に言うと、RPCにOpen API仕様書を自動生成する仕組みが組み込まれたライブラリがoRPCです。
どんな利点があるのか、Hono RPCなどとの違いなど詳細は筆者の以下の記事に記載しているので、気になる方は読んでみてください。
筆者はかなりの確率でこのライブラリは流行すると見ています。(少なくともRPCは今後のトレンドになると思っています)
TanStack Formとは
TS/JS、React、Vue、Angular、Solid、Lit 向けのヘッドレスで高性能、かつ型安全なフォーム状態管理
TanStack Form を使えば、シンプルさ、構成可能性、そして型安全性を取り戻し、フォーム作成に悩む必要はもうありません。小さなフットプリント、ゼロ依存関係、フレームワークに依存しないコア、そしてきめ細やかな型安全 API を備えたTanStack Form は、安心して素早くフォームを作成するために必要なシンプルさとパワーを完璧に兼ね備えています。
公式ドキュメントによると上記の特徴を備えたform 管理ライブラリです。
最近v1.0.0がリリースされ、安定版となったのは記憶に新しいかと思います。
実際使ってみるとわかりますが、かなり型サポートが強力ですし、きめ細かい部分まで制御ができたり、Server Actionsとも合わせられるので、React Hook FormだけでなくConformの代替にもなり得るライブラリです。
型安全がどれくらいかは以下の記事の「強力な型サポート」のセクションを見ると良さそうです。
Valibotとは
Valibot は、バンドルサイズ、型安全性、開発者エクスペリエンスを考慮した TypeScript 用のオープンソース スキーマ ライブラリです。
こちらも公式ドキュメントの参照しますが、Zodのようなバリデーションスキーマなどのスキーマを定義するライブラリです。
特徴としては以下の特徴があります。
- 強力な型推論による型生成
- 600 バイト未満の小さなバンドル サイズ
- 多くの変換および検証処理が含まれています
- 依存関係がゼロ
- 最小限で読みやすいAPI
Valibot の実装はバンドルサイズと初期化の手間を最小限に抑えるように最適化されているため、TTIベンチマークでこれを上回るパフォーマンスを発揮するライブラリはほとんどありません。実行時パフォーマンスに関しては、Valibot は中堅クラスです。大まかに言えば、このライブラリはZodの約2倍の速度ですが、 TypiaやTypeBoxと比べるとはるかに遅いです。
特にパフォーマンスは最も使われているスキーマ定義ライブラリのZodと比較して、非常に優れています。
これはZodがチェーンでつなげるという設計であることが大きいです。
例えば、Valibotにはpipe
というAPIがありますが、このようなAPIを使用することで、チェーン構造を避けているのが特徴だったりします。
const EmailSchema = v.pipe(
v.string(),
v.nonEmpty('Please enter your email.'),
v.email('The email is badly formatted.'),
v.maxLength(30, 'Your email is too long.')
);
ただ、Zodもこのパフォーマンス面のボトルネックは解消する動きをずっと取っていたようで、つい最近(2025/4/10あたり)v4を出し、Braking Changeとして公表したので、以前ほど、差はなくなるでしょうし、開発体験でいったら多くの人が慣れているZodが依然として使われるのではと思っております。
とはいえ、Valibotも非常に優れたスキーマ定義ライブラリであることに変わりはないので、知っておいて損はないと思います。
ということで、各Stackについて軽く触れたところで実装を見ていきたいと思います。
実装
まずは前提として技術スタックを紹介します。
技術スタック
- Next.js: フレームワーク
- oRPC: RPCライブラリ
- TanStack Form: フォーム管理ライブラリ
- Valibot: スキーマ定義ライブラリ
- up-fetch: fetchライブラリ
- データ: dummyjson
ライブラリ導入
まずは、必要なライブラリを導入します
bun add @tanstack/react-form valibot @orpc/server@latest @orpc/client@latest @orpc/openapi@latest @orpc/valibot@latest @orpc/react@latest up-fetch
Schema作成
まず最初に行う実装は、Valibotによるスキーマ定義です。
Valibotの特徴は、前述の通りチェーン構文ではなく、ひとつの関数で複数のバリデーションや変換処理をまとめるという点にあります。
例えば、v.pipe() や v.optional() はその代表的なAPIで、v.pipe()は複数のバリデーション処理を順番に適用する「パイプライン」のような構造になっています。
これは一般的なメソッドチェーンとは異なり、明示的に各処理をラップするため、柔軟性と可読性の高いバリデーション設計が可能になります。
v.optional()は第一引数に型を、第二引数にはdefault値を指定することができます。
import * as v from 'valibot'
export const TodoFormSchema = v.object({
todo: v.pipe(
v.string(),
v.minLength(1, 'You must have a length of at least 1'),
),
completed: v.optional(v.boolean(), false),
userId: v.number('User ID must be a number'),
})
export const TodoInputSchema = v.pick(TodoFormSchema, ['todo'])
v.InferOutput<typeof Schema>
で型情報を取得できるの見てみると、以下のようなTodoFormSchema
からは以下のような型を作ることができます。
※ 今回はこのv.InferOutput<T>
は使用しないので、これは型情報の参考情報として記載しているということにご留意ください
type Schema = {
todo: string;
completed: boolean;
userId: number;
}
Server Action作成
次にServer Actionの作成です。
oRPCのドキュメントに従い、作成していきます。
actionable()
を使用することでServer Actionとして定義できるようで、errorsにカスタムエラーをhandlerで実際に行うロジックをというように、手順を作成していくように各種処理を定義していけるのが特徴です。
'use server'
import { os } from '@orpc/server'
import { revalidateTag } from 'next/cache'
import * as v from 'valibot'
import { TodoFormSchema } from '~/features/todo/types/schemas/todo-form-schema'
import type { Todo } from '~/features/todo/types/schemas/todo-schema'
import { upfetch } from '~/lib/up-fetch'
// ? https://dummyjson.com/docs/todos#todos-add
export const addTodoAction = os
.input(TodoFormSchema)
.errors({
ADD_TODO_ERROR: {
message: 'Error adding todo',
data: v.object({ error: v.string() }),
},
})
.handler(async ({ input }) => {
const response = await upfetch<Todo>('/todos/add', {
method: 'POST',
body: input,
})
revalidateTag('getTodos')
return response
})
.actionable()
UI作成
次にUIですが、今回はシンプルなTextFieldを持つformを作成します。
各hookやJSXの処理について順に解説していきます。
'use client'
import { isDefinedError, onError, onSuccess } from '@orpc/client'
import { useServerAction } from '@orpc/react/hooks'
import { useForm } from '@tanstack/react-form'
import { addTodoAction } from '~/features/todo/actions/add-todo-action'
import { TodoInputSchema } from '~/features/todo/types/schemas/todo-form-schema'
export function TodoForm() {
const { execute, error, status } = useServerAction(addTodoAction, {
interceptors: [
onError((error) => {
if (isDefinedError(error)) {
alert(error.message)
}
}),
onSuccess((data) => {
alert(`Todo added successfully: ${data.todo} for user ${data.userId}`)
}),
],
})
const f = useForm({
defaultValues: {
todo: '',
},
validators: {
onChange: TodoInputSchema,
},
})
const action = (form: FormData) => {
f.handleSubmit()
const todo = form.get('todo') as string
execute({ todo, completed: false, userId: 1 })
}
return (
<form className="flex gap-x-2 px-2 mb-5" action={action}>
<f.Field
name="todo"
children={(field) => {
return (
<div className="relative">
<div className="flex flex-col gap-y-1">
<input
type="text"
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
className="bg-white text-black p-1 rounded-md shadow-md w-full max-w-44"
/>
{field.state.meta.errors.length > 0 ? (
<em className="text-red-500 absolute -bottom-6 w-64">
{field.state.meta.errors
.map((err) => err?.message)
.join(',')}
</em>
) : error?.message.includes('validation') ? (
<em className="text-red-500 absolute -bottom-6 w-64">
You must have a length of at least 1
</em>
) : null}
</div>
</div>
)
}}
/>
<f.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<button
type="submit"
disabled={!canSubmit || status === 'pending'}
className="bg-blue-500 text-white p-1 rounded-md shadow-md hover:bg-blue-500/90 cursor-pointer transition-all duration-300 px-4 h-9 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting || status === 'pending' ? 'Adding...' : 'Add'}
</button>
)}
/>
</form>
)
}
useServerAction
まずはこのuseServerAction
というhookについてですが、これは、@orpc/react
というパッケージに含まれるoRPCが提供するhookです。
これを使うことで、Server Actionsのハンドリングを簡便化することができます。
const { execute, error, status } = useServerAction(addTodoAction, {
interceptors: [
onError((error) => {
if (isDefinedError(error)) {
alert(error.message)
}
}),
onSuccess((data) => {
alert(`Todo added successfully: ${data.todo} for user ${data.userId}`)
}),
],
})
似たようなhookにReact標準のuseActionState
がありますが、そのhookだと自前でuseServerAction
のinterceptors
に相当するコールバックを作成する必要があります。
それについては以下で詳しく記載したので、こちらをご参照ください。
useForm
次にTanStack FormのuseForm
です。
こちらは以下の公式ドキュメントと公式サンプルを参考に実装していきます。
const f = useForm({
defaultValues: {
todo: '',
},
validators: {
onChange: TodoInputSchema,
},
})
やることはシンプルで、defaultValues
を指定し、validators
のonChange
にバリエーション対象となるSchemaを定義するだけです。
このようにするとform単位でバリデーションを定義することができます。
もちろん、formのinput部品単位でバリデーションすることも可能です。
action
最後のactionですが、ここでuseFormのhandleSubmit()
を呼び、バリーションのチェックをします。
これがClient側でのバリデーションチェックです。
そして、excuteを実行します。
このようにしておけば、Clientバリデーションが効かなかった場合も、このようにすればServer側でバリデーションが効かせることができます。
const action = (form: FormData) => {
f.handleSubmit()
const todo = form.get('todo') as string
execute({ todo, completed: false, userId: 1 })
}
useFormのField
ここからはJSX部分ですが、TanStack Formでは、form.Field
のように各種Input要素などを作成していきます。
やっていることはシンプルで、Fieldのnameに紐づけたいSchemaの要素を指定して、childrenで受け取ったfieldが持つ各種値をHTMLの属性や各種イベントに適用させていくだけです。
また、バリデーションエラーの要素も持っているため、その条件と要素も表示することができます。
(error?.message.includes('validation')
はuseServerAction
の返り値であるerrorが定義元です)
<f.Field
name="todo"
children={(field) => {
return (
<div className="relative">
<div className="flex flex-col gap-y-1">
<input
type="text"
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
className="bg-white text-black p-1 rounded-md shadow-md w-full max-w-44"
/>
{field.state.meta.errors.length > 0 ? (
<em className="text-red-500 absolute -bottom-6 w-64">
{field.state.meta.errors.map((err) => err?.message).join(',')}
</em>
) : error?.message.includes('validation') ? (
<em className="text-red-500 absolute -bottom-6 w-64">
You must have a length of at least 1
</em>
) : null}
</div>
</div>
)
}}
/>
今回はchildren
propsにて実装していますが、以下のように、実際のchildrenのようにして実装ることもでき、FieldのvalidatorsのonChange
やonBlur
でバリデーションを定義することで、かなり細かくバリデーションを行うタイミングを制御することができます。
このあたりは以下の記事が非常にわかりやすかと思います。
<form.Field
name="age"
validators={{
onChange: ({ value }) =>
value < 13 ? 'You must be 13 to make an account' : undefined,
}}
>
{(field) => (
<>
<label htmlFor={field.name}>Age:</label>
<input
id={field.name}
name={field.name}
value={field.state.value}
type="number"
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors ? (
<em role="alert">{field.state.meta.errors.join(', ')}</em>
) : null}
</>
)}
</form.Field>
useFormのSubscribe
<form.Subscribe />
は、フォームの状態をsubscribeするコンポーネントです。
送信可能かどうかはcanSubmit
で, 送信中かどうかはisSubmitting
をsubscribeし、ボタンの状態を制御しています。
<form.Subscribe />
はコンポーネントレベルの再レンダリングを引き起こしません。subscribeしている値が変更されるたびに、<form.Subscribe />
内の要素のみが再レンダリングされます。これにより、フォーム全体が再レンダリングされることなく、必要な部分だけを効率的に更新できるため、大規模なフォームでも高いパフォーマンスを維持できます。
今回のケースだと、useServerAction
のstatusがpendingのときにもプロセス中であることを示したいので、条件として追加しています。
<f.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<button
type="submit"
disabled={!canSubmit || status === 'pending'}
className="bg-blue-500 text-white p-1 rounded-md shadow-md hover:bg-blue-500/90 cursor-pointer transition-all duration-300 px-4 h-9 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting || status === 'pending' ? 'Adding...' : 'Add'}
</button>
)}
/>
実装としては以上となります。
それにしてもTanStack Formは使い勝手もよく、パフォーマンス面も良くできているので、これは増々ReactHookFormの登場機会は減ることが予想できそうです。
OpenAPI仕様書の作成
それでは、最後にOpenAPI仕様書を作成していきます。
Valibotを使用しているので、以下を参考し、行っていきます。
※ 記載があるようにまだ実験的段階のようなので、本番運用は一旦は避けて素振り程度にしておくのが無難そうです。
import fs from 'node:fs'
import { OpenAPIGenerator } from '@orpc/openapi'
import { experimental_ValibotToJsonSchemaConverter as ValibotToJsonSchemaConverter } from '@orpc/valibot'
import { NextResponse } from 'next/server'
import { router } from '~/lib/orpc-router'
// ? https://github.com/unnoq/orpc/tree/main/packages/valibot
export async function GET() {
const generator = new OpenAPIGenerator({
schemaConverters: [new ValibotToJsonSchemaConverter()],
})
const spec = await generator.generate(router, {
info: {
title: 'My Dummy Todo API',
version: '1.0.0',
},
})
fs.writeFileSync('openapi.json', JSON.stringify(spec, null, 2), 'utf8')
return NextResponse.json(spec, {
status: 200,
headers: {
'Content-Type': 'application/json',
},
})
}
筆者の場合、/api/openapi/doc
というNext.jsのRoute Handlerを定義してOpenAPIの仕様書を作成しています。
細かいAPIの内容などはここでは触れませんが、これだけで簡単にAPI仕様書を作れてしまうのはoRPCの最たる特徴です。
具体的なAPIの定義方法などは以下をご参照ください。
それでは、実際に生成された仕様書をSwagger Editorに貼り付けるとしっかりとできていました。
※ Swagger Editor上で多少エラーが出るので、多少の手直しは必要なようです。まだまだoRPC自体が安定版ではないので、このあたりは安定版リリースで修正されることを願うしかないです。
また、HonoはSwagger UI上に自動で反映する仕組みがあるので、この機能についてもoRPCに実装されてほしいと願うしかないです。
おわりに
いかがだったでしょうか?
Valibot・RPCで型安全性を担保しつつ、Tanstack FormとValibotでClient側のバリデーションやform要素の型まで堅牢にすることができます
その上、oRPCを使用すれば、RPCで「関数呼び出すだけでAPI仕様書なんてないですよ」という状態を簡単に回避することができます。
それも開発体験を損なわず、保守性を高める方向で実現できます。
ぜひ、この記事だったり、参考文献を見て、試してみてください。
最後までお読みいただき、ありがとうございます。
本記事が少しでも参考になれば、幸いです。
参考文献