Form コンポーネントを hook にしてみる
- カテゴリ:
- JavaScript
- コメント数:
- Comments: 0
◆ React の input 管理を楽にしたい
◆ Form コンポーネントで Context を使っていたのを hook にしてみる
◆ props を作って input に追加するのだと見た目がいまいち
◆ Input コンポーネントを使うのだと hook との相性がいまいち
◆ Form コンポーネントで Context を使っていたのを hook にしてみる
◆ props を作って input に追加するのだと見た目がいまいち
◆ Input コンポーネントを使うのだと hook との相性がいまいち
React って input 関係は value の設定と onChange で変更時の処理を書く必要があり自分でやることが多いです
その分 自由度は高くありますが 特別なことをするとき以外は面倒なだけです
そういうときにいい方法はないかなと いろいろ試してみて 今のところこういう感じにするのが一番かなと思ってたのがこういう書き方です
Form と Input コンポーネントは Context でつながっています
input の変更時に Form に設定した data を書き換えた版のオブジェクトを onChange で通知します
あえてライブラリを入れるまでもないっていうのもそうですが 流行り廃りが早すぎます
人気のランキングを見ても毎年のように置き換わっているものが多いです
フォーム系では 最近は react-hook-form が流行りのようですが これが出る前によく使われてたものはもうオワコンで今は選択肢にも上がらないとかいう書き込みも目にしました
そんなですし フォーム制御程度にライブラリを入れようとも思いません
ただ最近おすすめに出てくる記事で react-hook-form を目にすることが何度かあったのでインターフェース的にどう言う感じで使うものか見てみました
hook-form というだけあって hook を使っています
という感じで ライブラリが生成する props を input コンポーネントに渡せば良いようでした
シンプルに統合できて良さそうですが {...register()} があまりきれいに見えず そんなにいいのか疑問もあります
ただこの考え方なら Form コンポーネントで Context を開始してその中の Input コンポーネントと連携してみたいなことは不要です
見た感じではそんなに機能もなさそうですし index.js ファイル 1 つで数百行くらいの規模なら入れてみるのもありかなと思ってソースコードを見てみました
https://github.com/react-hook-form/react-hook-form/tree/master/src
src フォルダ内で test を除いても 100 近くのファイルがありました
ほとんどは 100 行もないものでしたが 一部 1000 行を超えるファイルもありました
見た目ほど小さいライブラリではないようです
軽く見た感じだとパフォーマンスのために 再レンダリングを抑えるよう色々内部でやってるようです
使う側的には controlled じゃなくて uncontrolled 風に扱い submit イベント時に現在の値を取得するようです
form タグ無しでも button の onClick で useForm から得られる getValues を呼び出せばフォームの状態を取得できるようです
他にも使う機能だけを有効にするために Proxy を使っていて 通常の代入ではなく 分割代入機能で代入すべきというような制限もあるようです
パフォーマンス優先なのは好みですが React の標準的な部分から外れたことを内部でしてると 思い通り動かないときに対処が大変そうです
分割代入を使わないといけないみたいな制限もあまり好きじゃないです
そういうこともあって実際にこのライブラリを使おうとは現時点では思ってません
今後長期的に見て 使われ続けて標準的なものというポジションになったらでしょうか
hook も含めて短く済んでいるので ありかもしれません
オプションの value では value として扱うプロパティ名を指定して checked を使うケースに対応させています
入力値の変更時に他の input も合わせて更新したいときは 個別に設定よりは フォーム単位で 1 つの関数で処理したいので reducer という形にしました
デフォルトでは name に指定したプロパティをそのまま更新するので 複雑なルールがないなら指定なしで簡単に使えます
例えば テキストは小文字のみで checkbox を切り替えると select を毎回リセットするというルールの場合は reducer 関数を使って
とできます
デフォルトのものでは name をそのままプロパティ名とするので foo.bar みたい名前にしても foo.bar というプロパティに値がセットされるだけです
もしネストするオブジェクトにしたいなら reducer 関数側で制御して対応できます
自分で制御する部分なので name を foo/bar みたいに / 区切りに変えたりも自由にできます
Input コンポーネントを用意する別バージョンも試してみました
使用箇所の見た目はきれいになって見やすいです
しかし hook からコンポーネントを返す場合には問題がありました
React ではコンポーネントの関数が違えば中身が全く同じで 同じ ReactElement を返しても別物として扱われて DOM の要素は新しいものに置き換わります
例えばこういうコンポーネントと hook があるとします
rerender ボタンを押すたびに App コンポーネントは再レンダリングされます
そのとき Div コンポーネントは毎回新しく作られます
Div 関数内の処理は毎回全く同じで 結果として作られる HTML は同じものになるはずですが div 要素は毎回作り直されます
div の参照を保持しておき rerender 後に保持しておいたものと比較すると一致しません
ただの div などなら パフォーマンス的に問題なければ構いません
しかし input は特別で 要素が変わるとフォーカスも外れます
1 文字入力するたびにフォーカスが外れて入力できなくなります
これではフォームとしては使い物になりません
Input 関数が常に同じで関数であってほしいので毎回同じになるよう useCallback を使います
そのとき 中で state を参照するので依存配列に state の指定が必要です
それはつまり state が変わるたびに Input も新しくなるということです
state は入力があるたびに毎回変わるものなので結局毎回 Input が新しくなり入力に問題が出ます
これは Input をコンポーネントにするから起きる問題で ただの関数であれば結果として得られる ReactElement が同じなら問題ないです
Input コンポーネントの代わりに input 関数を返して {} の中で直接呼び出せば対処できます
しかし 使うときにコンポーネント風に扱えずあまり React っぽくない見た目になります
これなら {...register()} 風のほうがマシに思えます
どうにか Input コンポーネントとして使いたかったので ref を使って常に最新の state を参照できるようにして Input コンポーネントは常に同じものにできるようにしました
こういうケースの対応として React の rfcs リポジトリではより便利な hook が提案されてるので そういうのが早く使えるようになって欲しいです
見かけた限りでは setState の次に getState を受け取れて 呼び出すことで常に最新の state を得られるようにする hook や useCallback に近い形で関数を渡せば返ってくる関数は常に同じだけど呼び出すと内部で ref を通して保持している最新の関数が呼び出せる useEvent などがありました
できれば Input コンポーネントの方が見た目的に良いので こっちにしたいですが ref を使ったり特殊なことをしてる分 props を作るほうがいいかなとも思ったりです
ref を使うならいっその事 state に保持せず ref 内のみにしてしまえば再レンダリングを防げて パフォーマンス的に改善できるかもしれません
ただそういう方向にするとさらに react-hook-form に近づきますね
とりあえず Form を hook にしてみましたが react-hook-form みたいに props を作って ... で input などに追加する方法か Input コンポーネントを使うけど ref を使ってちょっと特殊なことをしてるかのどちらになりました
シンプルに state だけで管理するならこれまでの Form コンポーネントでいいのかもという気もしてます
その分 自由度は高くありますが 特別なことをするとき以外は面倒なだけです
これまで
小さいものなら onChange に setState を直接書いても別にいいかなと思いますが ある程度大きくなってくると楽したくなりますそういうときにいい方法はないかなと いろいろ試してみて 今のところこういう感じにするのが一番かなと思ってたのがこういう書き方です
<Form data={form_data} onChange={onChange}>
<div>
<Input component="input" type="text" name="text" />
</div>
<div>
<Input component="select" name="select">
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
</Input>
</div>
<button onClick={show}>OK</button>
</Form>
Form と Input コンポーネントは Context でつながっています
input の変更時に Form に設定した data を書き換えた版のオブジェクトを onChange で通知します
hook-form
React や Vue みたいなフレームワークくらいの規模のライブラリだといいとして そこで使うような小さいライブラリ等はできる限り入れたくないと思ってますあえてライブラリを入れるまでもないっていうのもそうですが 流行り廃りが早すぎます
人気のランキングを見ても毎年のように置き換わっているものが多いです
フォーム系では 最近は react-hook-form が流行りのようですが これが出る前によく使われてたものはもうオワコンで今は選択肢にも上がらないとかいう書き込みも目にしました
そんなですし フォーム制御程度にライブラリを入れようとも思いません
ただ最近おすすめに出てくる記事で react-hook-form を目にすることが何度かあったのでインターフェース的にどう言う感じで使うものか見てみました
hook-form というだけあって hook を使っています
<input {...register("name")} />
という感じで ライブラリが生成する props を input コンポーネントに渡せば良いようでした
シンプルに統合できて良さそうですが {...register()} があまりきれいに見えず そんなにいいのか疑問もあります
ただこの考え方なら Form コンポーネントで Context を開始してその中の Input コンポーネントと連携してみたいなことは不要です
見た感じではそんなに機能もなさそうですし index.js ファイル 1 つで数百行くらいの規模なら入れてみるのもありかなと思ってソースコードを見てみました
https://github.com/react-hook-form/react-hook-form/tree/master/src
src フォルダ内で test を除いても 100 近くのファイルがありました
ほとんどは 100 行もないものでしたが 一部 1000 行を超えるファイルもありました
見た目ほど小さいライブラリではないようです
軽く見た感じだとパフォーマンスのために 再レンダリングを抑えるよう色々内部でやってるようです
使う側的には controlled じゃなくて uncontrolled 風に扱い submit イベント時に現在の値を取得するようです
form タグ無しでも button の onClick で useForm から得られる getValues を呼び出せばフォームの状態を取得できるようです
他にも使う機能だけを有効にするために Proxy を使っていて 通常の代入ではなく 分割代入機能で代入すべきというような制限もあるようです
パフォーマンス優先なのは好みですが React の標準的な部分から外れたことを内部でしてると 思い通り動かないときに対処が大変そうです
分割代入を使わないといけないみたいな制限もあまり好きじゃないです
そういうこともあって実際にこのライブラリを使おうとは現時点では思ってません
今後長期的に見て 使われ続けて標準的なものというポジションになったらでしょうか
インターフェースの参考にした
このライブラリを使わなくても hook で取得した関数に設定を渡して input の props とするようなものは簡単に作れるので参考にして Form コンポーネントの代わりに useForm という hook を用意して使うようにしてみましたconst Component = () => {
const form = useForm({ foo: "aa", bar: "10", baz: true, qux: "a" })
console.log(form.state)
return (
<div>
<input {...form.input("foo")} />
<input type="number" {...form.input("bar")} />
<input type="checkbox" {...form.input("baz", { value: "checked" })} />
<select {...form.input("qux")}>
<option value="a">A</option>
<option value="b">B</option>
</select>
</div>
)
}
const defaultFormReducer = (state, name, option, event) => {
return { ...state, [name]: event.target[option.value || "value"] }
}
const useForm = (init, reducer = defaultFormReducer) => {
const [state, setState] = useState(init)
const input = (name, option = {}) => {
return {
[option.value || "value"]: state[name],
onChange: (...args) => setState(reducer(state, name, option, ...args)),
}
}
return { state, input }
}
hook も含めて短く済んでいるので ありかもしれません
オプションの value では value として扱うプロパティ名を指定して checked を使うケースに対応させています
入力値の変更時に他の input も合わせて更新したいときは 個別に設定よりは フォーム単位で 1 つの関数で処理したいので reducer という形にしました
デフォルトでは name に指定したプロパティをそのまま更新するので 複雑なルールがないなら指定なしで簡単に使えます
例えば テキストは小文字のみで checkbox を切り替えると select を毎回リセットするというルールの場合は reducer 関数を使って
const form = useForm(
{ foo: "aa", bar: "10", baz: true, qux: "a" },
(state, name, option, event) => {
const value = event.target[option.value || "value"]
const diff = (
name === "foo" ?
{ foo: value.toLowerCase() }
: name === "baz" ?
{ baz: value, qux: "a" }
:
{ [name]: value }
)
return { ...state, ...diff }
}
)
とできます
デフォルトのものでは name をそのままプロパティ名とするので foo.bar みたい名前にしても foo.bar というプロパティに値がセットされるだけです
もしネストするオブジェクトにしたいなら reducer 関数側で制御して対応できます
自分で制御する部分なので name を foo/bar みたいに / 区切りに変えたりも自由にできます
別パターン
ただやっぱり {...register()} に相当する部分はあまり見やすくありませんInput コンポーネントを用意する別バージョンも試してみました
const Component = () => {
const { state, Input } = useForm({ foo: "aa", bar: "10", baz: true, qux: "a", my_input: "" })
console.log(state)
return (
<div>
<Input name="foo"/>
<Input type="number" name="bar"/>
<Input type="checkbox" name="baz" />
<Input type="select" name="qux">
<option value="a">a</option>
<option value="b">b</option>
</Input>
<Input component={MyInput} name="my_input" />
</div>
)
}
const MyInput = ({ value, onChange }) => {
return <input value={value} onChange={(event) => onChange(event.target.value)} />
}
const useForm = (init, reducer = defaultFormReducer) => {
const [state, setState] = useState(init)
const ref = useRef()
ref.current = state
const Input = useCallback((input_props) => {
const state = ref.current
const { type, component: Component, name, ...props } = input_props
const onChange = (...args) => setState(reducer(state, input_props, ...args))
if (Component) {
return <Component value={state[name]} onChange={onChange} {...props} />
}
if (type === "textarea") {
return <textarea value={state[name]} onChange={onChange} {...props} />
}
if (type === "select") {
return <select value={state[name]} onChange={onChange} {...props} />
}
if (type === "checkbox" || type === "radio") {
return <input type={type} checked={state[name]} onChange={onChange} {...props} />
}
return <input type={type} value={state[name]} onChange={onChange} {...props} />
}, [])
return { state, Input }
}
const defaultFormReducer = (state, { type, name }, event) => {
const prop = type === "checkbox" || type === "radio" ? "checked" : "value"
const value = event?.target instanceof HTMLElement
? event.target[prop]
: event
return { ...state, [name]: value }
}
使用箇所の見た目はきれいになって見やすいです
<div>
<Input name="foo"/>
<Input type="number" name="bar"/>
<Input type="checkbox" name="baz" />
<Input type="select" name="qux">
<option value="a">a</option>
<option value="b">b</option>
</Input>
<Input component={MyInput} name="my_input" />
</div>
しかし hook からコンポーネントを返す場合には問題がありました
React ではコンポーネントの関数が違えば中身が全く同じで 同じ ReactElement を返しても別物として扱われて DOM の要素は新しいものに置き換わります
例えばこういうコンポーネントと hook があるとします
const useDiv = () => {
const Div = ({ children }) => {
return <div>{children}</div>
}
return { Div }
}
const App = () => {
const { Div } = useDiv()
const [, setState] = React.useState({})
return (
<div>
<button onClick={() => setState({})}>rerender</button>
<Div>
div item
</Div>
</div>
)
}
rerender ボタンを押すたびに App コンポーネントは再レンダリングされます
そのとき Div コンポーネントは毎回新しく作られます
Div 関数内の処理は毎回全く同じで 結果として作られる HTML は同じものになるはずですが div 要素は毎回作り直されます
div の参照を保持しておき rerender 後に保持しておいたものと比較すると一致しません
ただの div などなら パフォーマンス的に問題なければ構いません
しかし input は特別で 要素が変わるとフォーカスも外れます
1 文字入力するたびにフォーカスが外れて入力できなくなります
これではフォームとしては使い物になりません
Input 関数が常に同じで関数であってほしいので毎回同じになるよう useCallback を使います
そのとき 中で state を参照するので依存配列に state の指定が必要です
それはつまり state が変わるたびに Input も新しくなるということです
state は入力があるたびに毎回変わるものなので結局毎回 Input が新しくなり入力に問題が出ます
これは Input をコンポーネントにするから起きる問題で ただの関数であれば結果として得られる ReactElement が同じなら問題ないです
Input コンポーネントの代わりに input 関数を返して {} の中で直接呼び出せば対処できます
しかし 使うときにコンポーネント風に扱えずあまり React っぽくない見た目になります
<div>
{input({ name: "foo"})}
{input({ type: "number", name: "bar" })}
{input({ type: "checkbox", name: "baz" })}
{input({ type: "select", name: "qux"},
<>
<option value="a">a</option>
<option value="b">b</option>
</>
)}
{input({ component: MyInput, name: "my_input" })}
</div>
これなら {...register()} 風のほうがマシに思えます
どうにか Input コンポーネントとして使いたかったので ref を使って常に最新の state を参照できるようにして Input コンポーネントは常に同じものにできるようにしました
const useForm = (init, reducer = defaultFormReducer) => {
const [state, setState] = useState(init)
const ref = useRef()
ref.current = state
const Input = useCallback((input_props) => {
const state = ref.current
const { type, component: Component, name, ...props } = input_props
const onChange = (...args) => setState(reducer(state, input_props, ...args))
// 略
return <input type={type} value={state[name]} onChange={onChange} {...props} />
}, [])
return { state, Input }
}
こういうケースの対応として React の rfcs リポジトリではより便利な hook が提案されてるので そういうのが早く使えるようになって欲しいです
見かけた限りでは setState の次に getState を受け取れて 呼び出すことで常に最新の state を得られるようにする hook や useCallback に近い形で関数を渡せば返ってくる関数は常に同じだけど呼び出すと内部で ref を通して保持している最新の関数が呼び出せる useEvent などがありました
できれば Input コンポーネントの方が見た目的に良いので こっちにしたいですが ref を使ったり特殊なことをしてる分 props を作るほうがいいかなとも思ったりです
ref を使うならいっその事 state に保持せず ref 内のみにしてしまえば再レンダリングを防げて パフォーマンス的に改善できるかもしれません
ただそういう方向にするとさらに react-hook-form に近づきますね
const useRerender = () => {
const [, setState] = useState()
return useCallback(() => setState({}), [])
}
const useForm = (init, watcher, reducer = defaultFormReducer) => {
const ref = useRef(init)
const rerenderForm = useRerender()
const Input = useCallback((input_props) => {
const rerenderInput = useRerender()
const value = ref.current[name]
const { type, component: Component, name, ...props } = input_props
const onChange = (...args) => {
const state = ref.current
const next = reducer(state, input_props, ...args)
ref.current = next
if (watcher?.(state, next)) {
rerenderForm()
}
rerenderInput()
}
// 略
return <input type={type} value={value} onChange={onChange} {...props} />
}, [])
const getValues = useCallback(() => {
return ref.current
}, [])
const setValues = useCallback((values) => {
ref.current = values
}, [])
return { Input, getValues, setValues }
}
とりあえず Form を hook にしてみましたが react-hook-form みたいに props を作って ... で input などに追加する方法か Input コンポーネントを使うけど ref を使ってちょっと特殊なことをしてるかのどちらになりました
シンプルに state だけで管理するならこれまでの Form コンポーネントでいいのかもという気もしてます