【Chakra UI】v3のNumber Inputコンポーネントで増減ボタンの値が反映されない事象の解決方法（Warning: ForwardRef contains an input of type text with both value and defaultValue props.）

# はじめに

お疲れ様です、りつです。

今回は、`Number Input`を使用した際に、使用方法を誤っていたために発生したエラーを共有します。

https://www.chakra-ui.com/docs/components/number-input

# 問題

モーダルの入力欄の値をDBに登録する処理を実装していたのですが、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3911933/2884bf23-64cf-e1c2-2d6c-f8c16046f553.png)

`学習時間`の入力欄が以下の挙動になってしまいました。
- 入力欄の値を直接入力して登録：入力された値が登録される
- 増減ボタンで値を入力して登録：
  - 直接入力してから増減ボタンを操作していた場合：直接入力した値が登録される
  - 直接入力せず増減ボタンを操作した場合：初期値（`0`）が登録登録される

また、開発者ツールのコンソール上に以下のWarningが表示されていました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3911933/f33e30f2-03c6-b388-b477-beb78eef457f.png)

```text:Warningメッセージ
Warning: ForwardRef contains an input of type text with both value and defaultValue props. Input elements must be either controlled or uncontrolled (specify either the value prop, or the defaultValue prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://reactjs.org/link/controlled-components
```

## ソースコード（修正前）

<details><summary>src/App.tsx</summary>

```react:src/App.tsx
import { ChangeEvent, useEffect, useState } from 'react';
import { MdEdit, MdDeleteOutline } from 'react-icons/md';
import { FiPlusCircle } from 'react-icons/fi';
import { Box, Center, Container, Flex, Heading, IconButton, Input, Spinner, Stack, Table } from '@chakra-ui/react';
import { Button } from '@/components/ui/button';
import { Field } from '@/components/ui/field';
import { NumberInputField, NumberInputRoot } from '@/components/ui/number-input';
import { Toaster } from '@/components/ui/toaster';
import { Record } from '@/domain/record';
import { useMessage } from '@/hooks/useMessage';
import { getAllRecords, insertRecord } from '@/utils/supabaseFunctions';
import {
  DialogActionTrigger,
  DialogBody,
  DialogCloseTrigger,
  DialogContent,
  DialogFooter,
  DialogHeader,
  DialogRoot,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';

function App() {
  const [records, setRecords] = useState<Record[]>([]);
  const [title, setTitle] = useState('');
  const [time, setTime] = useState('0');
  const [isLoading, setIsLoading] = useState(false);
  const [isCreating, setIsCreating] = useState(false);
  const [open, setOpen] = useState(false);
  const { showMessage } = useMessage();

  useEffect(() => {
    getRecords();
  }, []);

  const onClickOpenModal = () => {
    setTitle('');
    setTime('0');
    setOpen(true);
  };

  const getRecords = async () => {
    setIsLoading(true);
    getAllRecords()
      .then((data) => {
        setRecords(data);
      })
      .catch(() => {
        showMessage({ title: '一覧の取得に失敗しました', type: 'error' });
      })
      .finally(() => {
        setIsLoading(false);
      });
  };

  const onClickCreate = (record: Record) => {
    setIsCreating(true);
    insertRecord(record)
      .then(() => {
        showMessage({ title: '学習記録の登録が完了しました', type: 'success' });
      })
      .catch(() => {
        showMessage({ title: '学習記録の登録に失敗しました', type: 'error' });
      })
      .finally(() => {
        setIsCreating(false);
        setOpen(false);
        getRecords();
      });
  };

  const onChangeTitle = (e: ChangeEvent<HTMLInputElement>) => {
    setTitle(e.target.value);
  };

  const onChangeTime = (e: ChangeEvent<HTMLInputElement>) => {
    setTime(e.target.value);
  };

  return (
    <>
      <Toaster />

      <Box bg="teal.500" py="4" data-testid="title" color="gray.100">
        <Container maxW="6xl">
          <Flex justify="space-between" align="center">
            <Heading as="h1" textAlign="left">
              学習記録アプリ
            </Heading>
            <IconButton
              aria-label="Search database"
              variant="ghost"
              size="lg"
              color="white"
              _hover={{ bg: 'teal.500', color: 'gray.200' }}
              onClick={onClickOpenModal}
            >
              <FiPlusCircle />
            </IconButton>
          </Flex>
        </Container>
      </Box>

      {isLoading ? (
        <Center h="100vh">
          <Spinner />
        </Center>
      ) : (
        <Container maxW="6xl">
          <Table.Root size="md" variant="line" my={10}>
            <Table.Header>
              <Table.Row>
                <Table.ColumnHeader>タイトル</Table.ColumnHeader>
                <Table.ColumnHeader>時間</Table.ColumnHeader>
                <Table.ColumnHeader textAlign="end"></Table.ColumnHeader>
              </Table.Row>
            </Table.Header>
            <Table.Body>
              {records.map((record) => (
                <Table.Row key={record.id}>
                  <Table.Cell>{record.title}</Table.Cell>
                  <Table.Cell>{record.time}</Table.Cell>
                  <Table.Cell textAlign="end">
                    <Button colorPalette="blue" variant="outline" mr="4">
                      <MdEdit />
                      編集
                    </Button>
                    <Button colorPalette="red" variant="outline">
                      <MdDeleteOutline />
                      削除
                    </Button>
                  </Table.Cell>
                </Table.Row>
              ))}
            </Table.Body>
          </Table.Root>
        </Container>
      )}

      <DialogRoot lazyMount open={open} onOpenChange={(e) => setOpen(e.open)} motionPreset="slide-in-bottom" trapFocus={false}>
        <DialogContent>
          <DialogCloseTrigger />
          <DialogHeader>
            <DialogTitle>学習記録登録</DialogTitle>
          </DialogHeader>
          <DialogBody mx={4}>
            <Stack gap={4}>
              <Field label="学習内容" required>
                <Input value={title} onChange={onChangeTitle} />
              </Field>
              <Field label="学習時間" required>
                <NumberInputRoot min={0} width="100%">
                  <NumberInputField value={time} onChange={onChangeTime} />
                </NumberInputRoot>
              </Field>
            </Stack>
          </DialogBody>
          <DialogFooter mb="2">
            <DialogActionTrigger asChild>
              <Button variant="outline">キャンセル</Button>
            </DialogActionTrigger>
            <Button colorPalette="teal" loading={isCreating} onClick={() => onClickCreate({ title, time })}>
              登録
            </Button>
          </DialogFooter>
        </DialogContent>
      </DialogRoot>
    </>
  );
}

export default App;
```

