3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-12-24

はじめに

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

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

問題

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

image.png

学習時間の入力欄が以下の挙動になってしまいました。

  • 入力欄の値を直接入力して登録:入力された値が登録される
  • 増減ボタンで値を入力して登録:
    • 直接入力してから増減ボタンを操作していた場合:直接入力した値が登録される
    • 直接入力せず増減ボタンを操作した場合:初期値(0)が登録登録される

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

image.png

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

ソースコード(修正前)

src/App.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 = () => {
    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;

解決方法

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

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

  • NumberInputFieldに指定しているvalueonChangeを削除
  • NumberInputRootvalue属性とonValueChange属性を指定
  • onChangeTimeは削除

ソースコード(修正後)

src/App.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;

おわりに

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

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

参考

3
0
0

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
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?