Reactにとって、複数のフォーム制御はけっこう処理が面倒な部分でした。
ですが今までのフォーム制御の知識は一旦、全部捨てて下さい。全てuseActionStateフックで解決します。
React19でこのuseActionStateフックが登場し、以下の方法を知ったときは遂にReactもVue、Angular、Svelteと同じぐらいフォーム制御が簡潔になったかと大いに感動を覚えたものです(動きとしてはAngularのreactiveFormに近い)。
ところが、色々と技術系サイトを巡回した所、この手法がなぜかほとんど知られていないような気がしたので記事にしてみました。
従来の記法
今までReactでのフォーム制御といえば
const [hoge,setHoge] = useState('');
<input onChange={(e)=>setHoge(e.target.value)} value={hoge} />
こんな記述をしたと思います。onChangeイベントハンドラで値をステート変数化し、セッタ関数で同期をとって、それをvalueに返す……そしてフォームが複数になれば、こういった制御をずらずらと書いたりもしていました(useStateフックを一元化する方法もあります)。
ですがuseActionStateフックを使えばもうそんな冗長な記述は要りません。
- onChangeイベントハンドラ
- valueプロパティ
- useState
- セッタ関数
これらは全部不要となります。
そしてuseActionStateフックに用意されているisPendingという制御フラグですが、単に状態遷移目的のメッセージ表示切換に用いるだけではなくフォーム入力準備完了の判定にも用いることができます。
実装フォームの例1
具体的な実装フォームの例を挙げてみます。至ってオーソドックスなフォーム制御の記述です(一部簡潔に書いているだけの部分もあります)。
import { useState,useEffect,useActionState } from 'react';
//具体的なアクション
async function feedback(val,formData){
val.item1 = formData.get('item1')
val.item2 = formData.get('item2')
val.item3 = formData.get('item3')
return val;
}
export default function EditTodo(){
const [initial,setInitial] = useState([])
const [dif,formAction,isPending] = useActionState(feedback,initial)
//更新を検知
useEffect(()=>{
if(isPending){
//フォーム転送後の処理
}
},[isPending])
return (
<>
<h2>useActionStateを用いたフォーム制御</h2>
<form action={formAction} >
<input id="item1" name="item1" defaultValue={initial.item1} />
<input id="item2" name="item2" defaultValue={initial.item2} />
<input id="item3" name="item3" defaultValue={initial.item3} />
<button type="submit">更新する</button>
</form>
</>
)
}
解説
useActionStateの働きを分解するとこうなります。
const [dif,formAction,isPending] = useActionState(feedback,initial)
const [更新差分,アクションの値,処理判定フラグ] = useActionState(具体的なフォーム処理,初期値)
フォーム
フォーム上にはonChangeイベントハンドラもvalueも指定していないので、それ自体は何の同期制御もしていない通常フォーム扱いとなります。したがって、Reactの制御から外れているので、自由に文字を書き換えできます。
また、defaultValueも初期値を出力するだけのプロパティ(新規登録や検索の場合は不要)なので、機能としては独立しています。
フォーム処理のアクション
フォームがイベントによってsubmitされることでformタグ上のactionプロパティに設定されたformActionがコールされ、フォーム設定用のアクション用メソッドfeedbackが実行されます。具体的には以下の部分です。
※バリデーションを実装したい場合は、この内部に記述していきます。
//具体的な制御アクション
async function feedback(val,formData){
val.item1 = formData.get('item1')
val.item2 = formData.get('item2')
val.item3 = formData.get('item3')
return val; //差分を代入し、返す
}
もし、このフォームがいくつも存在した場合、ずらずらと記述していくのは面倒になります。その場合は以下のようにループ文でも制御可能です。
async function feedback(vals,formData){
for( const[key,value] of formData.entries() ){
vals[key] = value; //フォームの値を順次代入していく
}
return vals;
}
※【注】差分更新用のオブジェクトは分割してはいけません。判定フラグが検知しなくなります。
async function feedback(val,formData){
const tmp = {...val};
for( const[key,value] of formData.entries() ){
tmp[key] = value; //フォームの値を順次代入
}
return tmp; //分割された値はフラグが検知しない(isPendingがtrueにならない)
}
また、returnを忘れるとuseActionStateが一回だけの処理で止まってしまい、連続で操作できなくなります。
//具体的な制御アクション
async function feedback(val,formData){
//returnで返していないので、useActionStateがリセットされない
val.item1 = formData.get('item1') //nameプロパティで設定したフォームの値
val.item2 = formData.get('item2') //nameプロパティで設定したフォームの値
val.item3 = formData.get('item3') //nameプロパティで設定したフォームの値
}
更新の検知
フォーム処理のアクションによって値が返されると、isPendingはtrueを返します。したがって、useEffectフックが機能するようになり、フォーム転送後の処理を実行できます。ここに修正や検索などの結果を返すようにします。
//更新を検知
useEffect(()=>{
if(isPending){
//フォーム転送後の具体的処理(差分difにフォームの値を格納している)
}
},[isPending])
また、このようにフォーム処理をわけておくと、別コンポーネントからのデータ取得、トップコンポーネントへのリデューサ送出などにおいて非常に至便となります。
実装フォームの例2(useEffectを使用しない方法)
Geminiに質問して技術の適正さを再確認したところ、上記のuseEffect使用はどちらかというとuseFormStateフックを使用していたReact18時代を折衷したもので、最新の記法はuseAcstionStateの中にメソッドを内包してしまう手法が、確実で安全性が高くなるとのことでした。
また、その場合はfeedback内の変数もシャローコピー(その方が安全)を使用します。それにより第1戻り値resultには、処理後の判定(エラー制御などに用いる)が格納されます。
つまり、以下のように変わります。
const [result,formAction,isPending] = useActionState(feedback,initial)
const [処理判定,アクションの値,状態遷移フラグ] = useActionState(具体的なフォーム処理,初期値)
import { useState,useActionState } from 'react';
//具体的なアクション
async function feedback(val,formData){
const tmp = {...val}; //今度はシャローコピーで制御する(フックの戻り値が判定処理に変わる)
tmp.item1 = formData.get('item1');
tmp.item2 = formData.get('item2');
tmp.item3 = formData.get('item3');
return tmp; //差分を返す
}
export default function EditTodo(){
const [initial,setInitial] = useState([])
const [result,formAction,isPending] = useActionState(async(prev,formData)=>{
const updated = await feedback(prev,formData); //引数をそのまま代入
//差分の処理
} ,initial)
return (
<>
<h2>useActionStateを用いたフォーム制御</h2>
<form action={formAction} >
<input id="item1" name="item1" defaultValue={initial.item1} />
<input id="item2" name="item2" defaultValue={initial.item2} />
<input id="item3" name="item3" defaultValue={initial.item3} />
<button type="submit">更新する</button>
</form>
</>
)
}
フック第1戻り値の中身が変わった理由
useActionStateフックの第1戻り値が、それまでは更新差分だったのに対し、今度の制御においては処理判定に変わりました。その原因はアクション関数feedbackの処理で代入変数をシャローコピーで変更したためです。それによって、本来のReactが持っているミューテーション機能が正常に働き、第一戻り値が更新差分ではなく、判定結果に変わるそうです(つまりuseReducerと似たようなものとしても使えるし、useOptimisticのような使い方もできる)。
※更に条件によってはuseTransitionだけでもフォーム処理ができる(ただし、処理エラーなどの記述が面倒になるので推奨できない)ようです。
まとめ
フォーム制御におけるonChangeとvalueにセッタ関数という冗長な書き方はReact開発者側も技術者側も長年の課題だったそうで、遂にReact19で解決を見たと言えそうです。
ただし、フォームが一つだけの場合は従来の手法の方が手間がかかりません。あくまで複数フォーム制御を必要とする場合、または単一であってもバリデーション制御を徹底したい場合に本領を発揮します。