【react】RHFとZodでリクエスト・レスポンスのデータ整形を良しなにやる
はじめに
バックエンドへのデータ送信や、バックエンドから受け取ったデータをフロントエンド側で加工するのはよくあることかと思います。
ただ、加工処理をそのまま実装していくと、バリデーションであったり、場合によってはエラー制御であったりとコードが煩雑になっていくことかと思います(少なくとも僕みたいな村人Aエンジニアはそうでした...)
そのような際に、RHFとZodを用いることで、フォームのバリデーション・データ加工・型定義まで行えて、僕のQOLが爆上がりしたため備忘録として残します
デモ
今回は、nameとageをformに設置し、それらを加工してデータ送信する簡素なデモを作成します。
デモプロジェクトはViteで作成しています
またソースコードはこちらにあげてあります
環境
- react 18.2.0
- axios 1.6.8
- reach-hook-form 7.51.3
- zod 3.23.6
- @hookform/resolvers 3.3.4
- msw 2.2.14
ライブラリのインストール
必要なライブラリをインストールします
$ npm install zod react-hook-form axios @hookform/resolvers
$ npm install -D msw
zod
zodとは
zodは、TSのスキーマ宣言と検証が行えるライブラリです
Schema定義
BaseとなるSchemaを定義し、そこから以下2つのSchemaを定義します
- FormのデータをValid・エンドポイント用に加工するSchema
- エンドポイントからのデータを表示用に加工するSchema
また、どちらのSchemaにもtransformによるデータ加工を実装します(formattedAge
, formattedName
, nameCount
)
const BaseSchema = z.object({
name: z.string(),
age: z.preprocess((v) => Number(v), z.number()),
})
const Schema = BaseSchema.merge(
z.object({
id: z.number(),
nameCount: z.number(),
}))
.transform((vals) => ({
...vals,
formattedAge: `${vals.age}歳`,
formattedName: `${vals.name}(${vals.nameCount})`
})
)
const Schemas = z.object({
list: z.array(Schema)
})
const FormSchema = BaseSchema.transform((vals) => ({
...vals,
nameCount: vals.name.length
}))
zodは定義したSchemaからz.infer<typeof Schema>
でTSの型定義を行えます。
もし変換処理前後の型定義をSchemaから抽出したい場合は、z.inferではなくz.input<typeof Schema>
とz.output<typeof Schema>
を用います。
今回、form側Schemaにおいては変換処理前後の型が必要なため、z.inputとz.outputにて型定義を行います。
type InputFormSchema = z.input<typeof FormSchema>;
type OutputFormSchema = z.output<typeof FormSchema>;
type Schema = z.infer<typeof Schema>;
type Schemas = z.infer<typeof Schemas>;
RHF
RHFとは
React Hook Formは、React用のフォーム管理ライブラリです。
RHFの実装
useFormの第一型引数にresolverによる変換前の型情報、第三型引数に変換後の型引数を渡せます。そのため先ほど定義したInputFormSchema
とOutputFormSchema
をこの型引数に渡します。
これにより、型推論された状態でリクエスト前の変換が良しなに行えるようになります
参考:
fuction App() {
const { register, handleSubmit, reset, formState: {errors} } = useForm<InputFormSchema, unknown, OutputFormSchema>({
resolver: zodResolver(FormSchema),
});
}
mswの実装
エンドポイントは実際に実装はせず、mswを用いて作成したmockにて試していこうと思います
(めんどくさいので)
/api/test
に対するget, postメソッドをmockするようにします。getでは一件デフォルトで返すようにし、それに加えてpostによって追加されたデータを返すようにします。
import { http, HttpResponse, PathParams } from 'msw';
type ReqData = {
name: string;
age: number;
nameCount: number;
};
let mockData = [
{
id: 1,
name: 'test',
age: 20,
nameCount: 4,
},
];
const allMockData = {
list: mockData,
};
export const handlers = [
http.get('/api/test', () => {
return HttpResponse.json(allMockData);
}),
http.post<PathParams, ReqData, ReqData & { id: number }>('/api/test', async ({ request }) => {
const newPost = await request.json();
const newData = {
id: mockData.length + 1,
...newPost,
};
mockData.push(newData);
return HttpResponse.json(newData);
}),
];
今回はbrowserのみで使用しますので、browser側のworker設定をします
// src/mocks/browser.js
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
async function enableMocking() {
if (process.env.NODE_ENV !== 'development') {
return
}
const { worker } = await import('./mocks/browser')
// `worker.start()` returns a Promise that resolves
// once the Service Worker is up and ready to intercept requests.
return worker.start()
}
enableMocking().then(() => ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
))
これで/api/test
エンドポイントに対するgetとpostリクエストは、mswによってモックされた値が返るようになりました
mswの詳細はドキュメントを参照していただければと思います
他必要な定義
リクエスト処理等をざっと追加します
const useApi = () => {
const endpoint = '/api/test'
const fetchData = useCallback(async () => {
const fetchedData = await axios.get(endpoint)
return Schemas.parse(fetchedData.data)
}, [])
const postData = useCallback((values: OutputFormSchema) => {
return axios.post(endpoint, values)
}, [])
return {
fetchData,
postData,
}
}
useApiでは、字のごとくバックエンドとのAPI処理を記述しています
これをAppコンポーネント内で使用するようにします
function App() {
const { fetchData, postData } = useApi()
const [data, setData] = useState<Schemas['list']>([])
const postDataWithMutate = async (values: OutputFormSchema) => {
const _result = await postData(values)
// なんかうまいことエラー処理とか
reset()
const fetchedData = await fetchData()
setData(fetchedData.list)
}
useEffect(() => {
fetchData().then((value) => {
setData(value.list)
})
}, [fetchData])
return (
<>
<form onSubmit={handleSubmit(postDataWithMutate)}>
<input defaultValue='' {...register('name')} />
<input defaultValue='' type='number' {...register('age')} />
<input type='submit' />
<p>{errors.age?.message}</p>
<p>{errors.name?.message}</p>
</form>
{data.map(datum => (
<div key={datum.id}>
<p>名前: { datum.formattedName } 年齢: { datum.formattedAge }</p>
</div>
))}
</>
)
}
postDataWithMutateを定義して、post成功時にデータの再FetchとFormのクリアを行うようにしています
これをhandleSubmitに渡すことで、Validate成功時に渡した関数が実行されます
また、初回アクセス時にはデータを取得するようにuseEffectを記述しています
表示には、バックエンド側からのデータ加工がわかるようにformattedされたデータを表示するようにしています
最後にApp.tsx全てを載せておきます
import { useCallback, useEffect, useState } from 'react'
import './App.css'
import axios from 'axios'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
const BaseSchema = z.object({
name: z.string(),
age: z.preprocess((v) => Number(v), z.number()),
})
const Schema = BaseSchema.merge(
z.object({
id: z.number(),
nameCount: z.number(),
}))
.transform((vals) => ({
...vals,
formattedAge: `${vals.age}歳`,
formattedName: `${vals.name}(${vals.nameCount})`
})
)
const Schemas = z.object({
list: z.array(Schema)
})
const FormSchema = BaseSchema.transform((vals) => ({
...vals,
nameCount: vals.name.length
}))
type InputFormSchema = z.input<typeof FormSchema>;
type OutputFormSchema = z.output<typeof FormSchema>;
type Schema = z.infer<typeof Schema>;
type Schemas = z.infer<typeof Schemas>;
const useApi = () => {
const endpoint = '/api/test'
const fetchData = useCallback(async () => {
const fetchedData = await axios.get(endpoint)
return Schemas.parse(fetchedData.data)
}, [])
const postData = useCallback((values: OutputFormSchema) => {
return axios.post(endpoint, values)
}, [])
return {
fetchData,
postData,
}
}
function App() {
const { fetchData, postData } = useApi()
const [data, setData] = useState<Schemas['list']>([])
const { register, handleSubmit, reset, formState: {errors} } = useForm<InputFormSchema, unknown, OutputFormSchema>({
resolver: zodResolver(FormSchema),
});
const postDataWithMutate = async (values: OutputFormSchema) => {
const _result = await postData(values)
// なんかうまいことエラー処理とか
reset()
const fetchedData = await fetchData()
setData(fetchedData.list)
}
useEffect(() => {
fetchData().then((value) => {
setData(value.list)
})
}, [fetchData])
return (
<>
<form onSubmit={handleSubmit(postDataWithMutate)}>
<input defaultValue='' {...register('name')} />
<input defaultValue='' type='number' {...register('age')} />
<input type='submit' />
<p>{errors.age?.message}</p>
<p>{errors.name?.message}</p>
</form>
{data.map(datum => (
<div key={datum.id}>
<p>名前: { datum.formattedName } 年齢: { datum.formattedAge }</p>
</div>
))}
</>
)
}
export default App
確認
開発サーバーを起動して、http://localhost:3000/ にアクセスしたら、以下ページが表示されます
$ npm run dev
mswで、一件デフォルトで定義していたため、その一件が表示されています。また、zodによって変換された値が正常に表示されていることが確認できます
名前にtest data
年齢に20
を入力し、submitしましょう。またその際にリクエストデータがどうなっているか見てみます
データ内にnameCount
が含まれており、正常に加工されて送られていることが確認できました
おわりに
RHFとZodの組み合わせ結構気持ちよかったので、またどこかで使いたいなぁと思ったり思わなかったり...
また、今回デモでは使用しませんでしたが、ここにキャッシュライブラリ(SWRだったり)を入れて、unsafeなメソッドのリクエスト時にMutation噛ますと、今回のwithMutate君とuseEffect君が不要になるため、さらに使い勝手が良くなります
他もっとよい方法等ありましたらコメントで教えていただけると非常に助かります!
PS
バックエンドもTSにしてTRPCとか触ってみたい(小並感)
Discussion