machine coding11
react ui components
stateeventsrender-props

Star rating

Build a star rating widget with hover preview and a locked-in selection.

easy1 min readsolution

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 selected for hover loses the committed rating the moment the pointer leaves.
  • onMouseLeave per 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