1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【React】useForm(ReactHookForm)とuseNumberInput(ChakraUI)の連携

Last updated at Posted at 2024-02-03

お疲れ様です!

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の入力欄の両サイドに「-」と「+」のボタンを配置して、モバイル端末からも入力がしやすいようにできます。

以下公式の例をそのまま記載します。

Create a mobile spinner

EDITABLE_EXAMPLE.tsx
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

INTEGRATING_WITH_UI_LIBRARIES.tsx
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のコンポーネントを使用しているのでサンプルコード内でエラーになっている箇所がありますが、無視して大丈夫です。

MISUSE_EXAMPLE.tsx
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)

修正後のサンプルは以下です。

CORRECT_USE_EXAMPLE.tsx
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
具体的には以下のようにします。

CORRECT_USE_EXAMPLE2.tsx
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で管理するようにしたものになります。

OPTIMAL_SOLUTION.tsx
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;

MyCounter.tsx
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方向の連携が解決できていませんでしたが、上記の例ではそちらも解決され、再レンダーも最小限になっているためまさにあるべき姿になっています。
改めて、コメントいただきありがとうございました!

1
1
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?