はじめに
お疲れ様です、りつです。
今回は、Number Input
を使用した際に、使用方法を誤っていたために発生したエラーを共有します。
問題
モーダルの入力欄の値をDBに登録する処理を実装していたのですが、
学習時間
の入力欄が以下の挙動になってしまいました。
- 入力欄の値を直接入力して登録:入力された値が登録される
- 増減ボタンで値を入力して登録:
- 直接入力してから増減ボタンを操作していた場合:直接入力した値が登録される
- 直接入力せず増減ボタンを操作した場合:初期値(
0
)が登録登録される
また、開発者ツールのコンソール上に以下のWarningが表示されていました。
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
に指定しているvalue
とonChange
を削除 -
NumberInputRoot
にvalue
属性と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
を使用する点にも注意が必要です。
参考