-
-
Save gragland/cfc4089e2f5d98dde5033adc44da53f8 to your computer and use it in GitHub Desktop.
import { useRef, useState, useEffect } from 'react'; | |
// Usage | |
function App() { | |
const [hoverRef, isHovered] = useHover(); | |
return ( | |
<div ref={hoverRef}> | |
{isHovered ? '😁' : '☹️'} | |
</div> | |
); | |
} | |
// Hook | |
function useHover() { | |
const [value, setValue] = useState(false); | |
const ref = useRef(null); | |
const handleMouseOver = () => setValue(true); | |
const handleMouseOut = () => setValue(false); | |
useEffect( | |
() => { | |
const node = ref.current; | |
if (node) { | |
node.addEventListener('mouseover', handleMouseOver); | |
node.addEventListener('mouseout', handleMouseOut); | |
return () => { | |
node.removeEventListener('mouseover', handleMouseOver); | |
node.removeEventListener('mouseout', handleMouseOut); | |
}; | |
} | |
}, | |
[ref.current] // Recall only if ref changes | |
); | |
return [ref, value]; | |
} |
@Guria Good call on moving useRef
inside the hook and just returning it. Updated the code example.
Now this is a useful hook. Some feedback:
- I don't think
ref
can ever be null, so we can probably just checkref.current
inuseEffect
- This won't update correctly if
ref
changes to a different DOM element. I think passing[ref.current]
as the last argument touseEffect
will fix this. - We can simplify
useEffect
to only check forref.current
once, since there's no unsubscribe work to do if the ref is empty. - Bind
ref.current
to local variablenode
via closure, so we are totally sure to callremoveEventListener
on the right thing (i.e. ifref.current
is changed out of order, though this shouldn't happen in normal usage). - Minor change:
ref
->hoverRef
inApp
just to make it crystal clear what that ref is for. - Minor change: Importing
React
since that's usually necessary to use JSX.
Suggested code changes (NB: GitHub wouldn't let me leave a comment with emoji in it. Weird.):
import React, { useRef, useState, useEffect } from 'react';
// Usage
function App() {
const [hoverRef, isHovered] = useHover();
return (
<div ref={hoverRef}>
{isHovered ? 'Hovered! :D' : 'Hover me please :('}
</div>
);
}
// Hook
function useHover() {
const [value, setValue] = useState(false);
const ref = useRef(null);
const handleMouseOver = () => setValue(true);
const handleMouseOut = () => setValue(false);
useEffect(() => {
const node = ref.current;
if (node) {
node.addEventListener('mouseover', handleMouseOver);
node.addEventListener('mouseout', handleMouseOut);
return () => {
node.removeEventListener('mouseover', handleMouseOver);
node.removeEventListener('mouseout', handleMouseOut);
};
}
// If we didn't set up any listeners, we won't need to unsubscribe from anything.
return () => {};
}, [ref.current]); // Ensure we remove and re-add the listeners if and only if the ref changes.
return [ref, value];
}
I tend to agree with the @andybarron 👍
- No need to return empty function when unsubscription is useless
- We can create handle functions directly in the useEffect function I think, to avoid create new handle functions at every render for nothing 😉
export default function useHover() {
const [value, setValue] = useState(false);
const ref = useRef(null);
useEffect(
() => {
const handleMouseOver = () => setValue(true);
const handleMouseOut = () => setValue(false);
const element = ref && ref.current;
if (element) {
element.addEventListener("mouseover", handleMouseOver);
element.addEventListener("mouseout", handleMouseOut);
return () => {
element.removeEventListener("mouseover", handleMouseOver);
element.removeEventListener("mouseout", handleMouseOut);
};
}
},
[ref]
);
return [ref, value];
}
Ah, good catch! Creating the functions inside the useEffect
callback is way better.
I still think we can just do
const element = ref.current;
because useRef
should never return null
.
@gragland did a bit more thoughts on explicit passing ref to hook. It looks nice to make hook to manage ref inside hook itself in this simple scenario. But it doesn't scale if we need another hook with ref for the same element. So it is better to revert it back I suppose.
So, for instance, if we add useFocus into the same example we getting in a trouble:
function App() {
const [hoverRef, isHovered] = useHover();
const [focusRef, isFocused] = useFocus();
return (
<div ref={hoverRef /* we can't pass multiple refs here :( */}>
{isHovered ? 'hovered' : 'not hovered'}
{isFocused ? 'focused' : 'not focused'}
</div>
);
}
@gragland I guess we should make multiple hooks reusing same ref example in a next issue of usehooks.com to show off why creating ref inside a hook doesn't scale. And probably we will get more ideas from community.
That's a great point. I guess we should invert control and require hoverRef
as a parameter to useHover
.
Why even rely on refs?
function useFocus() {
const [focused, set] = useState(false);
const binder = {
onFocus: () => set(true),
onBlur: () => set(false)
};
return [focused, binder];
}
function useHover() {
const [hovered, set] = useState(false);
const binder = {
onMouseEnter: () => set(true),
onMouseLeave: () => set(false)
};
return [hovered, binder];
}
function HoverableAndFocusable() {
const [hovered, bindHover] = useHover();
const [focused, bindFocus] = useFocus();
return (
<div>
<input {...bindHover} {...bindFocus} />
<h2>{hovered ? "Hovered" : "Not hovered"}</h2>
<h2>{focused ? "Focused" : "Not focused"}</h2>
</div>
);
}
@andybarron Thanks, added your suggestions! I think we can avoid returning at all in useEffect
if no ref.current
right?
@Guria Good point about it not scaling well if we need another hook with same ref. I like the idea of just passing in a hoverRef or even not using refs at all like @raunofreiberg suggests.
I'm trying to find the right balance between keeping these code recipes simple and accounting for the various edge cases that come up. Rather than refining the original recipe past a certain point, I'm wondering if it might be more informative to include multiple code variations on the site (could be a row of links above the code block that user can toggle between). This way they can learn things like "oh, for super simple situations I could just have a hook return a ref, but if that's too limiting I can also accept a ref as an argument". Basically a more user friendly way to learn things discussed in this gist. Anyway, thinking on this and open to feedback!
I ended up with the same useHover
implementation as @raunofreiberg. Not only is it simpler, but it makes use of React's SyntheticEvents instead of listening to standard DOM events. The current implementation in this gist causes the hover state to change when you mouse in/out of a child element, which is probably not what you'd want (here's an example of what I mean, check out the console/log: https://codesandbox.io/s/x95rozo9wz). An implementation that uses React's onMouseEnter
and onMouseLeave
doesn't have that issue.
There is a bug with this due to the fact that it does not use a callback ref (https://reactjs.org/docs/refs-and-the-dom.html#callback-refs), so the hook does not get notified if a child component changes the element that the ref
gets passed to.
Here is a demo that reproduces the issue: https://codesandbox.io/s/usehover-1l8w3
Hovering works at first, but after unmounting and remounting the hoverable element it no longer works.
Here is an alternative implementation that uses a callback ref to implement useHover
, which does not have the above issue: https://codesandbox.io/s/usehover-1c6sc
Note that the above implementation also uses another custom hook, useRefEffect
, which handles the potential gotchas surrounding using callback refs with hooks. It also makes callback refs easier to use by mimicking the useEffect
API.
@gragland If you want, I can make a PR to update the useHover
definition and add a page for useRefEffect
?
Here is another version that fixes the issue by using a callback ref, but does not use useRefEffect
: https://codesandbox.io/s/usehover-ue8v3
@butchler Thanks for pointing out this issue and sharing some fixes! I still see some value in sharing the existing version, since it's easier to understand and I'm guessing most people won't be changing the element the ref gets passed to, but I'll update the post description to link out to this alternate version: https://gist.github.com/gragland/a32d08580b7e0604ff02cb069826ca2f (same as yours, with some extra commenting).
@gragland Thank you :)
Binding event listeners to the node can lead to the hover state getting out of sync if you move your mouse quickly. Instead bind the listeners to the document and check to see if the event target
is our node or our node contains the event target
:
import { useRef, useState, useEffect } from "react";
export default function useHover() {
const [value, setValue] = useState(false);
const ref = useRef(null);
const handleMouseOver = e => {
const node = ref.current;
if (!node) return setValue(false);
setValue(e.target === node || node.contains(e.target));
};
useEffect(
() => {
const node = ref.current;
if (node) {
const doc = node.ownerDocument;
doc.addEventListener("mouseover", handleMouseOver);
return () => {
doc.removeEventListener("mouseover", handleMouseOver);
};
}
},
[ref.current] // Recall only if ref changes
);
return [ref, value];
}
@jcready What do you mean by getting out of sync? Your example seems reasonable, just want to understand what the issue with the current implementation is.
Hey @gragland
Current implementation re-creates handleMouseOver
and handleMouseOut
callbacks on every state update. There is a fix for that: https://gist.github.com/mbelsky/909c7a6b9bde3289e91a6448ae1a74b3/revisions#diff-0dd251e6c939d6c6f3846a366eade1f2
Hello everyone!
I've made a Typescript version:
import { useEffect, useState, useRef } from 'react';
type THook<T extends HTMLElement> = [
React.RefObject<T>,
boolean,
];
export const useMouseHover = <T extends HTMLElement>(): THook<T> => {
const [hovered, setHovered] = useState(false);
const ref = useRef<T>(null);
useEffect(() => {
const handleMouseOver = (): void => setHovered(true);
const handleMouseOut = (): void => setHovered(false);
const node = ref && ref.current;
if (node) {
node.addEventListener('mouseover', handleMouseOver);
node.addEventListener('mouseout', handleMouseOut);
return () => {
node.removeEventListener('mouseover', handleMouseOver);
node.removeEventListener('mouseout', handleMouseOut);
};
}
}, [ref]);
return [ref, hovered];
};
Example of usage:
const [buttonRef, buttonHovered] = useMouseHover<HTMLButtonElement>();
const color = buttonHovered ? 'red' : 'blue';
return (
<button
ref={buttonRef}
style={{ color }}
>
click me
</button>
);
React Hook React.useEffect has an unnecessary dependency: 'ref.current'. Either exclude it or remove the dependency array. Mutable values like 'ref.current' aren't valid dependencies because mutating them doesn't re-render the component.
eslint (react-hooks/exhaustive-deps)
[ref.current] // Recall only if ref changes
could be changed to
[ref] // Recall only if ref changes
?
I had an issue with the last event being fired being a mouseover
event. I switched the mouseover
to mouseenter
and mouseout
to mouseleave
which solved it. Any drawbacks to this method? It also limits the amount of events firing as it doesn't fire on child elements.
I don't really think you need the useRef hook.
you could just use the useState hook and the onMouseEnter and onMouseLeave as props on the component.
what do you think?
import React,{useState,useRef,useEffect} from "react"
export default function Image({className,image}){
const [ishover, setIsHover] = useState(false)
console.log(ishover)
return(
<div
className={`${className} image-container`}
onMouseEnter={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
>
<img src={image.url} className="image-grid"/>
</div>
)
}
https://gist.github.com/Guria/9e020d5025dd4e5f1d3f6bcff87a947c
https://codesandbox.io/s/ow6r54z1z