Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 067 / 100

Reactive classes: a practical Svelte & SvelteKit guide

Encapsulate state and logic.

Expona Admin9 min read

Reactive classes: a practical Svelte & SvelteKit guide

We've all hit that moment where component state grows tangled and methods start duplicating logic across files. Svelte's reactivity system is powerful, but when you're building complex features—shopping carts, form handlers, data filters—keeping everything organized in a single component becomes unwieldy. Reactive classes bring structure: they let you encapsulate both state and logic into reusable, testable units that stay reactive without losing Svelte's simplicity.

On Day 67 of our journey, we're going to show you how to use reactive classes effectively in Svelte and SvelteKit applications. We'll walk through the patterns we've refined in production across e-commerce and SaaS platforms, where managing complex domain logic inside components often led to maintenance headaches until we started treating classes as first-class reactive citizens.

Why reactive classes matter

Before diving into the mechanics, let's clarify why we reach for classes. A component managing a shopping cart, for example, needs to:

  • Track items and quantities
  • Compute totals, discounts, and taxes
  • Validate state transitions (removing items, clearing cart)
  • Persist to localStorage or send updates to an API

You could write all of this inline in a component, but you'd end up with scattered reactive statements, duplicated calculations, and tests that require DOM rendering. A reactive class keeps all that logic in one place, making it easier to reason about, test in isolation, and reuse across routes or components.

Use $state fields in classes

The foundation of a reactive class is $state. In Svelte 5+, $state creates reactive variables that trigger updates whenever they change. Inside a class, $state fields become the source of truth for your domain logic.

Let's build a cart manager for an e-commerce platform:

typescript
import { derived } from 'svelte/store';

export class CartManager {
  items = $state([]);
  taxRate = $state(0.1);
  appliedCoupon = $state(null);

  addItem(product, quantity = 1) {
    const existing = this.items.find(item => item.id === product.id);
    if (existing) {
      existing.quantity += quantity;
    } else {
      this.items.push({ ...product, quantity });
    }
  }

  removeItem(productId) {
    this.items = this.items.filter(item => item.id !== productId);
  }

  updateQuantity(productId, quantity) {
    const item = this.items.find(item => item.id === productId);
    if (item) {
      item.quantity = Math.max(0, quantity);
    }
  }

  clear() {
    this.items = [];
    this.appliedCoupon = null;
  }
}

Notice that items, taxRate, and appliedCoupon are all declared as $state. When you modify these fields, Svelte automatically tracks the change and re-renders any component that reads them. The key insight: mutations to $state fields are reactive without needing assignment statements.

In a component, you'd instantiate this class and bind to it:

typescript
let cart = new CartManager();

function handleAddToCart(product) {
  cart.addItem(product, parseInt(quantity));
}

Every call to addItem() updates the reactive items array, and Svelte re-renders accordingly.

Getters with $derived for computed state

Often you need values that depend on other reactive state but shouldn't be stored separately. That's where $derived comes in. It creates a reactive computed value that updates whenever its dependencies change.

Let's extend our cart manager with pricing calculations:

typescript
export class CartManager {
  items = $state([]);
  taxRate = $state(0.1);
  appliedCoupon = $state(null);

  get subtotal() {
    return this.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
  }

  get discount() {
    if (!this.appliedCoupon) return 0;
    return this.subtotal * (this.appliedCoupon.percentOff / 100);
  }

  get taxAmount() {
    return derived(this.subtotal - this.discount) * this.taxRate;
  }

  get total() {
    return this.subtotal - this.discount + this.taxAmount;
  }

  applyCoupon(coupon) {
    this.appliedCoupon = coupon;
  }

  removeCoupon() {
    this.appliedCoupon = null;
  }
}

In Svelte 5, you can use getters directly and Svelte's reactivity system understands the dependency chain. When items changes, subtotal re-evaluates; when the coupon changes, discount updates. No manual dependency tracking needed.

In your component, computed values feel like regular properties:

typescript
<div>
  Subtotal: ${cart.subtotal.toFixed(2)}
  Discount: -${cart.discount.toFixed(2)}
  Tax: ${cart.taxAmount.toFixed(2)}
  <strong>Total: ${cart.total.toFixed(2)}</strong>
</div>

This approach keeps calculations out of your template and makes them testable. If you need to change how tax is computed, you modify one place in the class.

Methods update state

Methods in reactive classes are where business logic lives. They're the only way external code should modify state, which helps you reason about what can change and when.

We've already seen addItem() and removeItem(). Let's look at a more complex scenario: a form manager for SaaS sign-ups where you need to validate fields, track errors, and manage async submission:

typescript
export class FormManager {
  fields = $state({
    email: '',
    password: '',
    confirmPassword: '',
    agreeToTerms: false
  });

  errors = $state({});
  isSubmitting = $state(false);
  submitSuccess = $state(false);

  setField(name, value) {
    this.fields[name] = value;
    this.validateField(name);
  }

