I encountered this strange problem, this is the first time I encountered it. I created a button that uses the Redux toolkit to handle application creation. According to the UI design, the button should appear twice on the page as shown below. The highlighted button is the same component.
If I try to create an app, it displays two toast messages:
I noticed that if I remove one of the "Create App" buttons and keep one, and then I try to create an app it only displays a Toast message, as expected.
Is it an ideal best practice to create 2 separate buttons to handle one function?
This is the CreateAnApp button:
import React, { useState, useEffect } from "react"; import { Box, Button, Checkbox, FormControl, FormLabel, Flex, Input, useDisclosure, Modal, ModalOverlay, ModalContent, ModalHeader, Spinner, Text, ModalBody, ModalCloseButton, Wrap, Select, Textarea } from "@chakra-ui/react"; import { Select as Select1 } from "chakra-react-select"; import { useToast } from "@chakra-ui/react"; import { useDropzone } from "react-dropzone"; import "./style.css"; import { AiOutlineCloudUpload } from "react-icons/ai"; import { useDispatch, useSelector } from "react-redux"; import { createApp, reset } from "../../features/apps/appSlice"; export const CreateAnApp = (props) => { const { isOpen, onOpen, onClose } = useDisclosure(); const { variant, bg, textColor, fontSize, fontWeight, leftIcon, hover, children, ...rest } = props; const { isAppLoading, isError, isAppSuccess, message } = useSelector( (state) => state.app ); const toast = useToast(); const [formData, setFormData] = useState({ name: "", displayName: "", reason: "", product: "", environment: "", }); const { name, displayName, reason, product, environment } = formData; const [icon, setIcon] = useState([]); const { getRootProps, getInputProps } = useDropzone({ accept: "image/*", onDrop: (acceptedFiles) => { setIcon( acceptedFiles.map((file) => Object.assign(file, { preview: URL.createObjectURL(file), }) ) ); }, }); // const [product, setProduct] = useState([]); const [scopes, setScopes] = useState([]); const [institutionScope, setInstitutionScope] = useState([]); // const [environment, setEnvironment] = useState([]); const images = icon.map((file) => ( <img key={file.name} src={file.preview} alt="image" style={{ width: "50%", height: "50%" }} /> )); const onChange = (e) => { setFormData((prevState) => ({ ...prevState, [e.target.name]: e.target.value, })); }; const onCheckBoxChange = (event) => { if (event.target.checked) { setFormData((prevState) => ({ ...prevState, displayName: prevState.name, })); } } // handle onChange event of the dropdown const handleScopes = (e) => { setScopes(Array.isArray(e) ? e.map((x) => x.value) : []); }; const handleInstitutionScope = (e) => { setInstitutionScope(Array.isArray(e) ? e.map((x) => x.value) : []); }; // const handleEnvironment = (e) => { // setEnvironment(Array.isArray(e) ? e.map((x) => x.value) : []); // }; const scopesOptions = [ { label: "Transactions", value: "Transactions", }, { label: "Accounts", value: "Accounts", }, ]; const institutionScopeOptions = [ { label: "Neobanks", value: "Neobanks", }, { label: "DeFi/CeFi", value: "DeFi/CeFi", }, { label: "Personal finance", value: "Personal finance", }, { label: "Investments", value: "Investments", }, { label: "Wallets", value: "Wallets", }, ]; const dispatch = useDispatch(); useEffect(() => { if (isError) { toast({ title: "Error", description: message, status: "error", position: "top-right", duration: 5000, isClosable: true, }); dispatch(reset()); } if (isAppSuccess) { toast({ title: "App created", description: "Refreshing page", status: "success", position: "top-right", duration: 5000, isClosable: true, }); dispatch(reset()); onClose(); } }, [isAppSuccess, reset]); const onSubmit = async (e) => { e.preventDefault(); const appData = { name, displayName, product, // icon, scopes, reason, institutionScope, environment, }; dispatch(createApp(appData)); }; function SubmitButton() { if ( name?.length && displayName?.length && scopes?.length && environment?.length && reason?.length > 8 && institutionScope?.length > 0 ) { return ( <Button fontSize={{ sm: "12px", md: "14px" }} type="submit" borderRadius="md" color="white" bg="#002C8A" _hover={{ bg: "#002C6A" }} width={{ sm: "300px", md: "400px" }} > {isAppLoading ? <Spinner /> : "Create app"} </Button> ); } else { return ( <Button fontSize={{ sm: "12px", md: "14px" }} type="submit" borderRadius="md" color="white" bg="#002C8A" _hover={{ bg: "#002C6A" }} width={{ sm: "300px", md: "400px" }} isDisabled > {isAppLoading ? <Spinner /> : "Create app"} </Button> ); } } return ( <div> <Button {...rest} leftIcon={leftIcon} onClick={onOpen} bg={bg} textColor={textColor} borderRadius="lg" variant="solid" fontSize={fontSize} _hover={_hover} fontWeight={fontWeight} > Create an app </Button> <Modal size="lg" closeOnOverlayClick={false} isOpen={isOpen} onClose={onClose} > <ModalOverlay /> <ModalContent mt={1}> <ModalHeader textAlign="center" fontSize="md" color="#002c8a"> Create an app </ModalHeader> <ModalCloseButton /> <form onSubmit={onSubmit}> <ModalBody pb={6}> <Flex flexDirection={{ sm: "column", md: "row" }}> <Box> <Box mb={6}> <FormControl> <FormLabel fontSize="sm" fontWeight="semibold"> Add a logo to personalize your app </FormLabel> <Box width={{ sm: "340px", md: "450px" }} className="dropArea" {...getRootProps()} > <input {...getInputProps()} /> <Flex className="text" width={{ sm: "340px", md: "450px" }} > {images?.length > 0 && ( <> <div>{images}</div> </> )} {images?.length === 0 && ( <> <Box> <AiOutlineCloudUpload size={30} /> </Box> <Box> <Text fontSize="sm"> Drop app icon here or{" "} <Button variant="link" fontSize="sm" color="#002c8a" > browse </Button> </Text> </Box> </> )} </Flex> </Box> </FormControl> </Box> <Flex mt={6}> <Box> <FormControl> <FormLabel fontSize="sm" fontWeight="semibold"> App Name </FormLabel> <Input fontSize="14" width={{ sm: "165px", md: "225px" }} name="name" type="name" value={name} onChange={onChange} /> </FormControl> <Checkbox mt={1} onChange={onCheckBoxChange} size='sm' css={` > span:first-of-type { box-shadow: unset; } `}><Text fontSize="10.9px">Use as display name</Text></Checkbox> </Box> <Box ml={2}> <FormControl> <FormLabel fontSize="sm" fontWeight="semibold"> Display Name </FormLabel> <Input fontSize="14" width={{ sm: "165px", md: "225px" }} name="displayName" type="name" value={displayName} onChange={onChange} /> </FormControl> </Box> </Flex> <Box mt={3}> <FormLabel fontSize="sm" fontWeight="semibold"> Product </FormLabel> <Select name="product" placeholder=" " fontSize="14" value={product} onChange={onChange} > <option value="Connect">Connect</option> <option value="Directpay">Directpay</option> </Select> </Box> <Box w="100%" mt={3}> <FormLabel fontSize="sm" fontWeight="semibold"> Account </FormLabel> <Select1 useBasicStyles isMulti name="scopes" colorScheme="blue" placeholder=" " options={scopesOptions} closeMenuOnSelect={false} value={scopesOptions?.filter((obj) => scopes?.includes(obj.value) )} // set selected values onChange={handleScopes} /> </Box> <Box w="100%" mt={3}> <FormLabel fontSize="sm" fontWeight="semibold"> Institution </FormLabel> <Select1 useBasicStyles isMulti name="institution" colorScheme="blue" placeholder=" " _placeholder={{ color: "red" }} options={institutionScopeOptions} closeMenuOnSelect={false} value={institutionScopeOptions?.filter((obj) => institutionScope?.includes(obj.value) )} // set selected values onChange={handleInstitutionScope} /> </Box> <Box w="100%" mt={3}> <FormLabel fontSize="sm" fontWeight="semibold"> Environment </FormLabel> <Select name="environment" placeholder=" " fontSize="14" color="black" value={environment} onChange={onChange} > <option value="Sandbox">Sandbox</option> <option value="Production">Production</option></Select> {/*<FormLabel fontSize="sm" fontWeight="semibold"> Environment </FormLabel> <Select1 useBasicStyles name="environment" isMulti placeholder=" " options={environmentOptions} closeMenuOnSelect={true} color="black" value={environmentOptions?.filter((obj) => environment?.includes(obj.value) )} // set selected values onChange={handleEnvironment} />*/} </Box> <Box w="100%" mt={4}> <Textarea placeholder="Reason for data access" fontSize="sm" value={reason} name="reason" type="string" onChange={onChange} colorScheme="blue" /> </Box> </Box> </Flex> </ModalBody> <Wrap mb={6} justify="center"> <SubmitButton /> </Wrap> </form> </ModalContent> </Modal> </div> ); };
This is the application page:
import { Box, Button, Flex, Spacer, Center, Skeleton, SkeletonCircle, SkeletonText, Text, VStack, Image, Spinner, SimpleGrid, HStack, Avatar, Stack, Select, Hide, Tag, } from "@chakra-ui/react"; import React, { useState, useEffect } from "react"; import { MdFilterList } from "react-icons/md"; import { IoIosApps } from "react-icons/io"; import { ArrowLeftIcon, ArrowRightIcon, SpinnerIcon } from "@chakra-ui/icons"; import { CreateAnApp } from "../../../../components/Buttons/CreateAnApp"; import { useDispatch, useSelector } from "react-redux"; import { getAllApps } from "../../../../features/apps/appSlice"; import moment from "moment"; import { Link, useNavigate } from "react-router-dom"; import Card from "#components/Card/Card"; import CardBody from "#components/Card/CardBody"; import transaction_blue from "#assets/svg/transaction_blue.svg"; import { BsPlusCircleFill } from "react-icons/bs"; import useLocalStorage from "use-local-storage"; const Apps = () => { const dispatch = useDispatch(); const { apps, isLoading, isAppSuccess, meta } = useSelector( (state) => state.app ); const [mode] = useLocalStorage("apiEnv", false); const [loading, setLoading] = useState(true); useEffect(() => { setTimeout(() => { setLoading(false); }, 2000); }, [loading]); const fetchApps = () => { dispatch(getAllApps()); }; useEffect(() => { fetchApps(); }, [isAppSuccess]); return ( <> <Flex alignItems="center" mt={-3} ml={-4} p="5px" mb="10px"> <Spacer /> <Flex> <Box> <Skeleton borderRadius="lg" isLoaded={!loading}> <Button leftIcon={<MdFilterList size={20} />} variant="outline" textColor="black" borderRadius="lg" fontSize={{ sm: "xs", md: "sm" }} fontWeight="normal" > Filter </Button> </Skeleton> </Box> <Box ml={4}> <Skeleton borderRadius="lg" isLoaded={!loading}> <CreateAnApp bg="#002C8A" textColor="white" fontSize={{ sm: "xs", md: "sm" }} _hover={{ bg: "#002C6A" }} leftIcon={<BsPlusCircleFill size={16} />} fontWeight="normal" /> </Skeleton> </Box> </Flex> </Flex> {isLoading ? ( <Center> <Spinner mt={20} /> </Center> ) : ( <SimpleGrid mt={10} minChildWidth="360px" spacing="40px"> {isLoading ? ( <Center> <Spinner mt={20} /> </Center> ) : apps && apps?.length > 0 ? ( apps && apps?.map((app) => { return ( <Skeleton borderRadius="lg" isLoaded={!loading}> <Box _hover={{ bg: "white" }} h="150px" as="button" shadow="lg" p={2} w={{ sm: "85%", md: "350px" }} bg="#f5f5f5" borderRadius="lg" > <Link to={`/admin/viewapp/${app.uid}`}> <Box> {app.environment === "Sandbox" && ( <Box align="right" mt={-4}> <Tag variant="solid" borderRadius="10px" size="sm" colorScheme="orange" fontSize="xs" textTransform="uppercase" > Sandbox </Tag> </Box> )} {app.environment === "Production" && ( <Box align="right" mt={-4}> <Tag variant="solid" borderRadius="10px" colorScheme="green" size="sm" fontSize="xs" textTransform="uppercase" > Production </Tag> </Box> )} <HStack> <Box mt={3} ml={6}> <Avatar bg="black" color="white" name={app.name} /> </Box> <Box> <Stack ml={2}> <Box mt={-1}> <Text color="orange" textTransform="uppercase" fontSize="12px" > {app.product} </Text> </Box> <Box> <Text fontSize={{ sm: "sm", md: "lg" }} fontWeight="bold" > <SkeletonText isLoaded={!loading}> {app.displayName} </SkeletonText> </Text> </Box> </Stack> </Box> </HStack> <Text fontSize="sm"> Created on {moment(app.createdAt).format("LL")} </Text> </Box> </Link> </Box> </Skeleton> ); }) ) : ( <Center ml={{ sm: "0", md: -32 }}> <VStack spacing={4} align="stretch"> <Box> <Center> <SkeletonCircle isLoaded={!loading}> <IoIosApps size={45} /> </SkeletonCircle> </Center> </Box> <Box> <Text fontSize="30px" fontWeight={700}> <SkeletonText isLoaded={!loading}> No apps yet </SkeletonText> </Text> <Text mb={4}> <SkeletonText noOfLines={1} mt={2} isLoaded={!loading}> Create an app to get started </SkeletonText> </Text> <Skeleton borderRadius="lg" isLoaded={!loading}> <CreateAnApp w="200px" h="50px" leftIcon={ <BsPlusCircleFill className="bg-[#002C8A] hover: none text-white" size={30} /> } bg="#002C8A" _hover={{ bg: "#002C6A" }} color="white" /> </Skeleton> </Box> <Box></Box> </VStack> </Center> )} {apps && apps?.length > 1 && ( <Skeleton borderRadius="lg" isLoaded={!loading}> <CreateAnApp h="150px" ml={{ sm: 0, md: -20 }} shadow="lg" leftIcon={ <BsPlusCircleFill className="bg-[#f5f5f5] text-blue-800" size={30} /> } bg="#f5f5f5" textColor="black" border="2px" borderColor="gray.400" borderStyle="dashed" fontSize={{ sm: "sm", md: "2xl" }} fontWeight="bold" p={2} w={{ sm: "85%", md: "350px" }} /> </Skeleton> )} </SimpleGrid> )} </Box> </> ); }; export default Apps;
And my appSlice:
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; import appService from "./appService"; const initialState = { apps: [], app: [], isLoading: false, isAppLoading: false, isError: false, isAppSuccess: false, isSuccess: false, message: "", }; // Create new app export const createApp = createAsyncThunk( "app/createApp", async (appData, thunkAPI) => { try { const token = sessionStorage.getItem("token"); return await appService.createApp(appData, token); } catch (error) { const message = (error.response && error.response.data && error.response.data.message) || error.message || error.toString(); return thunkAPI.rejectWithValue(message); } } ); // Get all apps export const getAllApps = createAsyncThunk( "app/getAllApps", async (_, thunkAPI) => { try { const token = sessionStorage.getItem("token"); return await appService.getAllApps(token); } catch (error) { const message = (error.response && error.response.data && error.response.data.message) || error.message || error.toString(); return thunkAPI.rejectWithValue(message); } } ); export const appSlice = createSlice({ name: "app", initialState, reducers: { reset: (state) => { (state.isLoading = false), (state.isAppSuccess= false), (state.isAppLoading = false), (state.isSuccess = false), (state.isError = false), (state.message = ""); }, }, extraReducers: (builder) => { builder .addCase(createApp.pending, (state) => { state.isAppLoading = true; state.isError = false; }) .addCase(createApp.fulfilled, (state, action) => { state.isAppLoading = false; state.isAppSuccess = true; state.app = action.payload; }) .addCase(createApp.rejected, (state, action) => { state.isAppLoading = false; state.isError = true; state.message = action.payload; }) .addCase(getAllApps.pending, (state) => { state.isLoading = true; state.isError = false; }) .addCase(getAllApps.fulfilled, (state, action) => { state.isLoading = false; state.apps = action.payload.payload.data; }) .addCase(getAllApps.rejected, (state, action) => { state.isLoading = false; state.isError = true; state.message = action.payload; }) }, }); export const { reset } = appSlice.actions; export default appSlice.reducer;
I fixed this by moving the toast message function from the useEffect hook in Create Application to the Application page. I just figured it out in the bathroom haha. I can't elaborate more on this because I don't fully understand it yet. We are learning every day
Updated useEffect hook in the "Create Application" button:
Updated Application Page: