Code To Learn logo

Code To Learn

M6: State ManagementZustand Path

L11: Create Favorite Button Component

Build a reusable favorite button component

Let's create a dedicated FavoriteButton component! 🎯

Why a Separate Component?

Current situation: PropertyCard has inline favorite button code.

Better approach: Extract to reusable component!

Benefits:

  • Reusable in different contexts
  • Easier to test
  • Simpler to maintain
  • Can add animations/features in one place

Step 1: Create the Component

src/components/FavoriteButton.jsx
import useListingsStore from '@/state/useListingsStore';

function FavoriteButton({ listingId, size = 'medium', className = '' }) {
  // Select favorites and action
  const favorites = useListingsStore((state) => state.favorites);
  const toggleFavorite = useListingsStore((state) => state.toggleFavorite);
  
  // Check if favorited
  const isFavorited = favorites.includes(listingId);
  
  // Handle click
  const handleClick = (e) => {
    e.preventDefault();  // Prevent parent click events
    e.stopPropagation();  // Stop event bubbling
    toggleFavorite(listingId);
  };
  
  return (
    <button
      onClick={handleClick}
      className={`favorite-button favorite-button--${size} ${isFavorited ? 'favorited' : ''} ${className}`}
      aria-label={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
      title={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
    >
      {isFavorited ? '❤️' : '🤍'}
    </button>
  );
}

export default FavoriteButton;

Understanding the Component

Step 2: Add Styling

src/app/global.css
/* Favorite Button */
.favorite-button {
  background: white;
  border: none;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
  transition: all 0.2s;
  z-index: 10;
  position: relative;
}

.favorite-button:hover {
  transform: scale(1.1);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}

.favorite-button:active {
  transform: scale(0.95);
}

.favorite-button.favorited {
  background: #fee;
  animation: heartbeat 0.3s ease;
}

/* Button Sizes */
.favorite-button--small {
  width: 32px;
  height: 32px;
  font-size: 1.2rem;
}

.favorite-button--medium {
  width: 40px;
  height: 40px;
  font-size: 1.5rem;
}

.favorite-button--large {
  width: 48px;
  height: 48px;
  font-size: 1.8rem;
}

@keyframes heartbeat {
  0%, 100% { transform: scale(1); }
  25% { transform: scale(1.2); }
  50% { transform: scale(1.1); }
  75% { transform: scale(1.15); }
}

Step 3: Use in PropertyCard

Now update PropertyCard to use the new component:

src/components/PropertyCard.jsx
import { Link } from 'react-router-dom';
import FavoriteButton from '@/components/FavoriteButton';

function PropertyCard({ listing }) {
  return (
    <div className="listing-card">
      <div className="listing-image">
        <img 
          src={listing.media?.[0]?.url || '/placeholder.jpg'} 
          alt={listing.media?.[0]?.alt || listing.name}
        />
        
        {/* Use FavoriteButton component */}
        <FavoriteButton 
          listingId={listing.id}
          size="medium"
          className="listing-card__favorite"
        />
      </div>
      
      <div className="listing-content">
        <h3>{listing.name}</h3>
        <p className="listing-description">
          {listing.description}
        </p>
        
        <div className="listing-details">
          <span className="price">${listing.price}/night</span>
          <span className="guests">👥 {listing.maxGuests} guests</span>
        </div>
      </div>
    </div>
  );
}

export default PropertyCard;

Update Card Styling

src/app/global.css
/* Listing Card Updates */
.listing-image {
  position: relative;
  height: 200px;
  overflow: hidden;
}

.listing-image img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Position favorite button */
.listing-card__favorite {
  position: absolute;
  top: 0.75rem;
  right: 0.75rem;
}

Component Benefits

Use anywhere:

// In PropertyCard
<FavoriteButton listingId={listing.id} size="medium" />

// In ListingDetail page (large button)
<FavoriteButton listingId={listing.id} size="large" />

// In CompactListingRow (small button)
<FavoriteButton listingId={listing.id} size="small" />

// Custom styling
<FavoriteButton 
  listingId={listing.id}
  className="custom-favorite-btn"
/>

One component, many uses!

Update once, affects everywhere:

// Want to change animation? Update FavoriteButton.jsx
// Want to add sound effect? Update FavoriteButton.jsx
// Want to add confirmation? Update FavoriteButton.jsx

// All usages update automatically!

Single source of truth!

Easy to test in isolation:

// Test FavoriteButton
describe('FavoriteButton', () => {
  it('shows white heart when not favorited', () => {
    useListingsStore.setState({ favorites: [] });
    render(<FavoriteButton listingId="123" />);
    expect(screen.getByText('🤍')).toBeInTheDocument();
  });
  
  it('shows red heart when favorited', () => {
    useListingsStore.setState({ favorites: ['123'] });
    render(<FavoriteButton listingId="123" />);
    expect(screen.getByText('❤️')).toBeInTheDocument();
  });
  
  it('toggles favorite on click', () => {
    const toggleFavorite = jest.fn();
    useListingsStore.setState({
      favorites: [],
      toggleFavorite
    });
    
    render(<FavoriteButton listingId="123" />);
    fireEvent.click(screen.getByRole('button'));
    
    expect(toggleFavorite).toHaveBeenCalledWith('123');
  });
});

Testable component!

Advanced Features (Optional)

Add Loading State

src/components/FavoriteButton.jsx (Enhanced)
import { useState } from 'react';
import useListingsStore from '@/state/useListingsStore';

function FavoriteButton({ listingId, size = 'medium', className = '' }) {
  const [isLoading, setIsLoading] = useState(false);
  const favorites = useListingsStore((state) => state.favorites);
  const toggleFavorite = useListingsStore((state) => state.toggleFavorite);
  
  const isFavorited = favorites.includes(listingId);
  
  const handleClick = async (e) => {
    e.preventDefault();
    e.stopPropagation();
    
    setIsLoading(true);
    
    try {
      toggleFavorite(listingId);
      
      // Optional: Sync with server
      // await fetch(`/api/favorites/${listingId}`, { method: 'POST' });
      
      // Add small delay for animation
      await new Promise(resolve => setTimeout(resolve, 300));
    } finally {
      setIsLoading(false);
    }
  };
  
  return (
    <button
      onClick={handleClick}
      disabled={isLoading}
      className={`favorite-button favorite-button--${size} ${isFavorited ? 'favorited' : ''} ${className}`}
      aria-label={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
    >
      {isLoading ? '⏳' : isFavorited ? '❤️' : '🤍'}
    </button>
  );
}

export default FavoriteButton;

Add Sound Effect (Optional)

src/components/FavoriteButton.jsx (With Sound)
import useListingsStore from '@/state/useListingsStore';

function FavoriteButton({ listingId, size = 'medium', className = '' }) {
  const favorites = useListingsStore((state) => state.favorites);
  const toggleFavorite = useListingsStore((state) => state.toggleFavorite);
  
  const isFavorited = favorites.includes(listingId);
  
  const handleClick = (e) => {
    e.preventDefault();
    e.stopPropagation();
    
    // Play sound (optional)
    const audio = new Audio('/sounds/favorite.mp3');
    audio.play().catch(() => {
      // Ignore error if sound fails
    });
    
    toggleFavorite(listingId);
  };
  
  return (
    <button
      onClick={handleClick}
      className={`favorite-button favorite-button--${size} ${isFavorited ? 'favorited' : ''} ${className}`}
      aria-label={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
    >
      {isFavorited ? '❤️' : '🤍'}
    </button>
  );
}

export default FavoriteButton;

Comparison with Inline Code

Inline in PropertyCard:

function PropertyCard({ listing }) {
  const favorites = useListingsStore((state) => state.favorites);
  const toggleFavorite = useListingsStore((state) => state.toggleFavorite);
  const isFavorited = favorites.includes(listing.id);
  
  return (
    <div className="listing-card">
      <button
        onClick={() => toggleFavorite(listing.id)}
        className={`favorite-button ${isFavorited ? 'favorited' : ''}`}
      >
        {isFavorited ? '❤️' : '🤍'}
      </button>
      {/* ... rest of card ... */}
    </div>
  );
}

Issues:

  • Can't reuse in other components
  • PropertyCard has too many responsibilities
  • Hard to test button separately

Extracted component:

// FavoriteButton.jsx
function FavoriteButton({ listingId }) {
  const favorites = useListingsStore((state) => state.favorites);
  const toggleFavorite = useListingsStore((state) => state.toggleFavorite);
  const isFavorited = favorites.includes(listingId);
  
  return (
    <button onClick={() => toggleFavorite(listingId)}>
      {isFavorited ? '❤️' : '🤍'}
    </button>
  );
}

// PropertyCard.jsx
function PropertyCard({ listing }) {
  return (
    <div className="listing-card">
      <FavoriteButton listingId={listing.id} />
      {/* ... rest of card ... */}
    </div>
  );
}

Benefits:

  • Reusable everywhere
  • PropertyCard is simpler
  • Easy to test
  • Easy to enhance

Why extract components?

Single Responsibility:

  • PropertyCard → Display listing info
  • FavoriteButton → Handle favoriting

Reusability:

<FavoriteButton listingId="123" />  // In card
<FavoriteButton listingId="123" size="large" />  // In detail page
<FavoriteButton listingId="123" size="small" />  // In compact view

Maintainability:

  • Change button: Edit one file
  • All usages update automatically

Testability:

  • Test button independently
  • Mock store easily

Composition:

  • Mix and match components
  • Build complex UIs from simple parts

What's Next?

FavoriteButton component is complete! Now we have:

  • ✅ Reusable favorite button
  • ✅ Multiple sizes (small, medium, large)
  • ✅ Proper accessibility
  • ✅ Animations and effects
  • ✅ Clean separation of concerns

In the next lesson:

  1. Final integration - Ensure button works everywhere
  2. Complete testing - Verify all functionality
  3. Module review - Recap everything

✅ Lesson Complete! FavoriteButton is a reusable, accessible component!

Key Takeaways

  • Extracted component - Separated concerns from PropertyCard
  • Reusable - Works in any component, any context
  • Configurable - Props for size, className
  • Event handling - preventDefault and stopPropagation
  • Accessibility - aria-label and title attributes
  • Animations - Heartbeat effect on favorite
  • Single responsibility - Just handles favoriting
  • Easy to enhance - Can add loading, sound, confirmation