1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js × oRPC × TanStack Form × Valibot でつくる、次世代型・型安全アプリ構築

Posted at

はじめに

近年、フロントエンド・バックエンドの垣根を超えた型安全な開発が注目を集めています。
本記事では、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だと自前でuseServerActioninterceptorsに相当するコールバックを作成する必要があります。

それについては以下で詳しく記載したので、こちらをご参照ください。

useForm

次にTanStack FormのuseFormです。
こちらは以下の公式ドキュメントと公式サンプルを参考に実装していきます。

const f = useForm({
  defaultValues: {
    todo: '',
  },
  validators: {
    onChange: TodoInputSchema,
  },
})

やることはシンプルで、defaultValuesを指定し、validatorsonChangeにバリエーション対象となる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のonChangeonBlurでバリデーションを定義することで、かなり細かくバリデーションを行うタイミングを制御することができます。

このあたりは以下の記事が非常にわかりやすかと思います。

<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に実装されてほしいと願うしかないです。

image.png

おわりに

いかがだったでしょうか?
Valibot・RPCで型安全性を担保しつつ、Tanstack FormとValibotでClient側のバリデーションやform要素の型まで堅牢にすることができます

その上、oRPCを使用すれば、RPCで「関数呼び出すだけでAPI仕様書なんてないですよ」という状態を簡単に回避することができます。
それも開発体験を損なわず、保守性を高める方向で実現できます。

ぜひ、この記事だったり、参考文献を見て、試してみてください。
最後までお読みいただき、ありがとうございます。
本記事が少しでも参考になれば、幸いです。

参考文献

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?