react ui components
stateeventsrender-props
Star rating
Build a star rating widget with hover preview and a locked-in selection.
What it tests
Holding two pieces of state that describe the same visual and deciding which one wins. Hovering previews a rating; clicking commits it; leaving the row has to fall back to the committed value rather than clearing it.
The approach
selectedIdx and hoverIdx are tracked separately, and one predicate decides
whether a given star is filled — hover takes precedence when it's active:
const isFilled = (idx) => {
if (hoverIdx !== -1) return idx <= hoverIdx;
return idx <= selectedIdx;
};onMouseLeave sits on the container, not each star, so moving between stars
doesn't flicker through the cleared state.
Where it usually goes wrong
- One state variable. Reusing
selectedfor hover loses the committed rating the moment the pointer leaves. onMouseLeaveper star. Fires on every star-to-star move.- Not keyboard accessible. The version in the repo is pointer-only; a production widget wants a radio group so it's reachable by tab and arrow keys.
Full solution
2 files from src/folders/star.
index.jsx
import { useState } from "react";
import "./styles.css";
export default function Star() {
const [selectedIdx, setSelectedIdx] = useState(-1);
const [hoverIdx, setHoverIdx] = useState(-1);
function handleClick(idx) {
setSelectedIdx(idx);
}
function handleHover(idx) {
setHoverIdx(idx);
}
function handleMouseLeave() {
setHoverIdx(-1);
}
const isFilled = (idx) => {
if (hoverIdx !== -1) return idx <= hoverIdx;
return idx <= selectedIdx;
};
return (
<StarRating
len={10}
handleMouseLeave={handleMouseLeave}
renderItem={(_, idx) => (
<Box
key={idx}
filled={isFilled(idx)}
handleClick={handleClick}
handleHover={handleHover}
idx={idx}
/>
)}
/>
);
}
function StarRating({ len = 5, renderItem, handleMouseLeave }) {
return (
<ul className="star-container" onMouseLeave={handleMouseLeave}>
{Array.from({ length: len }).map((_, idx) => renderItem(_, idx))}
</ul>
);
}
function Box({ filled, handleClick, handleHover, idx, size = 40 }) {
return (
<span
className="star"
onClick={() => handleClick(idx)}
onMouseEnter={() => handleHover(idx)}
style={{ fontSize: `${size}px`, lineHeight: 1, cursor: "pointer" }}
>
{filled ? "⭐" : "☆"}
</span>
);
}styles.css
.star-container {
display: flex;
gap: 2px;
}
.star {
line-height: 1; /* keeps height tight */
display: inline-block;
cursor: pointer;
height: 100px;
width: 50px;
}share this post