I have a problem. It is:
let list = storage.map((element, index, array) => {
return (
<li key={index} className="list-element">
<div className="title-wrapper" onMouseEnter={this.handleMouseEnter}>
<p className="title">{array[index]['title']}</p>
<p className="title title-full" ref={node => this.title = node}>Text</p>
</div>
</li>
);
});
handleMouseEnter() {
this.title.style.opacity = "1";
}
So, when mouse enters .title-wrapper I want to set opacity to 1 on .title-full. But no matter on which .title-wrapper mouse enters, always opacity will be set to the last .title-full.
The problem is easy to solve with querySelector but I read that using it is bad thing in React, isn't it?
The reason this.title is always set to the last element is because you are setting each element in the loop to this.title, so the last one overwrites the one before it, and so on.
What about just using CSS directly, instead of handling it in React at all?
Example:
.title-wrapper:hover .title-full {
opacity: 1;
}
Just a general comment that refs aren't usually preferred in React (maybe for forms or modals sometimes). What you're emulating is a jQuery-like DOM manipulation approach, which can certainly work but is sidestepping the power of React being state-based, obvious, and easy to follow.
I would typically
this.setState({
hovered: true
})
in your handleMouseEnter method (and unset it in your mouseOut). Then choose your className based on this.state.hovered
I think going with CSS is definitely the best approach.
Just for anyone running into this issue of multiple refs in another context, you could solve the issue by storing the refs in an array
let list = storage.map((element, index, array) => {
return (
<li key={index} className="list-element">
<div className="title-wrapper" onMouseEnter={() => this.handleMouseEnter(index)}>
<p className="title">{array[index]['title']}</p>
<p className="title title-full" ref={node => this.titles[index] = node}>Text</p>
</div>
</li>
);
});
handleMouseEnter(index) {
this.titles[index].style.opacity = "1";
}
Again, you don't need to do this for your use case, just thought it might be helpful for others :D
Related
I once read that accessing the dom directly is considered bad practice when using react JS and wanted to clarify a use case in an accordion component I built. The component needs to animate when it expands/collapses so I decided to use CSS transitions for this.
To achieve this I essentially stick the element with the content of the accordion tab into an array which which is stored in a useRef object. Then, when the user clicks the trigger, I access the ref and alter the style property of the element, and other related element, by using methods such as firstElementChild and closest.
The resulting component is pretty clean and the method works well, I was just concerned whether or not there is something I am missing. I have seen multiple articles online which either use a library or nested hooks which ultimately use setTimeout to apply a style. This feels hacky to me.
Anyway, some code...
Event handlers:
const tabsRef = useRef<TabRef[]>([]);
const indRef = useRef<number[]>([]);
const alterTabs = (newIndexes: number[]) => {
tabsRef.current.forEach((tab) => {
tab.element.style.setProperty(
"height",
newIndexes.includes(tab.index)
? `${tab?.element?.firstElementChild?.clientHeight}px`
: "0px"
);
tab.element
?.closest("[aria-expanded]")
?.setAttribute("aria-expanded", `${newIndexes.includes(tab.index)}`);
});
};
const selectDay: React.MouseEventHandler = (
event: React.MouseEvent<HTMLButtonElement>
) => {
const trigger = event.currentTarget;
trigger?.setAttribute("disabled", "true");
const triggeredIndex = Number(trigger.dataset.index);
const newIndexes = indRef.current.includes(triggeredIndex)
? [...indRef.current.filter((index) => index !== triggeredIndex)]
: [...indRef.current, triggeredIndex];
alterTabs(newIndexes);
indRef.current = newIndexes;
trigger?.removeAttribute("disabled");
};
Relevant Render method:
{items.map((item, i) => (
<AccordionTriggerWrapper
key={Math.random().toString(36).substring(2, 9)}
data-index={i}
aria-expanded="false"
>
<AccordionTrigger>
<Button margin="sm" data-index={i} onClick={selectDay}>
<AccordionTitleIcon>
{item.icon && getIcon(item.icon)}
{item.title}
</AccordionTitleIcon>
{[getIcon("minus", 10), getIcon("plus", 10)]}
</Button>
</AccordionTrigger>
<Content>
<ContentInner
ref={(element) => {
tabsRef.current[i] = {
index: i,
element: element?.parentElement as HTMLElement,
};
}}
>
<ReactMarkdown
key={Math.random().toString(36).substring(2, 9)}
>
{item.content}
</ReactMarkdown>
</ContentInner>
</Content>
</AccordionTriggerWrapper>
))}
It's bad practice to dominate the DOM by yourself but small things like you do above, you can do it. Anyway, I can recommend you some react styling components like react-transition-group(especially), and you can take a look at React-Motion and React-Move. hit me up if it's helpful
I am rendering a list of students, some of which have failed their exams. For those who have failed their exams, I display a red square behind their avatars.
Whenever I hover over a student's avatar I want to display the subject that student has failed. My issue at the moment is that I display the subjects for all students, not only the one I've hovered over.
How can I display only the mainSubject for the student who's avatar I hovered on?
Here is a link to my code sandbox: Example Link
I solved it like following.
Get the id of the hovered student. Match this id from the list of students you render. if its match then show the subjects
Also, I renamed the hook
add key prop
you can check this too https://codesandbox.io/s/zealous-bhaskara-mi83k
const [hoveredStudentId, setHoveredStudentId] = useState();
return (
<>
{students.map((student, i) => {
return (
<div className="student-card" key={i}>
<p>
{student.firstName} {student.lastName}
</p>
{student.passed === false ? (
<>
<img
id={student.id}
src={student.picture}
className="student-avatar fail"
onMouseEnter={e => {
setHoveredStudentId(e.currentTarget.id);
}}
onMouseLeave={e => {
console.log(e.currentTarget.id);
setHoveredStudentId(0);
}}
alt="avatar"
/>
{hoveredStudentId === student.id && (
<div className="subject-label">{student.mainSubject}</div>
)}
</>
) : (
<img
src={student.picture}
className="student-avatar"
alt="avatar"
/>
)}
</div>
);
})}
</>
);
Issue is that you have a list of students but only 1 flag to show/hide subjects.
Solution: 1
Maintain a list of flag/student. So you will have n flags for n students. Simple way for this is to have a state in a way:
IStudentDetails { ... }
IStudentStateMap {
id: string; // uniquely identify a syudent
isSubjectVisible: boolean;
}
And based on this flag isSubjectVisible toggle visibility.
Updated code
Solution 2:
Instead of handling it using React, use CSS tricks. Note this is a patch and can be avoided.
Idea:
Wrap Student in a container element and add a class onHover on elements on elements that needs to be shown on hover.
Then use CSS to show/hide those elements.
.student-container .onHover {
display:none;
}
.student-container:hover .onHover{
display: block;
}
This way there wont be rerenders and no need for flags.
Updated Code
However, solution 1 is better as you have more control and when you are using a UI library, its better to let it do all mutation and you should follow its ways.
I display a list of foos and when i click on some link more results i keep the existing foos and i append to them the new ones from my api like bellow
const [foos, setFoos] = useState([]);
...
// api call with axios
...
success: (data) => {
setFoos([ ...foos, ...data ])
},
Each <Foo /> component run the animation above
App.js
...
<div className="foos-results">
{ foos.map((foo, index) => <Foo {...{ foo, index }} key={foo.id}/>) }
</div>
...
Foo.js
const Foo = ({ foo, index }) => <div className="circle">...</div>
animation.css
.circle {
...
animation: progress .5s ease-out forwards;
}
The problem is when i append the new ones then the animation is triggered for all the lines of <Foo />.
The behavior expected is that the animation is triggered just for the new ones and not starting over with the existing ones too.
UPDATE
We have found the origin of the problem (it's not related to the uniqueness of key={foo.id})
if we change
const Foo = ({ foo, index }) => <div className="circle">...</div>
to
const renderFoo = ({ foo, index }) => <div className="circle">...</div>
And App.js to
...
<div className="foos-results">
{ foos.map((foo, index) => renderFoo({ foo, index })) }
</div>
...
It works
So why is this behavior like this in react ?
here is a sandbox based on #Jackyef code
This is quite an interesting one.
Let's look at the sandbox provided in the question.
Inside App, we can see this.
const renderItems = () => (
<div>
{items.map((item, index) => (
<div className="item" key={item.id}>
<span>
{index + 1}. {item.value}
</span>
</div>
))}
</div>
);
const Items = () => renderItems();
return (
<div className="App">
<h1>List of items</h1>
<button onClick={addItem}>Add new item</button>
<Items />
</div>
);
Seems pretty harmless right? The problem with this is that Items is declared in the App render function. This means that on each render, Items actually is now a different function, even though what it does is the same.
<Items /> is transpiled into React.createElement, and when diffing, React takes into account each components' referential equality to decide whether or not it is the same component as previous render. If it's not the same, React will think it's a different component, and if it's different, it will just create and mount a new component. This is why you are seeing the animation being played again.
If you declare Items component outside of App like this:
const Items = ({ items }) => (
<div>
{items.map((item, index) => (
<div className="item" key={item.id}>
<span>
{index + 1}. {item.value}
</span>
</div>
))}
</div>
);
function App() { /* App render function */}
You will see everything works as expected. Sandbox here
So, to summarise:
Referential equality matters to React when diffing
Components (function or class that returns JSX) should be stable. If they change between renders, React will have a hard time due to point number 1.
I don't think there is a way to disable this re-rendering animation, but I think there is a workaround that could solve this issue.
As we know that each div's css is reloaded every time, so the solution I can think of, is to create another css class rule (let this class be named 'circle_without_anim') with same css as class 'circle' but without that animation and while appending new div, just before appending change class of all divs that have class name 'circle' to 'circle_without_anim' that would make the changes and css to previous divs but just without that animation and the append this new div with class 'circle' making it the only div that have animation.
Formally the algorithm will be like:
Write another css class(different name for example prev_circle) with same rules as 'circle' but without the animation rule.
In Javascript just before appending new div with class 'circle', change class of all previous divs that have class named 'circle' to newly created class 'prev_circle' that do not have animation rule.
Append the new div with class 'circle'.
Result: It would give an illusion that the CSS of previous divs is not being reloaded as the css is same but without animation, but the new div has different css rule (animation rule) which is going to be reloaded.
With this code:
const Items = () => renderItems();
...
<Items />
React has no chance of knowing that Items in the current render is the same component as Items in the previous render.
Consider this:
A = () => renderItems()
B = () => renderItems()
A and B are different components, so if you have <B /> in the current render and <A /> instead of <B /> in the previous render, React will discard the subtree rendered by <A /> and render it again.
You are invoking React.createElement (since <Items /> is just a JSX syntax sugar for React.createElement(Items, ...)) every render, so React scraps the old <Items /> in the DOM tree and creates it again each time.
Check out this question for more details.
There are two solutions:
create Items component outside of the render function (as Jackyef suggested)
use render function ({ renderItems() } instead of <Items />)
I'm trying to calculate and set an element's max-height style programmatically based on the number of children it has. I have to do this on four separate elements, each with a different number of children, so I can't just create a single computed property. I already have the logic to calculate the max-height in the function, but I'm unable to pass an element from the template into a function.
I've tried the following solutions with no luck:
<div ref="div1" :style="{ maxHeight: getMaxHeight($refs.div1) }"></div>
This didn't work because $refs is not yet defined at the time I'm passing it into the function.
Trying to pass this or $event.target to getMaxHeight(). This didn't work either because this doesn't refer to the current element, and there was no event since I'm not in a v-on event handler.
The only other solution I can think of is creating four computed properties that each call getMaxHeight() with the $ref, but if I can handle it from a single function called with different params, it would be easier to maintain. If possible, I would like to pass the element itself from the template. Does anyone know of a way to do this, or a more elegant approach to solving this problem?
A cheap trick I learned with Vue is that if you require anything in the template that isnt loaded when the template is mounted is to just put a template with a v-if on it:
<template v-if="$refs">
<div ref="div1" :style="{ maxHeight: getMaxHeight($refs.div1) }"></div>
</template>
around it. This might look dirty at first, but the thing is, it does the job without loads of extra code and time spend and prevents the errors.
Also, a small improvement in code length on your expandable-function:
const expandable = el => el.style.maxHeight =
( el.classList.contains('expanded') ?
el.children.map(c=>c.scrollHeight).reduce((h1,h2)=>h1+h2)
: 0 ) + 'px';
I ended up creating a directive like was suggested. It tries to expand/compress when:
It's clicked
Its classes change
The element or its children update
Vue component:
<button #click="toggleAccordion($event.currentTarget.nextElementSibling)"></button>
<div #click="toggleAccordion($event.currentTarget)" v-accordion-toggle>
<myComponent v-for="data in dataList" :data="data"></myComponent>
</div>
.....
private toggleAccordion(elem: HTMLElement): void {
elem.classList.toggle("expanded");
}
Directive: Accordion.ts
const expandable = (el: HTMLElement) => el.style.maxHeight = (el.classList.contains("expanded") ?
[...el.children].map(c => c.scrollHeight).reduce((h1, h2) => h1 + h2) : "0") + "px";
Vue.directive("accordion-toggle", {
bind: (el: HTMLElement, binding: any, vnode: any) => {
el.onclick = ($event: any) => {
expandable($event.currentTarget) ; // When the element is clicked
};
// If the classes on the elem change, like another button adding .expanded class
const observer = new MutationObserver(() => expandable(el));
observer.observe(el, {
attributes: true,
attributeFilter: ["class"],
});
},
componentUpdated: (el: HTMLElement) => {
expandable(el); // When the component (or its children) update
}
});
Making a custom directive that operates directly on the div element would probably be your best shot. You could create a directive component like:
export default {
name: 'maxheight',
bind(el) {
const numberOfChildren = el.children.length;
// rest of your max height logic here
el.style.maxHeight = '100px';
}
}
Then just make sure to import the directive in the file you plan on using it, and add it to your div element:
<div ref="div1" maxheight></div>
I have been trying to implement a hover effect on a div-element like in this codesandbox:
https://codesandbox.io/s/XopkqJ5oV
The component in which I want to do this, is a reusable component that is used multiple times on the same page. I suppose that is the reason why I can't get it to work. What am I missing?
Even using the above code won't work in my application.
EDIT: Thank you for your responses. I found the issue:
I was not letting ShouldComponentUpdate know, it should take state.isHovering into account.
shouldComponentUpdate(nextProps, nextState) {
return (
nextProps.post.id !== this.props.post.id ||
nextProps.screenshotClickUrl !== this.props.screenshotClickUrl ||
nextProps.onImageClick !== this.props.onImageClick ||
nextProps.handleMouseHover !== this.props.handleMouseHover ||
nextState.isHovering !== this.state.isHovering
)
}
You're missing a this in:
toggleHoverState(state) {
return {
isHovering: !state.isHovering // Need a "this" to access state.
};
}
If you stack the elements too closely it will interfere with the mouse enter/leave events, e.g., if you space them apart:
const Foo = () => {
return (
<div>
<HoverExample />
<div style={{height: '2em', border: '1px solid blue'}} />
<HoverExample />
</div>
)
}
it work like (I think) you'd expect.
https://codesandbox.io/s/93l25m453o
I put borders around it to help visualize the issue.
If that doesn't make sense, see what happens when you have the hover indicator in an adjacent span rather than stacked:
https://codesandbox.io/s/5k5jj3rpok