Code To Learn logo

Code To Learn

M6: State ManagementRedux Toolkit Path

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:

  1. Shows filled heart (❤️) if favorited
  2. Shows outline heart (🤍) if not favorited
  3. Toggles state on click
  4. Dispatches Redux action
  5. Works anywhere in the app

Step 1: Create the Component File

Create a new file:

touch src/components/ListingFavoriteButton.jsx

Step 2: Build the Component

Add the basic structure:

src/components/ListingFavoriteButton.jsx
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 Redux
  • useDispatch - Dispatch actions
  • toggleFavorite - Action to toggle favorite
  • Heart - 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 favorites array from Redux: [1, 5, 9]
  • Check if our listingId is in the array
  • Returns true if favorited, false if 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 = false

Handle Click

const handleClick = () => {
  dispatch(toggleFavorite(listingId));
};

When clicked:

  1. Call toggleFavorite(listingId) - creates action
  2. Dispatch action to Redux
  3. Reducer adds/removes ID from favorites
  4. 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-label describes 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:

src/components/ListingFavoriteButton.jsx
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:

  1. Toggle favorite ✅
  2. 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 transitions
  • hover:scale-110 - Grow to 110% on hover
  • active: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 background

Adds subtle background color on hover for better feedback!

Complete Component Variations

Basic version (what we started with):

src/components/ListingFavoriteButton.jsx
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:

src/components/ListingFavoriteButton.jsx
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:

src/components/ListingFavoriteButton.jsx
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:

src/components/ListingFavoriteButton.jsx
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: ❤️ Favorited

Usage Example

Here's how you'll use the button:

Example Usage
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:

  1. Add button to PropertyCard - Show on listing cards
  2. Position properly - Top-right corner
  3. 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 isFavorited state
  • fill prop controls heart icon fill (none vs currentColor)
  • e.stopPropagation() prevents parent click handlers
  • Reusable component works anywhere with just listingId prop
  • Accessibility with aria-label for screen readers