  validateField(name) {
    const field = this.fields[name];
    if (!field && name !== 'agreeToTerms') {
      this.errors[name] = 'This field is required';
    } else if (name === 'email' && !field.includes('@')) {
      this.errors[name] = 'Invalid email';
    } else if (name === 'password' && field.length < 8) {
      this.errors[name] = 'Password must be at least 8 characters';
    } else if (name === 'confirmPassword' && field !== this.fields.password) {
      this.errors[name] = 'Passwords do not match';
    } else {
      delete this.errors[name];
    }
  }

  async submit() {
    this.isSubmitting = true;
    this.submitSuccess = false;

    try {
      const response = await fetch('/api/signup', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(this.fields)
      });

      if (response.ok) {
        this.submitSuccess = true;
      } else {
        this.errors.submit = 'Signup failed. Please try again.';
      }
    } catch (err) {
      this.errors.submit = err.message;
    } finally {
      this.isSubmitting = false;
    }
  }

  reset() {
    this.fields = {
      email: '',
      password: '',
      confirmPassword: '',
      agreeToTerms: false
    };
    this.errors = {};
    this.submitSuccess = false;
  }
}

Notice that every state change flows through a method. The component never directly modifies this.fields or this.errors; instead, it calls setField(), which validates and updates in one go. This gives you a single place to add logging, analytics, or persistence logic.

In the component:

typescript
let form = new FormManager();

function handleSubmit() {
  form.submit();
}

All the complexity—validation, async handling, error state—stays encapsulated in the class.

Great for complex features

Reactive classes truly shine when you're building sophisticated features that span multiple components or require heavy state management. A few real-world patterns we've used in production:

Data filters for listing pages

E-commerce platforms need category filters, price ranges, search, and sorting. A reactive class keeps filters in sync and computes the filtered dataset:

typescript
export class ProductFilter {
  allProducts = $state([]);
  category = $state(null);
  minPrice = $state(0);
  maxPrice = $state(1000);
  searchTerm = $state('');
  sortBy = $state('name');

  get filtered() {
    let result = this.allProducts;
    if (this.category) {
      result = result.filter(p => p.category === this.category);
    }
    result = result.filter(p => p.price >= this.minPrice && p.price <= this.maxPrice);
    if (this.searchTerm) {
      result = result.filter(p => p.name.toLowerCase().includes(this.searchTerm.toLowerCase()));
    }
    if (this.sortBy === 'price-asc') {
      result.sort((a, b) => a.price - b.price);
    } else if (this.sortBy === 'price-desc') {
      result.sort((a, b) => b.price - a.price);
    } else {
      result.sort((a, b) => a.name.localeCompare(b.name));
    }
    return result;
  }

  setCategory(cat) { this.category = cat; }
  setPriceRange(min, max) { this.minPrice = min; this.maxPrice = max; }
  setSearchTerm(term) { this.searchTerm = term; }
  setSortBy(sort) { this.sortBy = sort; }
}

Multi-step wizard flows

Travel booking or complex onboarding flows benefit from a class that manages the current step and accumulated data:

typescript
export class WizardManager {
  currentStep = $state(1);
  totalSteps = 4;
  stepData = $state({
    personal: {},
    trip: {},
    payment: {},
    confirmation: {}
  });

  nextStep() {
    if (this.currentStep < this.totalSteps) {
      this.currentStep++;
    }
  }

  previousStep() {
    if (this.currentStep > 1) {
      this.currentStep--;
    }
  }

  updateStepData(stepName, data) {
    this.stepData[stepName] = { ...this.stepData[stepName], ...data };
  }

  get isComplete() {
    return this.currentStep === this.totalSteps;
  }
}

Common mistakes

Mistake 1: Forgetting $state declaration. If you declare a field without $state, mutations won't trigger reactivity. Always use $state for fields you expect to change.

Mistake 2: Mixing class instances with stores. You can use a class instance as a regular variable in your component. If you try to make it a Svelte store, you'll lose the reactivity benefits. Keep it simple: instantiate the class, use its properties and methods directly.

Mistake 3: Expensive computed properties without memoization. A getter runs every time it's accessed. If get filtered() loops through thousands of items every render, your app will slow down. For heavy computations, consider caching or using a derivation that only updates when dependencies actually change.

Mistake 4: State leaking across instances. If you share a single class instance across multiple components and don't expect that, changes in one component will affect others. Be explicit about whether your class is a singleton or per-component.

Key takeaways

  • $state fields in classes are the foundation of reactive domain logic; mutations trigger component updates automatically.
  • Getters and computed properties keep calculations close to state, making them testable and maintainable.
  • Methods are the single point of entry for state changes, giving you control over validation, side effects, and consistency.
  • Reactive classes excel at organizing complex features like carts, forms, filters, and multi-step flows that would otherwise scatter logic across components.
  • Avoid mixing class reactivity with stores, always declare state fields with $state, and be mindful of expensive computations in getters.
  • Instantiate classes per-component or per-route as needed; they're not global singletons unless you explicitly make them so.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles