Dynamic Filtering

Users don’t want to scroll through every service. They want to filter by category — show me just lawn care, just consultations. This combines everything you’ve learned: state for the selected filter, conditions for empty states, and loops for rendering results.


What You’ll Learn

  • Track selected category with state
  • Filter arrays based on selection
  • Handle filtered empty states
  • Build a complete filter UI

The Building Blocks

We need:

  1. Services data — The full list of services
  2. Selected category state — Which filter is active
  3. Filtered services — Computed from the full list
  4. Category buttons — To change the filter
  5. Service display — The filtered results

Set Up the Data

Start with services that have category information:

<script>
  const services = [
    { id: '1', slug: 'lawn-mowing', name: 'Lawn Mowing', category: 'lawn-care', price: 50 },
    { id: '2', slug: 'hedge-trimming', name: 'Hedge Trimming', category: 'lawn-care', price: 75 },
    { id: '3', slug: 'garden-consultation', name: 'Garden Consultation', category: 'consultation', price: 100 },
    { id: '4', slug: 'tree-pruning', name: 'Tree Pruning', category: 'tree-care', price: 150 },
    { id: '5', slug: 'patio-cleaning', name: 'Patio Cleaning', category: 'cleaning', price: 80 }
  ];
  
  const categories = [
    { id: 'all', name: 'All Services' },
    { id: 'lawn-care', name: 'Lawn Care' },
    { id: 'tree-care', name: 'Tree Care' },
    { id: 'consultation', name: 'Consultation' },
    { id: 'cleaning', name: 'Cleaning' }
  ];
  
  let selectedCategory = $state('all');
</script>

Compute Filtered Services

Use $derived to automatically filter based on selection:

<script>
  // ... services and categories data ...
  
  let selectedCategory = $state('all');
  
  let filteredServices = $derived(
    selectedCategory === 'all'
      ? services
      : services.filter(s => s.category === selectedCategory)
  );
</script>

When selectedCategory changes, filteredServices automatically recalculates. We’ll cover $derived in depth in Module 10 — for now, know that it creates computed values that update reactively.


Build the Filter UI

Create buttons for each category:

