お疲れ様です!
Reactをやっと10%くらい理解できた気がしているReact初心者です。
普段はJavaで開発をしています。
エンジニア歴は1年半くらいです。
フロントエンドにReactを使用したWebアプリを個人的に作成しており、Formの状態管理にReactHookForm、UIライブラリにChakraUIを使用しています。
ChakraUIのuseNumberInputを使用して数字入力を制御しようとしたところ、ReactHookFormと競合して若干詰まったので、概要と解決策をまとめます。
ピンポイントすぎて需要ない気もしますが、解決してスッキリした勢いで書いています。
誤りやより良い解決法等ありましたら、教えていただけると嬉しいです!
初回投稿後2回更新を行なっており、「7.【重要】2024/02/05追記」が最も良い解決策になりますので必ずそちらをご確認ください。
目次
1.useNumberInput
2.useForm
3.valueの競合
4.解決策
5.おわりに
6.2024/02/05追記
7.【重要】2024/02/05追記②
1. useNumberInput
公式のDocを見るとわかりやすいですが、Formの入力欄の両サイドに「-」と「+」のボタンを配置して、モバイル端末からも入力がしやすいようにできます。
以下公式の例をそのまま記載します。
function HookUsage() {
const { getInputProps, getIncrementButtonProps, getDecrementButtonProps } =
useNumberInput({
step: 0.01,
defaultValue: 1.53,
min: 1,
max: 6,
precision: 2,
})
const inc = getIncrementButtonProps()
const dec = getDecrementButtonProps()
const input = getInputProps()
return (
<HStack maxW='320px'>
<Button {...inc}>+</Button>
<Input {...input} />
<Button {...dec}>-</Button>
</HStack>
)
}
useNumberInputは引数にステップやデフォルト値等の値をとり、引数に応じたInput、+ボタン、-ボタンの属性を取得できる関数などを返します。
あとはそれらの関数から受け取った属性を各コンポーネントのタグ内で展開するだけで使えます。
2. useForm
UIライブラリをしない場合はuseForm()から受け取ったregisterをタグ内で展開して使用しますが、UIライブラリを使用している場合はControllerコンポーネントを使用して管理します。
こちらも公式の例をそのまま記載します。
Integrating with UI libraries
import Select from "react-select";
import { useForm, Controller, SubmitHandler } from "react-hook-form";
import Input from "@material-ui/core/Input";
interface IFormInput {
firstName: string;
lastName: string;
iceCreamType: { label: string; value: string };
}
const App = () => {
const { control, handleSubmit } = useForm({
defaultValues: {
firstName: '',
lastName: '',
iceCreamType: {}
}
});
const onSubmit: SubmitHandler<IFormInput> = data => {
console.log(data)
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="firstName"
control={control}
render={({ field }) => <Input {...field} />}
/>
<Controller
name="iceCreamType"
control={control}
render={({ field }) => <Select
{...field}
options={[
{ value: "chocolate", label: "Chocolate" },
{ value: "strawberry", label: "Strawberry" },
{ value: "vanilla", label: "Vanilla" }
]}
/>}
/>
<input type="submit" />
</form>
);
};
上記の例ではMUIを使用していますが、ChakraUIの場合も同じように書けます。
Controllerコンポーネントのrender内で対象のFormに属性を展開することで、状態を管理できるようにしています。
useNumberInputと使い方がほとんど同じです。
3. valueの競合
以下、useFormとuseNumberInputを同時に使用する際の誤用例です。
バリデーション等は省いていますが、ユーザーの年齢を編集できるFormを返すコンポーネントです。
chakraUIのコンポーネントを使用しているのでサンプルコード内でエラーになっている箇所がありますが、無視して大丈夫です。
import React from "react";
import type { ReactNode } from "react";
import { useForm, Controller } from "react-hook-form";
import { Button, HStack, Input, useNumberInput } from "@chakra-ui/react";
import axios from "axios";
interface User {
id: number;
age: number;
}
interface Props {
user: User;
}
const MisuseExample = ({ user }: Props): ReactNode => {
const { getInputProps, getIncrementButtonProps, getDecrementButtonProps } =
useNumberInput({
step: 1,
defaultValue: user.age,
min: 0,
max: 150,
precision: 0,
});
const inc = getIncrementButtonProps();
const dec = getDecrementButtonProps();
const input = getInputProps();
const { control, handleSubmit } = useForm<User>();
const onSubmit = (data: User): void => {
console.log(data.age);
axios
.put(`/users/${user.id}`, { user: data })
.then((res) => {
console.log(res.data);
})
.catch((error) => {
console.log(error);
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<HStack maxW="320px">
<Button {...inc}>+</Button>
<Controller
name="age"
control={control}
render={({ field }) => <Input {...field} {...input} />}
/>
<Button {...dec}>-</Button>
</HStack>
<Button type="submit">Submit</Button>
</form>
);
};
export default MisuseExample;
上記のコードは画面上は正しく動作し、「+」ボタンや「ー」ボタンでInputの値を増減させることができます。
しかしいざSubmitしてみると、ageがデフォルト値から変更されずに送信され、画面に表示されている値で更新することができません。
それなら、とInput内の{...field}と{...input}の順番を入れ替えてみると、今度はボタン押下で値を増減させることができなくなります。
上記の現象から、useFormで管理しているFormのvalueと、useNumberInputで持っているvalueが競合していることがわかりました。
4. 解決策
useNumberInputで持っているvalueが更新された際に、useFormで管理しているvalueに反映させます。
useNumberInputとuseFormが返す値を改めて確認したところ、以下の値と関数があるようでした。
- useNumberInput:value もしくは valueAsNumber
- useForm:setValue(name, value)
修正後のサンプルは以下です。
import React, { useEffect } from "react";
import type { ReactNode } from "react";
import { useForm, Controller } from "react-hook-form";
import { Button, HStack, Input, useNumberInput } from "@chakra-ui/react";
import axios from "axios";
interface User {
id: number;
age: number;
}
interface Props {
user: User;
}
const CorrectUseExample = ({ user }: Props): ReactNode => {
const {
getInputProps,
getIncrementButtonProps,
getDecrementButtonProps,
valueAsNumber, // 追加
} = useNumberInput({
step: 1,
defaultValue: user.age,
min: 0,
max: 150,
precision: 0,
});
const inc = getIncrementButtonProps();
const dec = getDecrementButtonProps();
const input = getInputProps();
// 追加
useEffect(() => {
setValue("age", valueAsNumber);
}, [valueAsNumber]);
//
const { control, handleSubmit, setValue } = useForm<User>(); // setValueを追加
const onSubmit = (data: User): void => {
console.log(data.age);
axios
.put(`/users/${user.id}`, { user: data })
.then((res) => {
console.log(res.data);
})
.catch((error) => {
console.log(error);
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<HStack maxW="320px">
<Button {...inc}>+</Button>
<Controller
name="age"
control={control}
render={({ field }) => <Input {...field} {...input} />}
/>
<Button {...dec}>-</Button>
</HStack>
<Button type="submit">Submit</Button>
</form>
);
};
export default CorrectUseExample;
useEffectでvalueAsNumberの更新を検知して、setValueでuseForm管理のvalueにvalueAsNumberを設定しています。
これでsubmit時に画面と同じ値が送信されるようになります。
5. おわりに
Reactの学習を始めたときはステートやプロップス、副作用の概念が理解できなくて非常に苦しかったですが、徐々に慣れて楽しくなってきました。
アプリが完成したら、また別途記事を書こうと思います。
以上、最後までご覧いただきありがとうございました!
6. 2024/02/05追記
別な解決策を見つけた&そちらの方が良さげだったので追記しておきます。
以下参考にしたstackoverflowのリンクです。
useNumberInput hook in Chackra with Formik
具体的には以下のようにします。
import React, { type ReactNode } from "react";
import { useForm, Controller } from "react-hook-form";
import { Button, HStack, Input, useNumberInput } from "@chakra-ui/react";
import axios from "axios";
interface User {
id: number;
age: number;
}
interface Props {
user: User;
}
const CorrectUseExample = ({ user }: Props): ReactNode => {
const {
getInputProps,
getIncrementButtonProps,
getDecrementButtonProps,
} = useNumberInput({
step: 1,
defaultValue: user.age,
min: 0,
max: 150,
precision: 0,
// 追加
onChange(valueAsString, valueAsNumber) {
setValue('age', valueAsNumber);
},
//
});
const inc = getIncrementButtonProps();
const dec = getDecrementButtonProps();
const input = getInputProps();
const { control, handleSubmit, setValue } = useForm<User>(); // setValueを追加
const onSubmit = (data: User): void => {
console.log(data.age);
axios
.put(`/users/${user.id}`, { user: data })
.then((res) => {
console.log(res.data);
})
.catch((error) => {
console.log(error);
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<HStack maxW="320px">
<Button {...inc}>+</Button>
<Controller
name="age"
control={control}
render={({ field }) => <Input {...field} {...input} />}
/>
<Button {...dec}>-</Button>
</HStack>
<Button type="submit">Submit</Button>
</form>
);
};
export default CorrectUseExample;
useNumberInputのプロップスにはオプションでonChange(valueAsString: string, valueAsNumber: number): void
を渡すことができ、該当のvalueが更新されたときにonChangeが呼ばれます。
そのため、onChangeの中でsetValueすればuseFormのvalueも更新されるという仕組みです。
5.解決策で紹介した方法よりも、こっちの方が良い感じですね。
7. 【重要】2024/02/06追記②
@honey32 さんからコメントにて、より良い解決方法を教えていただいたので再度追記させていただきます。
もし参考にされる方がいましたらこちらの方法を使用してください!
具体的には増減ボタンとInputを1つの制御コンポーネントとして切り出し、このコンポーネントをReactHookFormのControllerで管理するようにしたものになります。
import React from 'react';
import type { ReactNode } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { Button, HStack } from '@chakra-ui/react';
import axios from 'axios';
import { MyCounter } from './MyCounter';
interface User {
id: number;
age: number;
}
interface Props {
user: User;
}
const OptimalSolution = ({ user }: Props): ReactNode => {
const { control, handleSubmit } = useForm<User>();
const onSubmit = (data: User): void => {
console.log(data.age);
axios
.put(`/users/${user.id}`, { user: data })
.then((res) => {
console.log(res.data);
})
.catch((error) => {
console.log(error);
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<HStack maxW="320px">
<Controller
name="age"
control={control}
defaultValue={0}
// MyCounterにvalueとonChangeを渡す
render={({ field }) => <MyCounter {...field} />}
/>
</HStack>
<Button type="submit">Submit</Button>
</form>
);
};
export default OptimalSolution;
import { Button, Input, useNumberInput } from '@chakra-ui/react';
import React, { type FC } from 'react';
interface Props {
value: number;
onChange: (valueAsNumber: number) => void;
}
export const MyCounter: FC<Props> = ({ value, onChange }) => {
const { getInputProps, getIncrementButtonProps, getDecrementButtonProps } =
useNumberInput({
step: 1,
min: 0,
max: 150,
precision: 0,
// useFormとuseNumberInputの連携
value,
onChange: (_valueAsString, valueAsNumber) => {
onChange(valueAsNumber);
},
//
});
const inc = getIncrementButtonProps();
const dec = getDecrementButtonProps();
const input = getInputProps();
return (
<>
<Button {...inc}>+</Button>
<Input {...input} />
<Button {...dec}>-</Button>
</>
);
};
export default MyCounter;
これまでの2例ではuseForm→useNumberInput方向の連携が解決できていませんでしたが、上記の例ではそちらも解決され、再レンダーも最小限になっているためまさにあるべき姿になっています。
改めて、コメントいただきありがとうございました!