L13: Favorite Button Component
Create a ListingFavoriteButton component to toggle favorites
Let's build a heart button that lets users favorite/unfavorite listings!
What We're Building
A ListingFavoriteButton component that:
- Shows filled heart (❤️) if favorited
- Shows outline heart (🤍) if not favorited
- Toggles state on click
- Dispatches Redux action
- Works anywhere in the app
Step 1: Create the Component File
Create a new file:
touch src/components/ListingFavoriteButton.jsxStep 2: Build the Component
Add the basic structure:
import { useSelector, useDispatch } from 'react-redux';
import { toggleFavorite } from '@/state/slices/listingsSlice';
import { Heart } from 'lucide-react';
function ListingFavoriteButton({ listingId }) {
const dispatch = useDispatch();
const isFavorited = useSelector((state) =>
state.listings.favorites.includes(listingId)
);
const handleClick = () => {
dispatch(toggleFavorite(listingId));
};
return (
<button
onClick={handleClick}
className={`p-2 rounded-full transition-colors ${
isFavorited
? 'text-red-500 hover:text-red-600'
: 'text-gray-400 hover:text-gray-500'
}`}
aria-label={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
size={24}
fill={isFavorited ? 'currentColor' : 'none'}
/>
</button>
);
}
export default ListingFavoriteButton;Let's break this down:
Imports
import { useSelector, useDispatch } from 'react-redux';
import { toggleFavorite } from '@/state/slices/listingsSlice';
import { Heart } from 'lucide-react';useSelector- Read from ReduxuseDispatch- Dispatch actionstoggleFavorite- Action to toggle favoriteHeart- Heart icon from lucide-react
Component Props
function ListingFavoriteButton({ listingId }) {Takes listingId as prop to know which listing to favorite.
Usage:
<ListingFavoriteButton listingId={listing.id} />
<ListingFavoriteButton listingId={5} />Get Dispatch Function
const dispatch = useDispatch();Get the dispatch function to send actions to Redux.
Check if Favorited
const isFavorited = useSelector((state) =>
state.listings.favorites.includes(listingId)
);What's happening:
- Get
favoritesarray from Redux:[1, 5, 9] - Check if our
listingIdis in the array - Returns
trueif favorited,falseif not
Examples:
// favorites = [1, 5, 9]
// listingId = 5
[1, 5, 9].includes(5) // true → isFavorited = true
// listingId = 3
[1, 5, 9].includes(3) // false → isFavorited = falseHandle Click
const handleClick = () => {
dispatch(toggleFavorite(listingId));
};When clicked:
- Call
toggleFavorite(listingId)- creates action - Dispatch action to Redux
- Reducer adds/removes ID from favorites
- Component re-renders with new state
Render Button
<button
onClick={handleClick}
className={`p-2 rounded-full transition-colors ${
isFavorited
? 'text-red-500 hover:text-red-600'
: 'text-gray-400 hover:text-gray-500'
}`}
aria-label={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
size={24}
fill={isFavorited ? 'currentColor' : 'none'}
/>
</button>Conditional styling:
- If favorited: Red color, filled heart
- If not favorited: Gray color, outline heart
Accessibility:
aria-labeldescribes action for screen readers
Understanding the Fill Logic
The heart icon changes based on state:
When isFavorited = false:
<Heart
size={24}
fill="none" // Outline heart
/>Result: 🤍 Outline heart
className="text-gray-400 hover:text-gray-500"Color: Gray (not active)
When isFavorited = true:
<Heart
size={24}
fill="currentColor" // Filled heart
/>Result: ❤️ Filled heart
className="text-red-500 hover:text-red-600"Color: Red (active)
The fill prop controls the heart's interior:
fill="none":
┌─────┐
│ ♡ │ Outline only
│ ♡ │ Transparent inside
└─────┘fill="currentColor":
┌─────┐
│ ♥ │ Filled with current text color
│ ♥ │ Solid inside
└─────┘currentColor means "use the current color CSS property"
So when text-red-500 is applied, fill="currentColor" fills with red!
Conditional Styling Breakdown
Let's understand the ternary operators:
className={`p-2 rounded-full transition-colors ${
isFavorited
? 'text-red-500 hover:text-red-600'
: 'text-gray-400 hover:text-gray-500'
}`}Adding Click Feedback
Let's add visual feedback when clicking:
import { useSelector, useDispatch } from 'react-redux';
import { toggleFavorite } from '@/state/slices/listingsSlice';
import { Heart } from 'lucide-react';
function ListingFavoriteButton({ listingId }) {
const dispatch = useDispatch();
const isFavorited = useSelector((state) =>
state.listings.favorites.includes(listingId)
);
const handleClick = (e) => {
e.stopPropagation(); // Prevent parent click handlers
dispatch(toggleFavorite(listingId));
};
return (
<button
onClick={handleClick}
className={`p-2 rounded-full transition-all hover:scale-110 active:scale-95 ${
isFavorited
? 'text-red-500 hover:text-red-600 hover:bg-red-50'
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-100'
}`}
aria-label={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
size={24}
fill={isFavorited ? 'currentColor' : 'none'}
/>
</button>
);
}
export default ListingFavoriteButton;What changed?
Stop Propagation
const handleClick = (e) => {
e.stopPropagation();
dispatch(toggleFavorite(listingId));
};e.stopPropagation() prevents the click from bubbling up.
Why? If the button is inside a clickable card:
<div onClick={goToDetails}> {/* Card click → navigate */}
<h3>Beach House</h3>
<ListingFavoriteButton /> {/* Button click → favorite */}
</div>Without stopPropagation(), clicking the button would:
- Toggle favorite ✅
- Navigate to details ❌ (unwanted!)
With stopPropagation(), clicking the button only toggles favorite!
Scale on Hover/Click
className="transition-all hover:scale-110 active:scale-95"transition-all- Smooth all transitionshover:scale-110- Grow to 110% on hoveractive:scale-95- Shrink to 95% when clicking
Visual feedback:
- Hover: Button slightly grows
- Click: Button slightly shrinks (bounce effect)
Background on Hover
isFavorited
? 'hover:bg-red-50' // Light red background
: 'hover:bg-gray-100' // Light gray backgroundAdds subtle background color on hover for better feedback!
Complete Component Variations
Basic version (what we started with):
import { useSelector, useDispatch } from 'react-redux';
import { toggleFavorite } from '@/state/slices/listingsSlice';
import { Heart } from 'lucide-react';
function ListingFavoriteButton({ listingId }) {
const dispatch = useDispatch();
const isFavorited = useSelector((state) =>
state.listings.favorites.includes(listingId)
);
const handleClick = () => {
dispatch(toggleFavorite(listingId));
};
return (
<button
onClick={handleClick}
className={`p-2 rounded-full transition-colors ${
isFavorited
? 'text-red-500 hover:text-red-600'
: 'text-gray-400 hover:text-gray-500'
}`}
aria-label={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
size={24}
fill={isFavorited ? 'currentColor' : 'none'}
/>
</button>
);
}
export default ListingFavoriteButton;Pros: Simple, clean, minimal
Enhanced version with all feedback:
import { useSelector, useDispatch } from 'react-redux';
import { toggleFavorite } from '@/state/slices/listingsSlice';
import { Heart } from 'lucide-react';
function ListingFavoriteButton({ listingId }) {
const dispatch = useDispatch();
const isFavorited = useSelector((state) =>
state.listings.favorites.includes(listingId)
);
const handleClick = (e) => {
e.stopPropagation();
dispatch(toggleFavorite(listingId));
};
return (
<button
onClick={handleClick}
className={`p-2 rounded-full transition-all hover:scale-110 active:scale-95 ${
isFavorited
? 'text-red-500 hover:text-red-600 hover:bg-red-50'
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-100'
}`}
aria-label={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
size={24}
fill={isFavorited ? 'currentColor' : 'none'}
/>
</button>
);
}
export default ListingFavoriteButton;Pros: Better UX, visual feedback, prevents issues
Recommended for production!
Smaller button for tight spaces:
import { useSelector, useDispatch } from 'react-redux';
import { toggleFavorite } from '@/state/slices/listingsSlice';
import { Heart } from 'lucide-react';
function ListingFavoriteButton({ listingId, size = 'normal' }) {
const dispatch = useDispatch();
const isFavorited = useSelector((state) =>
state.listings.favorites.includes(listingId)
);
const handleClick = (e) => {
e.stopPropagation();
dispatch(toggleFavorite(listingId));
};
const sizeClasses = {
small: 'p-1',
normal: 'p-2',
large: 'p-3'
};
const iconSizes = {
small: 16,
normal: 24,
large: 32
};
return (
<button
onClick={handleClick}
className={`${sizeClasses[size]} rounded-full transition-all hover:scale-110 active:scale-95 ${
isFavorited
? 'text-red-500 hover:text-red-600 hover:bg-red-50'
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-100'
}`}
aria-label={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
size={iconSizes[size]}
fill={isFavorited ? 'currentColor' : 'none'}
/>
</button>
);
}
export default ListingFavoriteButton;Usage:
<ListingFavoriteButton listingId={1} size="small" />
<ListingFavoriteButton listingId={2} /> {/* Normal */}
<ListingFavoriteButton listingId={3} size="large" />Button with text label:
import { useSelector, useDispatch } from 'react-redux';
import { toggleFavorite } from '@/state/slices/listingsSlice';
import { Heart } from 'lucide-react';
function ListingFavoriteButton({ listingId, showText = false }) {
const dispatch = useDispatch();
const isFavorited = useSelector((state) =>
state.listings.favorites.includes(listingId)
);
const handleClick = (e) => {
e.stopPropagation();
dispatch(toggleFavorite(listingId));
};
return (
<button
onClick={handleClick}
className={`flex items-center space-x-2 px-3 py-2 rounded-full transition-all hover:scale-105 active:scale-95 ${
isFavorited
? 'text-red-500 hover:text-red-600 hover:bg-red-50'
: 'text-gray-600 hover:text-gray-700 hover:bg-gray-100'
}`}
aria-label={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
size={20}
fill={isFavorited ? 'currentColor' : 'none'}
/>
{showText && (
<span className="text-sm font-medium">
{isFavorited ? 'Favorited' : 'Favorite'}
</span>
)}
</button>
);
}
export default ListingFavoriteButton;Usage:
<ListingFavoriteButton listingId={1} showText />
// Renders: ❤️ FavoritedUsage Example
Here's how you'll use the button:
import ListingFavoriteButton from '@/components/ListingFavoriteButton';
function SomeComponent() {
return (
<div>
<h3>Beach House</h3>
<ListingFavoriteButton listingId={1} />
</div>
);
}The button handles everything:
- ✅ Checks if listing is favorited
- ✅ Shows correct icon state
- ✅ Dispatches Redux action on click
- ✅ Updates automatically when state changes
What's Next?
Perfect! The button is ready. In the next lesson, we'll:
- Add button to PropertyCard - Show on listing cards
- Position properly - Top-right corner
- Add to DetailsPage - Also show on details page
✅ Lesson Complete! You've created a reusable favorite button component!
Key Takeaways
- ✅ useSelector checks if listing is in favorites array
- ✅ useDispatch sends toggleFavorite action
- ✅ Conditional styling based on
isFavoritedstate - ✅ fill prop controls heart icon fill (none vs currentColor)
- ✅ e.stopPropagation() prevents parent click handlers
- ✅ Reusable component works anywhere with just
listingIdprop - ✅ Accessibility with aria-label for screen readers