Code To Learn logo

Code To Learn

M6: State ManagementZustand Path

L5: Refactor HomePage with Zustand

Replace useFetch with Zustand store for complete state management

Let's integrate Zustand into our HomePage! 🏠

Current Situation

Right now, HomePage uses useFetch hook:

src/pages/HomePage.jsx (Current)
import useFetch from '@/hooks/useFetch';
import PropertyCard from '@/components/PropertyCard';

function HomePage() {
  const { data, isLoading, isError } = useFetch(
    'https://v2.api.noroff.dev/holidaze/venues?_owner=true&_bookings=true'
  );
  
  if (isLoading) return <div>Loading...</div>;
  if (isError) return <div>Error loading listings</div>;
  
  return (
    <div className="home-page">
      <h1>Holiday Listings</h1>
      <div className="listings-grid">
        {data?.data?.map((listing) => (
          <PropertyCard key={listing.id} listing={listing} />
        ))}
      </div>
    </div>
  );
}

export default HomePage;

The Problem

Step 1: Update the Store

First, let's add filtering to our store:

src/state/useListingsStore.js
import { create } from 'zustand';

const useListingsStore = create((set, get) => ({
  // State
  items: [],
  favorites: [],
  status: 'idle',
  error: null,
  
  // Filter state
  searchQuery: '',
  maxPrice: null,
  maxGuests: null,
  
  // Actions
  setItems: (items) => set({ items }),
  setStatus: (status) => set({ status }),
  setError: (error) => set({ error }),
  
  toggleFavorite: (id) => set((state) => ({
    favorites: state.favorites.includes(id)
      ? state.favorites.filter(favId => favId !== id)
      : [...state.favorites, id]
  })),
  
  // Filter actions
  setSearchQuery: (query) => set({ searchQuery: query }),
  setMaxPrice: (price) => set({ maxPrice: price }),
  setMaxGuests: (guests) => set({ maxGuests: guests }),
  clearFilters: () => set({ searchQuery: '', maxPrice: null, maxGuests: null }),
  
  // Async action
  fetchListings: async () => {
    set({ status: 'loading', error: null });
    
    try {
      const response = await fetch(
        'https://v2.api.noroff.dev/holidaze/venues?_owner=true&_bookings=true'
      );
      
      if (!response.ok) {
        throw new Error('Failed to fetch listings');
      }
      
      const result = await response.json();
      
      set({
        items: result.data,
        status: 'succeeded'
      });
    } catch (error) {
      set({
        error: error.message,
        status: 'failed'
      });
    }
  },
  
  // Computed selector
  getFilteredItems: () => {
    const { items, searchQuery, maxPrice, maxGuests } = get();
    
    return items.filter((item) => {
      // Filter by search query
      if (searchQuery) {
        const query = searchQuery.toLowerCase();
        const matchesTitle = item.name?.toLowerCase().includes(query);
        const matchesDescription = item.description?.toLowerCase().includes(query);
        if (!matchesTitle && !matchesDescription) return false;
      }
      
      // Filter by max price
      if (maxPrice && item.price > maxPrice) {
        return false;
      }
      
      // Filter by max guests
      if (maxGuests && item.maxGuests < maxGuests) {
        return false;
      }
      
      return true;
    });
  },
}));

export default useListingsStore;

Step 2: Refactor HomePage

Now let's update HomePage to use Zustand:

src/pages/HomePage.jsx
import { useEffect, useState } from 'react';
import useListingsStore from '@/state/useListingsStore';
import PropertyCard from '@/components/PropertyCard';

