📝

【react】RHFとZodでリクエスト・レスポンスのデータ整形を良しなにやる

2024/05/05に公開

はじめに

バックエンドへのデータ送信や、バックエンドから受け取ったデータをフロントエンド側で加工するのはよくあることかと思います。
ただ、加工処理をそのまま実装していくと、バリデーションであったり、場合によってはエラー制御であったりとコードが煩雑になっていくことかと思います(少なくとも僕みたいな村人Aエンジニアはそうでした...)

そのような際に、RHFとZodを用いることで、フォームのバリデーション・データ加工・型定義まで行えて、僕のQOLが爆上がりしたため備忘録として残します

デモ

今回は、nameとageをformに設置し、それらを加工してデータ送信する簡素なデモを作成します。

デモプロジェクトはViteで作成しています
https://ja.vitejs.dev/guide/#最初の-vite-プロジェクトを生成する

またソースコードはこちらにあげてあります
https://github.com/takap-sandbox/rhf-and-zod

環境

  • 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のスキーマ宣言と検証が行えるライブラリです
https://zod.dev/

Schema定義

BaseとなるSchemaを定義し、そこから以下2つのSchemaを定義します

  • FormのデータをValid・エンドポイント用に加工するSchema
  • エンドポイントからのデータを表示用に加工するSchema

また、どちらのSchemaにもtransformによるデータ加工を実装します(formattedAge, formattedName, nameCount)

App.tsx
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>を用います。
https://zod.dev/?id=type-inference

今回、form側Schemaにおいては変換処理前後の型が必要なため、z.inputとz.outputにて型定義を行います。

App.tsx
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用のフォーム管理ライブラリです。
https://react-hook-form.com/

RHFの実装

useFormの第一型引数にresolverによる変換前の型情報、第三型引数に変換後の型引数を渡せます。そのため先ほど定義したInputFormSchemaOutputFormSchemaをこの型引数に渡します。
これにより、型推論された状態でリクエスト前の変換が良しなに行えるようになります

参考:
https://tech.buysell-technologies.com/entry/2023/04/19/120000
https://github.com/react-hook-form/react-hook-form/releases/tag/v7.44.0

App.tsx
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によって追加されたデータを返すようにします。

mocks/handlers.ts
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設定をします

mocks/browser.ts
// src/mocks/browser.js
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';

export const worker = setupWorker(...handlers);

main.tsx
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の詳細はドキュメントを参照していただければと思います
https://mswjs.io/docs/getting-started

他必要な定義

リクエスト処理等をざっと追加します

App.tsx
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コンポーネント内で使用するようにします

App.tsx
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全てを載せておきます

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