<div class="category-filters">
  {#each categories as category (category.id)}
    <button
      class:active={selectedCategory === category.id}
      onclick={() => selectedCategory = category.id}
    >
      {category.name}
    </button>
  {/each}
</div>

The class:active directive adds the active class when the condition is true — highlighting the selected filter.


Display Filtered Results

Combine the filter with the service list:

<div class="services-list">
  {#each filteredServices as service (service.id)}
    <article class="service-card">
      <h3>{service.name}</h3>
      <p class="category">{service.category}</p>
      <p class="price">${service.price}</p>
      <a href="/services/{service.slug}">View Details</a>
    </article>
  {:else}
    <div class="empty-state">
      <p>No services found in this category.</p>
      <button onclick={() => selectedCategory = 'all'}>
        View All Services
      </button>
    </div>
  {/each}
</div>

The {:else} handles empty filtered results.


The Complete Component

Here’s everything together:

<!-- filename: src/routes/services/+page.svelte -->
<script>
  const services = [
    { id: '1', slug: 'lawn-mowing', name: 'Lawn Mowing', category: 'lawn-care', price: 50 },
    { id: '2', slug: 'hedge-trimming', name: 'Hedge Trimming', category: 'lawn-care', price: 75 },
    { id: '3', slug: 'garden-consultation', name: 'Garden Consultation', category: 'consultation', price: 100 },
    { id: '4', slug: 'tree-pruning', name: 'Tree Pruning', category: 'tree-care', price: 150 },
    { id: '5', slug: 'patio-cleaning', name: 'Patio Cleaning', category: 'cleaning', price: 80 }
  ];
  
  const categories = [
    { id: 'all', name: 'All Services' },
    { id: 'lawn-care', name: 'Lawn Care' },
    { id: 'tree-care', name: 'Tree Care' },
    { id: 'consultation', name: 'Consultation' },
    { id: 'cleaning', name: 'Cleaning' }
  ];
  
  let selectedCategory = $state('all');
  
  let filteredServices = $derived(
    selectedCategory === 'all'
      ? services
      : services.filter(s => s.category === selectedCategory)
  );
</script>

<h1>Our Services</h1>

<div class="category-filters">
  {#each categories as category (category.id)}
    <button
      class:active={selectedCategory === category.id}
      onclick={() => selectedCategory = category.id}
    >
      {category.name}
    </button>
  {/each}
</div>

<p class="results-count">
  Showing {filteredServices.length} of {services.length} services
</p>

<div class="services-list">
  {#each filteredServices as service (service.id)}
    <article class="service-card">
      <h3>
        <a href="/services/{service.slug}">{service.name}</a>
      </h3>
      <span class="category-badge">{service.category}</span>
      <p class="price">${service.price}</p>
    </article>
  {:else}
    <div class="empty-state">
      <p>No services found in this category.</p>
      <button onclick={() => selectedCategory = 'all'}>
        View All Services
      </button>
    </div>
  {/each}
</div>

<style>
  .category-filters {
    display: flex;
    gap: 0.5rem;
    margin-bottom: 1.5rem;
    flex-wrap: wrap;
  }
  
  .category-filters button {
    padding: 0.5rem 1rem;
    border: 1px solid #ddd;
    background: white;
    border-radius: 20px;
    cursor: pointer;
  }
  
  .category-filters button:hover {
    border-color: #007bff;
  }
  
  .category-filters button.active {
    background: #007bff;
    color: white;
    border-color: #007bff;
  }
  
  .results-count {
    color: #666;
    margin-bottom: 1rem;
  }
  
  .services-list {
    display: grid;
    gap: 1rem;
  }
  
  .service-card {
    padding: 1rem;
    border: 1px solid #eee;
    border-radius: 8px;
  }
  
  .service-card h3 {
    margin: 0 0 0.5rem 0;
  }
  
  .service-card a {
    color: #333;
    text-decoration: none;
  }
  
  .service-card a:hover {
    color: #007bff;
  }
  
  .category-badge {
    display: inline-block;
    padding: 0.25rem 0.5rem;
    background: #f0f0f0;
    border-radius: 4px;
    font-size: 0.875rem;
    color: #666;
  }
  
  .price {
    font-weight: bold;
    color: #007bff;
  }
  
  .empty-state {
    text-align: center;
    padding: 2rem;
    background: #f8f9fa;
    border-radius: 8px;
  }
</style>

Test the Interaction

  1. Load the services page — all 5 services show
  2. Click “Lawn Care” — 2 services show
  3. Click “Tree Care” — 1 service shows
  4. Click “Consultation” — 1 service shows
  5. Click “All Services” — back to 5 services

The count updates, the list filters, and the active button highlights. All reactive, all automatic.


Common Mistakes

Mutating the Original Array

<!-- ❌ Don't modify the source array -->
let filteredServices = $derived(
  services.filter(s => s.category === selectedCategory)
);
// If you did: services = services.filter(...) — you'd lose data!

<!-- ✅ Filter returns a new array, original is preserved -->
let filteredServices = $derived(
  services.filter(s => s.category === selectedCategory)
);

Forgetting the Empty State

Always handle the case where filtering produces zero results. Users need feedback.

Not Using Keys

<!-- ❌ Without keys, filtering can cause glitches -->
{#each filteredServices as service}

<!-- ✅ With keys, each item is tracked correctly -->
{#each filteredServices as service (service.id)}

Summary

Filtering combines state, derived values, conditions, and loops. The selected category is state, filtered services are derived, the empty case is a condition, and rendering is a loop. These primitives compose into powerful interactive features.

Key takeaways:

  • Use state for the selected filter
  • Use $derived to compute filtered results
  • Always handle empty filtered states
  • Use keyed each blocks for dynamic lists

Module Complete! 🎉

You’ve finished Module 5: Control Flow. BookIt now has:

  • Understanding of Svelte’s control flow blocks
  • Conditional rendering with {#if}, {:else}, and {:else if}
  • Empty state handling for better UX
  • List rendering with {#each}
  • Keyed lists for correct updates
  • Interactive category filtering

What you’ve learned:

  • Svelte’s template syntax for control flow
  • {#if} / {:else} / {:else if} for conditional content
  • Designing thoughtful empty states
  • {#each} for rendering arrays
  • Keys for proper list tracking
  • Combining control flow patterns

Next Steps

Forms need more than text inputs. Continue with Module 6: Data Binding to explore checkboxes, radio buttons, selects, and more.