function HomePage() {
  // Select state
  const status = useListingsStore((state) => state.status);
  const error = useListingsStore((state) => state.error);
  const searchQuery = useListingsStore((state) => state.searchQuery);
  const maxPrice = useListingsStore((state) => state.maxPrice);
  const maxGuests = useListingsStore((state) => state.maxGuests);
  
  // Select actions
  const fetchListings = useListingsStore((state) => state.fetchListings);
  const getFilteredItems = useListingsStore((state) => state.getFilteredItems);
  const setSearchQuery = useListingsStore((state) => state.setSearchQuery);
  const setMaxPrice = useListingsStore((state) => state.setMaxPrice);
  const setMaxGuests = useListingsStore((state) => state.setMaxGuests);
  const clearFilters = useListingsStore((state) => state.clearFilters);
  
  // Local state for form inputs
  const [localSearch, setLocalSearch] = useState('');
  const [localPrice, setLocalPrice] = useState('');
  const [localGuests, setLocalGuests] = useState('');
  
  // Get filtered items
  const filteredItems = getFilteredItems();
  
  // Fetch on mount
  useEffect(() => {
    if (status === 'idle') {
      fetchListings();
    }
  }, [status, fetchListings]);
  
  // Handle filter submission
  const handleFilter = (e) => {
    e.preventDefault();
    setSearchQuery(localSearch);
    setMaxPrice(localPrice ? Number(localPrice) : null);
    setMaxGuests(localGuests ? Number(localGuests) : null);
  };
  
  // Handle clear filters
  const handleClearFilters = () => {
    clearFilters();
    setLocalSearch('');
    setLocalPrice('');
    setLocalGuests('');
  };
  
  // Loading state
  if (status === 'loading') {
    return (
      <div className="home-page">
        <div className="loading">
          <div className="spinner"></div>
          <p>Loading amazing places...</p>
        </div>
      </div>
    );
  }
  
  // Error state
  if (status === 'failed') {
    return (
      <div className="home-page">
        <div className="error">
          <h2>Oops! Something went wrong</h2>
          <p>{error}</p>
          <button onClick={fetchListings} className="retry-button">
            Try Again
          </button>
        </div>
      </div>
    );
  }
  
  // Success state
  return (
    <div className="home-page">
      <header className="page-header">
        <h1>Discover Amazing Places</h1>
        <p>Find your perfect holiday venue</p>
      </header>
      
      {/* Filter Form */}
      <form onSubmit={handleFilter} className="filter-form">
        <div className="filter-group">
          <label htmlFor="search">Search</label>
          <input
            id="search"
            type="text"
            placeholder="Search by name or description..."
            value={localSearch}
            onChange={(e) => setLocalSearch(e.target.value)}
          />
        </div>
        
        <div className="filter-group">
          <label htmlFor="maxPrice">Max Price (per night)</label>
          <input
            id="maxPrice"
            type="number"
            placeholder="e.g., 200"
            value={localPrice}
            onChange={(e) => setLocalPrice(e.target.value)}
          />
        </div>
        
        <div className="filter-group">
          <label htmlFor="maxGuests">Min Guests</label>
          <input
            id="maxGuests"
            type="number"
            placeholder="e.g., 4"
            value={localGuests}
            onChange={(e) => setLocalGuests(e.target.value)}
          />
        </div>
        
        <div className="filter-actions">
          <button type="submit" className="button-primary">
            Apply Filters
          </button>
          <button
            type="button"
            onClick={handleClearFilters}
            className="button-secondary"
          >
            Clear
          </button>
        </div>
      </form>
      
      {/* Active filters display */}
      {(searchQuery || maxPrice || maxGuests) && (
        <div className="active-filters">
          <h3>Active Filters:</h3>
          <div className="filter-tags">
            {searchQuery && (
              <span className="filter-tag">
                Search: "{searchQuery}"
              </span>
            )}
            {maxPrice && (
              <span className="filter-tag">
                Max Price: ${maxPrice}
              </span>
            )}
            {maxGuests && (
              <span className="filter-tag">
                Min Guests: {maxGuests}
              </span>
            )}
          </div>
        </div>
      )}
      
      {/* Results */}
      <div className="listings-section">
        <h2>
          {filteredItems.length} {filteredItems.length === 1 ? 'Place' : 'Places'} Found
        </h2>
        
        {filteredItems.length === 0 ? (
          <div className="no-results">
            <p>No listings match your filters.</p>
            <button onClick={handleClearFilters} className="button-secondary">
              Clear Filters
            </button>
          </div>
        ) : (
          <div className="listings-grid">
            {filteredItems.map((listing) => (
              <PropertyCard key={listing.id} listing={listing} />
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

export default HomePage;

What Changed?

function HomePage() {
  const { data, isLoading, isError } = useFetch('...');
  
  if (isLoading) return <div>Loading...</div>;
  if (isError) return <div>Error</div>;
  
  return (
    <div>
      {data?.data?.map((listing) => (
        <PropertyCard key={listing.id} listing={listing} />
      ))}
    </div>
  );
}

Issues:

  • ❌ No global state
  • ❌ No filtering
  • ❌ No favorites integration
  • ❌ Basic error handling
  • ❌ Will re-fetch every time
function HomePage() {
  const status = useListingsStore((state) => state.status);
  const fetchListings = useListingsStore((state) => state.fetchListings);
  const getFilteredItems = useListingsStore((state) => state.getFilteredItems);
  const filteredItems = getFilteredItems();
  
  useEffect(() => {
    if (status === 'idle') fetchListings();
  }, [status, fetchListings]);
  
  if (status === 'loading') return <Loading />;
  if (status === 'failed') return <Error />;
  
  return (
    <div>
      <FilterForm />
      {filteredItems.map((listing) => (
        <PropertyCard key={listing.id} listing={listing} />
      ))}
    </div>
  );
}

Benefits:

  • ✅ Global state (shared across pages!)
  • ✅ Filtering (search, price, guests)
  • ✅ Favorites integration ready
  • ✅ Better error handling
  • ✅ Fetches once, reuses data
FeatureuseFetchZustand
State scopeComponent-localGlobal
Data sharingCan't shareShared everywhere
FilteringNeed local stateBuilt into store
FavoritesNeed separate stateSame store
Re-fetchEvery mountOnce, then cached
Loading statesBasicComprehensive
Error handlingBasic booleanError messages
ComplexitySimple startMore powerful

Bottom line: Zustand is better for real applications with multiple features!

Understanding getFilteredItems

Local State vs Store State

Notice we use BOTH:

// Local state for form inputs
const [localSearch, setLocalSearch] = useState('');

// Store state for active filters
const searchQuery = useListingsStore((state) => state.searchQuery);

Why not put everything in store?

// ❌ Bad: Every keystroke updates store
<input
  value={searchQuery}
  onChange={(e) => setSearchQuery(e.target.value)}
/>
// Problem: Re-filters on EVERY keystroke!
// Type "beach" → 5 filter operations! ❌
// ✅ Good: Local state, update store on submit
<input
  value={localSearch}
  onChange={(e) => setLocalSearch(e.target.value)}
/>
// Problem solved: Filter only on submit! ✅

Rule of thumb:

  • Form inputs: Local state (fast, controlled)
  • Active filters: Store state (shared, persistent)

The pattern:

function MyForm() {
  // Store state (active filters)
  const searchQuery = useStore((state) => state.searchQuery);
  const setSearchQuery = useStore((state) => state.setSearchQuery);
  
  // Local state (form input)
  const [localSearch, setLocalSearch] = useState('');
  
  // On submit: Copy local → store
  const handleSubmit = (e) => {
    e.preventDefault();
    setSearchQuery(localSearch);  // Update store
  };
  
  // On clear: Reset both
  const handleClear = () => {
    setLocalSearch('');  // Clear local
    setSearchQuery('');  // Clear store
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        value={localSearch}  // Use local
        onChange={(e) => setLocalSearch(e.target.value)}
      />
      <button type="submit">Apply</button>
      <button type="button" onClick={handleClear}>Clear</button>
    </form>
  );
}

Flow:

  1. User types → local state updates
  2. User clicks "Apply" → store state updates
  3. Store state updates → filter recalculates
  4. Filter results → UI updates

Perfect balance! ⚖️

Step 3: Add Styling (Optional)

Add some CSS to make it look nice:

src/app/global.css (add these)
.home-page {
  max-width: 1200px;
  margin: 0 auto;
  padding: 2rem;
}

.page-header {
  text-align: center;
  margin-bottom: 3rem;
}

.page-header h1 {
  font-size: 2.5rem;
  margin-bottom: 0.5rem;
}

.page-header p {
  font-size: 1.2rem;
  color: #666;
}

/* Filter Form */
.filter-form {
  background: white;
  padding: 2rem;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  margin-bottom: 2rem;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 1rem;
}

.filter-group label {
  display: block;
  font-weight: 600;
  margin-bottom: 0.5rem;
}

.filter-group input {
  width: 100%;
  padding: 0.5rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.filter-actions {
  display: flex;
  gap: 1rem;
  align-items: flex-end;
}

/* Active Filters */
.active-filters {
  background: #f0f9ff;
  padding: 1rem;
  border-radius: 8px;
  margin-bottom: 2rem;
}

.active-filters h3 {
  font-size: 0.9rem;
  margin-bottom: 0.5rem;
  color: #666;
}

.filter-tags {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
}

.filter-tag {
  background: #0369a1;
  color: white;
  padding: 0.25rem 0.75rem;
  border-radius: 16px;
  font-size: 0.9rem;
}

/* Listings Grid */
.listings-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 2rem;
}

/* Loading & Error States */
.loading,
.error,
.no-results {
  text-align: center;
  padding: 4rem 2rem;
}

.spinner {
  width: 50px;
  height: 50px;
  border: 4px solid #f3f3f3;
  border-top: 4px solid #0369a1;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin: 0 auto 1rem;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

.button-primary,
.button-secondary,
.retry-button {
  padding: 0.75rem 1.5rem;
  border: none;
  border-radius: 4px;
  font-size: 1rem;
  cursor: pointer;
  transition: all 0.2s;
}

.button-primary {
  background: #0369a1;
  color: white;
}

.button-primary:hover {
  background: #075985;
}

.button-secondary {
  background: #e5e7eb;
  color: #374151;
}

.button-secondary:hover {
  background: #d1d5db;
}

What's Next?

Perfect! HomePage is now powered by Zustand. In the next lesson:

  1. Test favorites - Try toggleFavorite action
  2. Understand favorites flow - How state updates work
  3. Prepare for FavoritesPage - Set up the foundation
  4. Zustand DevTools - Debug state changes

✅ Lesson Complete! HomePage now uses Zustand with filtering!

Key Takeaways

  • Replaced useFetch - Zustand provides better state management
  • Added filtering - Search, price, guests filters
  • Computed selectors - getFilteredItems() calculates on demand
  • Local + store state - Form inputs local, filters in store
  • Better UX - Loading states, error handling, empty states
  • Shared state - Data available to other pages (FavoritesPage!)
  • Fetch once - Data cached, no redundant requests
  • Scalable - Easy to add more filters or features