</details>

# 解決方法

https://www.chakra-ui.com/docs/components/number-input#controlled

上記ドキュメントの`Controlled`に記載の内容を元に修正を行ったところ、入力欄への直接入力、増減ボタン操作による入力、いずれも正常に登録できるようになりました。

具体的には、以下のように修正しました。

- `NumberInputField`に指定している`value`と`onChange`を削除
- `NumberInputRoot`に`value`属性と`onValueChange`属性を指定
- `onChangeTime`は削除

## ソースコード（修正後）

<details><summary>src/App.tsx</summary>

```diff_tsx:src/App.tsx
import { ChangeEvent, useEffect, useState } from 'react';
import { MdEdit, MdDeleteOutline } from 'react-icons/md';
import { FiPlusCircle } from 'react-icons/fi';
import { Box, Center, Container, Flex, Heading, IconButton, Input, Spinner, Stack, Table } from '@chakra-ui/react';
import { Button } from '@/components/ui/button';
import { Field } from '@/components/ui/field';
import { NumberInputField, NumberInputRoot } from '@/components/ui/number-input';
import { Toaster } from '@/components/ui/toaster';
import { Record } from '@/domain/record';
import { useMessage } from '@/hooks/useMessage';
import { getAllRecords, insertRecord } from '@/utils/supabaseFunctions';
import {
  DialogActionTrigger,
  DialogBody,
  DialogCloseTrigger,
  DialogContent,
  DialogFooter,
  DialogHeader,
  DialogRoot,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';

function App() {
  const [records, setRecords] = useState<Record[]>([]);
  const [title, setTitle] = useState('');
  const [time, setTime] = useState('0');
  const [isLoading, setIsLoading] = useState(false);
  const [isCreating, setIsCreating] = useState(false);
  const [open, setOpen] = useState(false);
  const { showMessage } = useMessage();

  useEffect(() => {
    getRecords();
  }, []);

  const onClickOpenModal = () => {
    setOpen(true);
  };

  const getRecords = async () => {
    setIsLoading(true);
    getAllRecords()
      .then((data) => {
        setRecords(data);
      })
      .catch(() => {
        showMessage({ title: '一覧の取得に失敗しました', type: 'error' });
      })
      .finally(() => {
        setIsLoading(false);
      });
  };

  const onClickCreate = (record: Record) => {
    setIsCreating(true);
    insertRecord(record)
      .then(() => {
        showMessage({ title: '学習記録の登録が完了しました', type: 'success' });
      })
      .catch(() => {
        showMessage({ title: '学習記録の登録に失敗しました', type: 'error' });
      })
      .finally(() => {
        setIsCreating(false);
        setOpen(false);
        getRecords();
      });
  };

- const onChangeTitle = (e: ChangeEvent<HTMLInputElement>) => {
-   setTitle(e.target.value);
- };

  return (
    <>
      <Toaster />

      <Box bg="teal.500" py="4" data-testid="title" color="gray.100">
        <Container maxW="6xl">
          <Flex justify="space-between" align="center">
            <Heading as="h1" textAlign="left">
              学習記録アプリ
            </Heading>
            <IconButton
              aria-label="Search database"
              variant="ghost"
              size="lg"
              color="white"
              _hover={{ bg: 'teal.500', color: 'gray.200' }}
              onClick={onClickOpenModal}
            >
              <FiPlusCircle />
            </IconButton>
          </Flex>
        </Container>
      </Box>

      {isLoading ? (
        <Center h="100vh">
          <Spinner />
        </Center>
      ) : (
        <Container maxW="6xl">
          <Table.Root size="md" variant="line" my={10}>
            <Table.Header>
              <Table.Row>
                <Table.ColumnHeader>タイトル</Table.ColumnHeader>
                <Table.ColumnHeader>時間</Table.ColumnHeader>
                <Table.ColumnHeader textAlign="end"></Table.ColumnHeader>
              </Table.Row>
            </Table.Header>
            <Table.Body>
              {records.map((record) => (
                <Table.Row key={record.id}>
                  <Table.Cell>{record.title}</Table.Cell>
                  <Table.Cell>{record.time}</Table.Cell>
                  <Table.Cell textAlign="end">
                    <Button colorPalette="blue" variant="outline" mr="4">
                      <MdEdit />
                      編集
                    </Button>
                    <Button colorPalette="red" variant="outline">
                      <MdDeleteOutline />
                      削除
                    </Button>
                  </Table.Cell>
                </Table.Row>
              ))}
            </Table.Body>
          </Table.Root>
        </Container>
      )}

      <DialogRoot lazyMount open={open} onOpenChange={(e) => setOpen(e.open)} motionPreset="slide-in-bottom" trapFocus={false}>
        <DialogContent>
          <DialogCloseTrigger />
          <DialogHeader>
            <DialogTitle>学習記録登録</DialogTitle>
          </DialogHeader>
          <DialogBody mx={4}>
            <Stack gap={4}>
              <Field label="学習内容" required>
                <Input value={title} onChange={onChangeTitle} />
              </Field>
              <Field label="学習時間" required>
-               <NumberInputRoot min={0} width="100%">
+               <NumberInputRoot min={0} width="100%" value={time} onValueChange={(e) => setTime(e.value)}>
-                 <NumberInputField value={time} onChange={onChangeTime} />
+                 <NumberInputField />
                </NumberInputRoot>
              </Field>
            </Stack>
          </DialogBody>
          <DialogFooter mb="2">
            <DialogActionTrigger asChild>
              <Button variant="outline">キャンセル</Button>
            </DialogActionTrigger>
            <Button colorPalette="teal" loading={isCreating} onClick={() => onClickCreate({ title, time })}>
              登録
            </Button>
          </DialogFooter>
        </DialogContent>
      </DialogRoot>
    </>
  );
}

export default App;
```

</details>

# おわりに

今回は公式ドキュメントに載っている方法で無事解決できてよかったです。

HTMLの`input`タグのようなイメージで、`NumberInputField`の方に属性を指定してしまっていたのが誤りでした。
また、`onChange`ではなく`onValueChange`を使用する点にも注意が必要です。

# 参考

https://www.chakra-ui.com/docs/components/number-input