はじめに
自分が作ろうとしていたちょっとした個人用のWebアプリでは、入力画面の使い方がシンプルで、画面数も少なかったことから、工夫すれば入力フォームを共通化できそう(コンポーネントとして切り出せそう)だな、と思っていた。で、実際試したところ、とりあえず動作するものが作れたので、そのメモ。
しかし初めに書いておくと、タイトルにも書いた通り、これは実のところ中途半端で、少しでも動的な要素を画面に求めようとすると破綻する。結果的にこのWebアプリでも採用することなくお蔵入りしたのだが、一応動くものは作れたので備忘録として残す。どの辺が中途半端なのかについては後述する。(なにか色々考慮漏れがあるんだとは思うが…)
やりたかったこと
自分が作ろうとしていた入力画面は、究極的にいうと
-
yup
でvalidationする - 入力したフォームの内容をAPIにPOSTする
ができればそれでよかった。ということは、
- 各画面固有の入力項目(formikの
Field
タグ群等) - 各画面ごとの入力フォームに対応する
yup
のスキーマ - 入力した値をPOSTする先のAPIリクエストパス
を外から渡してもらってハメ込んで、いい感じに入力フォームとして成立させるコンポーネントが作れるんじゃないかと考えた。
入力フォームのコンポーネント
import React, { ReactNode } from "react";
import axios from "axios";
import * as yup from 'yup';
import { useState } from "react";
import { Formik, Field, Form, ErrorMessage } from "formik";
const InputForm = ( initialData:any , schema:yup.ObjectSchema<any> , apipath:string, indivisualForm:JSX.Element ) => {
const [status,setStatus] = useState('');
const submitData = async (data:any) => {
setStatus('now posting...');
const res = await axios.post(apipath,data);
if(res.status == 200) {
setStatus('succeeded! [reload]');
window.location.reload();
} else {
setStatus('failed:' + res.data.message);
}
};
return (
<Formik
initialValues={initialData}
validationSchema={schema}
onSubmit={submitData}
enableReinitialize
>
{(formik)=>{
return (
<Form>
<div className="status">
<p>{status}</p>
</div>
{indivisualForm}
<div>
<button type="submit" disabled={!formik.isValid || formik.isSubmitting}>submit</button>
</div>
</Form>
)
}}
</Formik>
);
}
export default InputForm;
- 引数は4つ。基本は↑に書いた通り。
-
initialData
:Formik
のinitialValues
に渡すパラメータ。新規入力のときは空文字とかnullとか、更新のときは値詰めて渡す感じ。 -
schema
:yup
のスキーマ。FormikのvalidationSchema
に渡すパラメータ。詳細は割愛する -
apipath
: 入力フォームをPOSTする先のAPIリクエストパス。"/api/post-api"
みたいな文字列を渡す。POSTの実行はsubmitData
関数内でaxios
でやる。 -
indivisualForm
: 各画面固有の入力フォーム群。型はJSX.Element
。
-
- ちょっと頑張ったんだがいい感じに型定義ができなかったので
any
型が目立つ。この辺も正直「中途半端」な側面の一つ。initialValues
とvalidationSchema
は頑張ればもっといい感じの型が付けられそうな気はする。追跡できていない。
各ページからの呼び出し方
各ページからは以下のように呼び出す。例えば/pages/post-test
ってページがあった場合を例とする。
import React from "react";
import InputForm from '@/components/input-form';
import * as yup from 'yup';
import { Formik, Field, Form, ErrorMessage , useFormikContext , useField } from "formik";
import { useState } from "react";
type InputFieldsType = {
test: string,
id: number,
};
const schema = yup.object().shape({
test: yup.string().min(1, 'min 1 length').max(10, 'max 10 length').required(),
id: yup.number().positive().integer().required(),
});
const InputFields = () => {
return (
<div>
<div>
<label htmlFor="test">test</label>
<Field name="test" type="text"
/>
<ErrorMessage name="test" />
</div>
<div>
<label htmlFor="id">id</label>
<Field name="id" type="number" />
<ErrorMessage name="id" />
</div>
</div>
);
};
const Index: React.FC<any> = ( props , ) =>{
return (
<div>
<h3>post-test import test</h3>
{InputForm(
{test:'' , id: 0},
schema,
'/api/post-test',
InputFields()
)
}
</div>
);
}
export default Index;
ポイントは
{InputForm(
{test:'' , id: 0},
schema,
'/api/post-test',
InputFields()
)
}
の部分だ。ここで↑のInputForm
コンポーネントを呼び出している。各画面固有のField
タグ群はInputFields
関数として別に定義されており、これをそのまま引き渡して、入力フォームとしてInputForm
コンポーネント側でindivisualForm:JSX.Element
というパラメータで受け取り、自分のとこにハメ込んで最終的にFormikの画面として成立させるという流れだ。
各画面の入力項目の配置等、画面毎のレイアウトをページ側で指定したうえで、入力フォームとして成立させるための他の共通的な要素はコンポーネントに一元化できる、ということで、個人的にこの発想自体は気に入っている。実際、少なくともこの例は動く。ビルドも通るし、画面もアクセスできるし、バリデーションも想定通りに動くし、APIへのPOSTも想定通りにリクエストされる。この点だけみれば何も問題はなかった。確認したバージョンは以下。
"next": "13.2.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"formik": "^2.4.3",
"yup": "^1.2.0"
できないこと
ただ、各画面に動的な要素、具体的にはsetFieldValue
とか使った動きをつけようと思うと、この構成だとできない。よく考えれば当たり前なんだが、formik
オブジェクト自体が各ページに存在しない(コンポーネント側にしか存在しない)ので、各ページからその辺を指示できない。例えば/pages/post-test.tsx
に以下のようなコードいれると、画面表示時にエラーになる。(setFieldValue
なんてモン存在しねェーよ!って怒られる)
...(略)...
const InputFields = () => {
const {setFieldValue} = useFormikContext()
const onClickSetTest = (e:React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
setFieldValue('test' , new Date().toISOString());
}
return (
<div>
<p>
<button onClick={onClickSetTest}>set test</button>
</p>
...(略)...
);
};
この例は、画面に一つボタンを用意して、そのボタンを押すとtest
というフィールドに値をセットするという動きを実現したいもの。Formikの場合、項目への値のセットにはsetFieldValue
を使うことになるが、Formik
の外側であるこのページの実装だと、useFormikContext()
の戻り値がundefinded
になるため、想定通りに動作しない。
useState
やらuseRef
やら使えば(仮にこのように入力項目群と入力フォームのコンポーネントが分離されていても)簡単に実現できるシンプルな動きなのだが、Formik使ってる場合のこの構成だと想定通りには動かない。多分Formikの使い方が悪い(というか何か着想を間違っている気がする)のだが、簡単に入力フォームが作れるFormikというライブラリを使う上での弊害かなあと感じた。どうしようもなさそうだし画面数も少ないので、今回はこの構成での入力フォーム作成は諦めたが、発想自体はいいところまでいったのにな~惜しいな~ということで記念に記録として残すものとする。