I'm building a React stepper with MUI and I wanted to have it be dynamic depending on the data coming in and also to be able to add/remove steps within that dynamic section. I was not able to find any examples or posts on here regarding what I had in mind, and the docs for MUI's stepper don't go anywhere near touching dynamic/responsive steps like this. I've been able to get it pretty far (actually a bit surprised I got it as far as I have), but I'm stuck right at the end. I've set up a generic example on CSB (link below), with the stepper pulling in data objects, displaying them on the dynamic steps, and the add/remove functionality works for the step labels, but not for the content. This is where I can't figure it out, I set up the structure the same for the labels and content, and since is basically just pushing to/filtering out arrays, I'm not finding where the difference is. I figure I'd post it here before messing with it any further. Also, if anyone has any suggestions on how to clean it up, I'd be happy to hear those as well, I'm sure it is a bit messy and heavy handed in getting the job done.
Heres the main Stepper component:
const DynamicStepper = () => {
const [open, setOpen] = useState(false);
const [activeStep, setActiveStep] = useState(0);
const [copiedObjs, setCopiedObjs] = useState([]);
const [copiedLabels, setCopiedLabels] = useState([]);
const [middleContent, setMiddleContent] = useState([]);
const [stepsContent, setStepsContent] = useState([])
//Dialog actions
const handleClickOpen = () => {
setOpen(true);
};
const handleClose = () => {
setOpen(false);
};
//Stepper actions
const handleNext = () => {
setActiveStep((prevActiveStep) => prevActiveStep + 1);
};
const handleBack = () => {
setActiveStep((prevActiveStep) => prevActiveStep - 1);
}
//Setting up dynamic labels (actual code involves conditional api calls)
useEffect(() => {
if(data) {
setCopiedObjs(data)
}
}, [])
useEffect(() => {
if(copiedObjs.length) {
let labeling = copiedObjs.map((i) => (
i.title
))
setCopiedLabels(labeling)
}
}, [copiedObjs])
//Set labels for stepper
const initialLabels = ["First", "Second", "Third"]
const finalLabels = ["One from the End", "Final"]
const steps = initialLabels.concat(copiedLabels).concat(finalLabels)
//Set content for stepper
//moved into useEffect
// function getMiddleContent(){
// const content = copiedObjs.map((obj, idx) => (
// <StepDynamic key={idx} props={obj} />
// ))
// setMiddleContent(content)
// }
useEffect(() => {
const content = copiedObjs.map((obj, idx) => (
<StepDynamic key={idx} props={obj} />
))
setMiddleContent(content)
}, [middleContent, copiedObjs])
useEffect(() => {
//add/delete steps
function addStep(n = 1){
let newSteps = [...copiedLabels];
let newContent = [...middleContent];
let newLabel = ["new obj"]
newSteps.push(...newLabel);
console.log("midContent pre push: ", middleContent)
let content = [<StepDynamic key={Math.random()*3} props={null} />]
newContent.push(...content)
console.log("postPush, newContent: ", content)
setCopiedLabels(newSteps)
setMiddleContent(content)
}
function removeStep(idx){
let newSteps = [...copiedLabels];
let newContent = [...middleContent];
let steps = newSteps.filter((item, i) => i !== idx);
let content = newContent.filter((item, i) => i !== idx)
setCopiedLabels(steps)
setMiddleContent(content)
}
const initialContent = [<StepOne key={1} />, <StepTwo key={2} />, <StepThree key={3} addStep={addStep} removeStep={removeStep} titles={copiedLabels} />]
const finalContent = [<StepPenUltimate key={4} />, <StepFinal key={5} />]
const content = initialContent.concat(middleContent).concat(finalContent)
setStepsContent(content)
}, [middleContent, copiedLabels])
function getStepsContent(stepIndex) {
return stepsContent[stepIndex]
}
//Moved this section into useEffect to see if I got better results, but responds the same as before
// //add/delete steps
// function addStep(n = 1){
// let newSteps = [...copiedLabels];
// let newContent = [...middleContent];
// let newLabel = ["new obj"]
// newSteps.push(...newLabel);
// console.log("midContent pre push: ", middleContent)
// let content = [<StepDynamic key={Math.random()*3} props={null} />]
// newContent.push(...content)
// console.log("postPush, newContent: ", content)
// setCopiedLabels(newSteps)
// setMiddleContent(content)
// }
// function removeStep(idx){
// let newSteps = [...copiedLabels];
// let newContent = [...middleContent];
// let steps = newSteps.filter((item, i) => i !== idx);
// let content = newContent.filter((item, i) => i !== idx)
// setCopiedLabels(steps)
// setMiddleContent(content)
// }
return (
<>
<Button variant="contained" color="primary" onClick={handleClickOpen}>
New Stepper
</Button>
<Dialog open={open} onClose={handleClose}>
<DialogTitle>Stepper Guide</DialogTitle>
<DialogContent>
<DialogContentText>
Just some words of guidance
</DialogContentText>
<div>
<Stepper activeStep={activeStep} alternativeLabel>
{steps && steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
<br />
</Stepper>
<>
{activeStep === steps.length ? (
"Complete"
) : (
<>
{getStepsContent(activeStep)}
<Button color='warning' onClick={handleBack}>
{activeStep === steps[0] ? "" : "Back"}
</Button>
<Button color="primary" onClick={handleNext}>
{activeStep === steps.length ? "Submit" : "Next"}
</Button>
</>
)}
</>
</div>
<DialogActions>
<Button color="error" onClick={handleClose}>
Cancel
</Button>
</DialogActions>
</DialogContent>
</Dialog>
</>
)
}
And heres the CSB: https://codesandbox.io/s/eager-cerf-tsrbci?file=/src/Stepper.js:463-5442
UPDATE
After playing around and a lot of console.logs I think I've figured out where the issue is, but I'm not sure how to resolve it. When adding/ removing a step, the functions are wrapped within the same useEffect as the logic to set the content for the steps. This was done to be able to provide the functions to the StepThree component where the adding/ removing takes place on the UI. However, it seems the state update and the logic setting the content are not on the same time cycle and so the content setting does not have access to the new updated state. I've tried breaking apart the useEffect into multiple and tried just writing separate functions, but because there is a lot of conditional state being utilized, and in order for the different pieces to correctly have access, as well as to avoid infinite re-render loops, I keep coming back to the same setup. The label setting works fine because the setting of the array for the labels happens outside the useEffect that compiles the conditional state.
If I'm wrong on my assessment please let me know, and if you have any ideas on how to better structure this, I'd love to hear it. I've looked at useReducer as a possible alternative, but I'm not that familiar with the react hooks beyond useState and useEffect, and so far haven't determined how to write out the reducer, again I keep coming back to needing to have the conditional pieces of state available at the right place in the code.
Related
I am currently get this duplicated key warning "Warning: Encountered two children with the same key". However, I am unsure where this duplication of key comes from. I am using the fileData id as my key which should be unique as it is firebase generated id. Therefore, I am not so sure what is happening behind here.
Here are my codes below and the warning I get.
MultimediaDetails.js
import React, { useEffect, useState } from "react";
import * as AiIcons from "react-icons/ai";
import * as FaIcons from "react-icons/fa";
import { database } from "../../../firebase";
import ViewImageFileModal from "../../modals/multimediaModals/view/ViewImageFileModal";
/**
* It's a component that displays audio, video, and image files
* #param props - The props object that is passed to the component.
* #returns The MultimediaDetails component is being returned.
*/
const MultimediaDetails = (props) => {
/* Destructuring the props object. */
const { pId } = props;
/* Setting the state of the component. */
const [imageData, setImageData] = useState([]);
const [imageMessage, setImageMessage] = useState(true);
const userType = JSON.parse(localStorage.getItem("admin") ?? false);
// Modal Variables
const [showViewImageModal, setShowViewImageModal] = useState(false);
const [fileData, setFileData] = useState(Object);
/**
* When the user clicks on the audio, video, or image file, the file data is set and the modal is
* toggled.
* #param obj
*/
const viewImageFile = (obj) => {
setFileData(obj);
toggleViewImageModal();
};
/* The function to toggle modal states */
const toggleAddImageModal = () => setShowAddImageModal((p) => !p);
const toggleViewImageModal = () => setShowViewImageModal((p) => !p);
useEffect(() => {
/* Query data from database and listening for changes. */
const imageQuery = database.portfolioRef.doc(pId).collection("images");
const unsubscribeImage = imageQuery.onSnapshot((snapshot) => {
if (snapshot.docs.length !== 0) {
setImageMessage(false);
setImageData(
snapshot.docs.map((doc) => ({ ...doc.data(), id: doc.id }))
);
} else {
setImageMessage(true);
}
});
return () => {
unsubscribeImage();
};
}, [pId]);
return (
<div className="multimedia-section">
<div id="image-section">
<div id="image-header">
<h6>
<u>Images</u>
</h6>
{userType ? (
<button className="addbtn" onClick={() => toggleAddImageModal()}>
<AiIcons.AiOutlinePlus /> Add Image File
</button>
) : (
<></>
)}
</div>
<div id="image-content" className="multimedia-flex">
{imageMessage ? (
<p>There is not existing images for this portfolio.</p>
) : (
<div>
{imageData.map((doc) => (
<button
key={doc.id}
className="fileBtn"
onClick={() => viewImageFile(doc)}
>
<FaIcons.FaImage /> {doc.imageName}
</button>
))}
</div>
)}
</div>
</div>
<ViewImageFileModal
show={showViewImageModal}
toggleModal={toggleViewImageModal}
pId={pId}
data={fileData}
key={fileData.id}
/>
</div>
);
};
export default MultimediaDetails;
The initialised values for the Modal.
/* Setting the initial state of the component. */
const valueState = {
name: '',
description: ''
}
const { currentUser } = useAuth();
const [formStateDisabled, setFormStateDisabled] = useState(true);
const [deleteState, setDeleteState] = useState(false);
const [message, setMessage] = useState('');
const [imageUrl, setImageUrl] = useState("");
const [loadForm, setLoadForm] = useState(false)
const [view, setView] = useState(false);
/* Destructuring the props object. */
const { show, toggleModal } = props;
const { handleChange, handleSubmit, values, errors, loading } =
useForm(validateUpdate, valueState, handleUpdate);
useEffect(() => {
if (Object.keys(props.data).length !== 0) {
values.name = props.data.imageName;
values.description = props.data.imageDesc;
setLoadForm(true);
}
}, [])
The warning I get (Shown Below), each time I click on the modal button to open the button, I noticed the warning actually repeats twice, and when I close it, it repeats another 2 times making it 4. I am not sure what is the cause of this, please help! Thank you!
Updates of trials
I only have 4 rows of data, all of which has its own unique id. Therefore I am unsure of where the duplicated key came from. However, if I remove the modal key "fileData.id" this warning would disappear. However, my component state will not reset and there will be a lot of props data issue that would surface. Where data for the previously clicked button will appear on the another button. Or the data might not appear at all.
FOR ADDITIONAL INFORMATION:
This is the output for the map buttons
I don't see any duplicates, and I am not sure where the issue is. Is there something I am doing wrong to cause this error. I checked my DB there isn't any data error as well.
Recommended solution
The problem is that your doc.id is repeating.
You are setting the imageData at the imageQuery.onSnapshot callback function, when you run the following code:
setImageData(snapshot.docs.map((doc) => ({ ...doc.data(), id: doc.id })));
What you need to make sure is that doc.id is unique in this context (because you're using this value at the key attribute in your buttons).
That's the correct way to fix it.
Alternative solution
Another way to handle it (as a last resort), is using the following code, where you use the index position of the element at the key attribute:
{imageData.map((doc, index) => (
<button
key={index}
className="fileBtn"
onClick={() => viewImageFile(doc)}
>
<FaIcons.FaImage /> {doc.imageName}
</button>
))}
But this is not recommended according to the React documentation:
We don’t recommend using indexes for keys if the order of items may
change. This can negatively impact performance and may cause issues
with component state. Check out Robin Pokorny’s article for an
in-depth explanation on the negative impacts of using an index as a key.
If you choose not to assign an explicit key to list items then
React will default to using indexes as keys.
Here is an in-depth explanation about why keys are necessary if you’re
interested in learning more.
Instead of doc.id use map item index like bellow. See if it works.
{imageData.map((index, doc) => (
<button
key={index}
className="fileBtn"
onClick={() => viewImageFile(doc)}>
<FaIcons.FaImage /> {doc.imageName}
</button>
))}
This is the culprit:
{imageData.map((doc) => (
<button
key={doc.id}
className="fileBtn"
onClick={() => viewImageFile(doc)}
>
<FaIcons.FaImage /> {doc.imageName}
</button>
))}
The main issue here is that doc.id is duplicate, probably you have duplicate data in your imageData or ou have a faulty data in your database or something that generate a non-unique id.
To easily fix the issue, what you can do is use index of map.
{imageData.map((doc, index) => (
<button
key={index}
className="fileBtn"
onClick={() => viewImageFile(doc)}
>
<FaIcons.FaImage /> {doc.imageName}
</button>
))}
index are always unique. but I suggest you should fix and see why you have duplicate data instead of just bypassing it with an index.
UPDATE
This is quite a hacky solution, but since I can't really pin point what's causing the issue without investigating first hand, let's make it so you don't have to pass a key on the modal.
So instead of storing the object data on the state, store the id instead:
Rename fileData to fileDataId.
const [fileDataId, setFileDataId] = useState(0);
then store the id when clicking the button.
{imageData.map((doc) => (
<button
key={doc.id}
className="fileBtn"
onClick={() => viewImageFile(doc.id)}
>
<FaIcons.FaImage /> {doc.imageName}
</button>
))}
on the Modal, you have to pass the imageData and the selected id, then remove the key:
<ViewImageFileModal
show={showViewImageModal}
toggleModal={toggleViewImageModal}
pId={pId}
list={imageData}
selectedId={fileDataId}
/>
then inside ViewImageFileModal you can declare data as:
const data= props.list.find(image => image.id === props.selectedId);
I just wrap my model with a condition if imageId exist and it works already!
{imageId !== '' &&
<ViewImageFileModal
show={showViewImageModal}
toggleModal={toggleViewImageModal}
imageData={imageData}
key={imageId}
/>
}
I am a Beginner to Reactjs and I just started working on a Tinder Clone with swipe functionality using tinde-card-react.
I am trying to get two variables to update using React useState() but coudn't.
There are 2 main components inside the main function, a TinderCards component and Swipe right and left and Replay buttons. The problem is that when I swipe the cards manually variables don't get updated and this is not the case when i swipe using the buttons.
In the current log, I swiped the cards twice to the right and logged the variables alreadyRemoved and people. The variable people is initially an Array containing 3 objects so after the second swipe it's supposed to log only 2 objects not 3, While the alreadyRemoved variable is supposed to update to the missing elements of the variable people.
This is my code :
import React, { useState, useEffect, useMemo } from 'react';
import './IslamCards.css';
import Cards from 'react-tinder-card';
import database from './firebase';
import hate from "./Cross.png"
import replayb from "./Replay.png"
import love from "./Love.png"
import IconButton from "#material-ui/core/IconButton"
function IslamCards(props) {
let [people, setPeople] = useState([])
useEffect(() => {
database.collection("People").onSnapshot(snapshot => { setPeople(snapshot.docs.map(doc => doc.data())) })
}, [])
let [alreadyRemoved , setalreadyRemoved] = useState([])
let buttonClicked = "not clicked"
// This fixes issues with updating characters state forcing it to use the current state and not the state that was active when the card was created.
let childRefs = useMemo(() => Array(people.length).fill(0).map(() => React.createRef()), [people.length])
let swiped = () => {
if(buttonClicked!=="clicked"){
console.log("swiped but not clicked")
if(people.length){
let cardsLeft = people.filter(person => !alreadyRemoved.includes(person))
if (cardsLeft.length) {
let toBeRemoved = cardsLeft[cardsLeft.length - 1] // Find the card object to be removed
let index = people.map(person => person.name).indexOf(toBeRemoved.name)// Find the index of which to make the reference to
setalreadyRemoved(list => [...list, toBeRemoved])
setPeople(people.filter((_, personIndex) => personIndex !== index))
console.log(people)
console.log(alreadyRemoved)
}
}
buttonClicked="not clicked"
}
}
let swipe = (dir) => {
buttonClicked="clicked"
console.log("clicked but not swiped")
if(people.length){
let cardsLeft = people.filter(person => !alreadyRemoved.includes(person))
if (cardsLeft.length) {
let toBeRemoved = cardsLeft[cardsLeft.length - 1] // Find the card object to be removed
let index = people.map(person => person.name).indexOf(toBeRemoved.name)// Find the index of which to make the reference to
setalreadyRemoved(list => [...list, toBeRemoved])
childRefs[index].current.swipe(dir)
let timer =setTimeout(function () {
setPeople(people.filter((_, personIndex) => personIndex !== index))}
, 1000)
console.log(people)
console.log(alreadyRemoved)
}
// Swipe the card!
}
}
let replay = () => {
let cardsremoved = alreadyRemoved
console.log(cardsremoved)
if (cardsremoved.length) {
let toBeReset = cardsremoved[cardsremoved.length - 1] // Find the card object to be reset
console.log(toBeReset)
setalreadyRemoved(alreadyRemoved.filter((_, personIndex) => personIndex !== (alreadyRemoved.length-1)))
if (!alreadyRemoved.length===0){ alreadyRemoved=[]}
let newPeople = people.concat(toBeReset)
setPeople(newPeople)
// Make sure the next card gets removed next time if this card do not have time to exit the screen
}
}
return (
<div>
<div className="cardContainer">
{people.map((person, index) => {
return (
<Cards ref={childRefs[index]} onSwipe={swiped} className="swipe" key={index} preventSwipe={['up', 'down']}>
<div style={{ backgroundImage: `url(${person.url})` }} className="Cards">
<h3>{person.name}</h3>
</div>
</Cards>);
})}
</div>
<div className="reactionButtons">
<IconButton onClick={() => swipe('left')}>
<img id="hateButton" alt="d" src={hate} style={{ width: "10vh", marginBottom: "5vh", pointerEvents: "all" }} />
</IconButton>
<IconButton onClick={() => replay()}>
<img id="replayButton" alt="e" src={replayb} style={{ width: "11vh", marginBottom: "5vh", pointerEvents: "all" }} />
</IconButton>
<IconButton onClick={() => swipe('right')}>
<img id="loveButton" alt="f" src={love} style={{ width: "11vh", marginBottom: "5vh", pointerEvents: "all" }} />
</IconButton>
</div>
</div>
);
}
export default IslamCards;
My console Log :
UPDATE :
As suggested in the 1st answer, I removed the Timer from the swiped() function but the problem persisted.
I hope to get more suggestions, so that I can solve this problem.
I can see the problem, but you might need to figure out what to do after that.
setPeople(people.filter((_, personIndex) => personIndex !== index))}
, 1000)
The problem is that index is figured out from the current update, however it takes 1 second to reach the next update, in between, your index points to the same one, because your index is derived from the people.
I am trying to figure out why when I click on a specific component its sibling it will render too
function CountButton({increment, count, number}) {
console.log(`Render CountButton ${number}`)
return <button onClick={() => increment(count + 1)}>{count}</button>
}
function DualCounter() {
const [count1, setCount1] = React.useState(0)
const increment1 = React.useCallback(() => setCount1(c => c + 1), [])
const [count2, setCount2] = React.useState(0)
const increment2 = React.useCallback(() => setCount2(c => c + 1), [])
console.log('Render DualCounter')
return (
<>
<CountButton count={count1} increment={increment1} number={1} />
<CountButton count={count2} increment={increment2} number={2} />
</>
)
}
I use useCallback and I pass theses function to use avoid that in any render the functions reference will be a different reference.
You are seeing a re-render on the sibling <CountButton /> component, because each time you hit the button to update the counter, you are actually updating a state value in the parent component <DualCounter />, which causes a re-render on that component as well.
And since DualCounter is re-rendered, child components will re-render as well, which in this case includes both <CountButton /> elements.
A solution to prevent this, would be wrapping CountButton component with React.memo(). This will prevent a re-render on a component that didn't have any change on the props values.
Example below:
function CountButton({increment, count, number}) {
console.log(`Render CountButton ${number}`)
return <button onClick={() => increment(count + 1)}>{count}</button>
}
const CountButtonMemo = React.memo(CountButton)
function DualCounter() {
const [count1, setCount1] = React.useState(0)
const increment1 = React.useCallback(() => setCount1(c => c + 1), [])
const [count2, setCount2] = React.useState(0)
const increment2 = React.useCallback(() => setCount2(c => c + 1), [])
console.log('Render DualCounter')
return (
<>
<CountButtonMemo count={count1} increment={increment1} number={1} />
<CountButtonMemo count={count2} increment={increment2} number={2} />
</>
)
Another solution would be not updating the DualCounter state on each change caused by events on your CountButton components, which will stop triggering unwanted re-renders on their siblings. You could handle the state directly on each CountButton component if this made sense for your app.
Alternatively, you could use a React state management tool, such as Redux, which also solves exactly this issue, by taking charge of delegating the state of your app separated from your components themselves.
I have cards and modals, I need to only show 2 cards in the page and have a button to show the rest, I’m new in programming and react, I don’t know what I have to do, that’s what I have now,
import React from "react"
import { Container } from "./_styles"
import { useTheme } from "#material-ui/core/styles"
import ImgMediaCard from "./../../../components/Cartao"
import AlertDialog from './../../../components/Modal'
export default function PortfolioSection(props) {
let arrayProjetos = props.projects;
const [selectedId, setSelectedId] = React.useState(0);
const [open, setOpen] = React.useState(false);
const handleClickOpen = (id) => {
setSelectedId(id);
setOpen(true);
};
const handleClose = () => {
setOpen(false);
};
let projetos = arrayProjetos.edges[selectedId].node.frontmatter;
return (
<Container>
{arrayProjetos.edges.map(
function criaCard(e, index){
let title = e.node.frontmatter.name;
let imageCard = e.node.frontmatter.images[0];
return (
<>
<ImgMediaCard
alt={title}
imagetitle={title}
texttitle={title}
src={imageCard}
click={() => handleClickOpen(index)}>
</ImgMediaCard>
</>
)
}
)}
<AlertDialog
imageModal={projetos.images[1]}
open={open}
handleClose={handleClose}
title={projetos.name}
text={projetos.description} />
</Container>
)
}
I'm using hooks to open the right modal when I click the "See more" button in the card, its working ok, I have 6 cards now, but I can add more in the future. I just need to limit how many cards I see when I enter the page and have a button to show everything.
API: you can add a pagination and total properties to your api call which returns 2 cards by default and you can handle the count of cards by increasing the pagination value. You may notice to add check to avoid situations like count > total.
UI: you can add const [cardCount, setCardCount] = useState(2)
and map through your cards array until the index not greater than cardCount value:
{arrayProjetos.edges.map((e, index) => { return index <= cardCount && <ImgMediaCard ... /> })}
<Box display={cardCount === 2 ? 'none' : 'block'}>
<Button
onClick={()=> arrayProjetos.edges.length - cardCount === 3 ? setCardCount(...cardCount - 1) : setCardCount(...cardCount - 2)}>See less</Button>
</Box>
<Box display={cardCount === arrayProjetos.edges.length ? 'none' : 'block'} >
<Button
onClick={() => arrayProjetos.edges.length - cardCount === 1 ? setCardCount(...cardCount + 1) : setCardCount(...cardCount + 2)}>See more </Button>
</Box>
How are you getting the cards?
You need to lazy load, if you are getting from a server, you can implement pagination, so the server sends back 2 cards every time (or based on a page data you send to server)
So every time you click the "load more" button, you fire a function who ask server for two more cards, and add the response to your cards js variable
Thanks Nazar, I did something similar I guess:
const [showMore, setShowMore] = React.useState(false);
const clickShow = () => {
setShowMore(oldValue => !oldValue);
showMore ? setButtonText("Ver Mais >") : setButtonText("Ver Menos <");
};
arrayProjetos.edges.slice(0, showMore ? arrayProjetos.edges.lenght : 2).map(
function criaCard(e, index){/*same thing here*/})
<button onClick={() => clickShow()}>{buttonText}</button>
I used slice to limit the array and a hook to change value
This is a bit of an open ended question, as I'm sure the way I'm going about this is incorrect. But I'm curious why React isn't re-rendering as I would expect. I suspect it has to do with the behavior of the useState hook paired with a functional component.
The code is in this CodeSandbox link, and code noted below:
function App() {
var foosList = ["foo1", "foo2", "foo3"];
const [selectedFoo, setSelectedFoo] = useState(-1);
const isSelected = i => i === selectedFoo;
return (
<div className="App">
<FoosWrapper>
<TitleSpan>Foos</TitleSpan>
<ListGroup>
{foosList.map((fooItem, i) => (
<ListGroupItem
key={fooItem}
active={isSelected(i)}
onClick={() => setSelectedFoo(i)}
>
{fooItem}
</ListGroupItem>
))}
</ListGroup>
</FoosWrapper>
<BarsWrapper>
<TitleSpan>Bars</TitleSpan>
<Bars foo={foosList[selectedFoo]} />
</BarsWrapper>
</div>
);
}
const Bars = props => {
const [pendingBar, setPendingBar] = useState("");
const [newBars, setNewBars] = useState([]);
const keyPress = e => {
if (e.key === "Enter") {
save(pendingBar);
}
};
const save = bar => {
newBars.push(bar);
setNewBars([...newBars]);
};
return (
<div>
<ListGroup>
<ListGroupItem key={props.foo}>{props.foo}</ListGroupItem>
</ListGroup>
<ListGroup>
{newBars.map(newBar => (
<ListGroupItem key={newBar}>{newBar}</ListGroupItem>
))}
</ListGroup>
<InputGroup>
<Input
placeholder="Add a bar"
onChange={e => setPendingBar(e.target.value)}
onKeyPress={keyPress}
/>
</InputGroup>
</div>
);
};
Broadly, there are two logical widgets: Foos and Bars. Foos on the left, Bars, on the right. I'd like to have a user select a 'foo', and a distinct list of bars associated with said 'foo' is displayed on the right. A user may add new bars to each respective 'foo'. Can think of foo having a parent relationship to bar.
The Bars component maintains a list of bars added by the user. My expectation is the Bars component would re-render the internal newBars collection when a new foo is selected. However, that state hangs around and is displayed regardless of what 'foo' is selected on the lefthand side.
That seems weird, but perhaps I'm not thinking of React functional components and hooks in the right way. Would love to understand why this behavior exists, and additional would love to hear proposed approaches that make more sense.
Any insight is greatly appreciated!
If you want the hierarchy to be reflected, then initialize the state of your bars so that it synchronizes with the state of your foos. Right now, your Bars is a single component maintaining its own state independently of App. Here's how I would approach this particular relationship.
function App() {
const foos = useMemo(() => ["foo1", "foo2", "foo3"], []);
const [bars, setBars] = useState(foos.map(() => []));
const [selectedIndex, setSelectedIndex] = useState(0);
const setBar = useCallback(
bar => {
setBars(bars => Object.assign(
[...bars],
{ [selectedIndex]: bar }
));
},
[setBars, selectedIndex]
);
const isSelected = useCallback(
index => index === selectedIndex,
[selectedIndex]
);
const foosList = useMemo(
() => foos.map((foo, index) => (
<ListGroupItem
key={foo}
active={isSelected(index)}
onClick={() => setSelectedIndex(index)}
>
{foo}
</ListGroupItem>
)),
[foos, isSelected, setSelectedIndex]
);
return (
<div className="App">
<FoosWrapper>
<TitleSpan>Foos</TitleSpan>
<ListGroup>{foosList}</ListGroup>
</FoosWrapper>
<BarsWrapper>
<TitleSpan>Bars</TitleSpan>
<Bars
foo={foos[selectedIndex]}
bars={bars[selectedIndex]}
setBars={setBar}
/>
</BarsWrapper>
</div>
);
}
function Bars({ foo, bars, setBars }) {
const [pendingBar, setPendingBar] = useState("");
const barsList = useMemo(
() => bars.map(bar => (
<ListGroupItem key={bar}>{bar}</ListGroupItem>
)),
[bars]
);
const save = useCallback(
bar => { setBars([...bars, bar]); },
[setBars, bars]
);
const change = useCallback(
event => { setPendingBar(event.target.value); },
[setPendingBar]
);
const keyPress = useCallback(
event => { if (event.key === "Enter") save(pendingBar); },
[pendingBar, save]
);
return (
<div>
<ListGroup>
<ListGroupItem key={foo}>{foo}</ListGroupItem>
</ListGroup>
<ListGroup>{barsList}</ListGroup>
<InputGroup>
<Input
placeholder="Add a bar"
onChange={change}
onKeyPress={keyPress}
/>
</InputGroup>
</div>
);
}
I may have gone a bit overboard with the memoization hooks, but that should give you at least an idea of what and how various values can be memoized.
Keep in mind that the second argument is the array of dependencies which determines whether the memoized value is recomputed or retrieved from cache. The dependencies of hooks are checked by reference, much like a PureComponent.
I also opted to initialize selectedIndex to 0 to avoid addressing the issue of how to handle the render function for Bars when no foo is selected. I'll leave that as an exercise to you.
If you want to show the bars for the selected foo, you need to structure your "bars" data accordingly. The simplest way to do this is to keep the "bars" data in the format of an object instead of an array. You can write the code as below.
const Bars = props => {
const [pendingBar, setPendingBar] = useState("");
const [newBars, setNewBars] = useState({});
const { foo } = props;
const keyPress = e => {
if (e.key === "Enter") {
save(pendingBar);
}
};
const save = bar => {
if (!foo) {
console.log("Foo is not selected");
return;
}
const bars = newBars[foo] || [];
bars.push(bar);
setNewBars({
...newBars,
[foo]: [...bars]
});
};
return (
<div>
<ListGroup>
<ListGroupItem key={props.foo}>{props.foo}</ListGroupItem>
</ListGroup>
<ListGroup>
{newBars[foo] &&
newBars[foo].map(newBar => (
<ListGroupItem key={newBar}>{newBar}</ListGroupItem>
))}
</ListGroup>
<InputGroup>
<Input
placeholder="Add a bar"
onChange={e => setPendingBar(e.target.value)}
onKeyPress={keyPress}
/>
</InputGroup>
</div>
);
};