Devlog 19: Svelte Stores - Reactive State Management That Just Works

6 min read
svelte state-management architecture

Devlog 19: Svelte Stores - Reactive State Management That Just Works

Coming from React, I was used to Redux boilerplate and useEffect hooks everywhere. Svelte stores are refreshingly simple while being powerful enough for complex state.

Here's how I use them to manage Tuggy's state.

The Basics

A writable store is just a value you can subscribe to:

import { writable } from 'svelte/store';

export const voteCount = writable(0);

// Set value
voteCount.set(100);

// Update based on current value
voteCount.update(n => n + 1);

// Subscribe
voteCount.subscribe(value => {
  console.log('Votes:', value);
});

In components, use the $ prefix for automatic subscription:

<script>
  import { voteCount } from './stores';
</script>

<div>Votes: {$voteCount}</div>

Svelte handles subscribe/unsubscribe automatically. No memory leaks.

Matchup Store

The core of Tuggy is the matchup store:

// stores/matchupStore.ts
import { writable, derived } from 'svelte/store';

interface MatchupState {
  id: string;
  title: string;
  votesA: number;
  votesB: number;
  tugs: number;
  tugsPerSecond: number;
  activeUpgrades: Upgrade[];
}

function createMatchupStore() {
  const { subscribe, set, update } = writable<MatchupState>({
    id: '',
    title: '',
    votesA: 0,
    votesB: 0,
    tugs: 0,
    tugsPerSecond: 0,
    activeUpgrades: []
  });

  return {
    subscribe,
    set,
    castVote: (side: 'a' | 'b', amount: number) => {
      update(state => ({
        ...state,
        [side === 'a' ? 'votesA' : 'votesB']: state.votesA + amount
      }));
    },
    earnTugs: (amount: number) => {
      update(state => ({ ...state, tugs: state.tugs + amount }));
    },
    activateUpgrade: (upgrade: Upgrade) => {
      update(state => ({
        ...state,
        activeUpgrades: [...state.activeUpgrades, upgrade]
      }));
    }
  };
}

export const matchup = createMatchupStore();

Custom methods make it feel like a class while staying reactive.

Derived Stores

Computed values with derived:

export const totalVotes = derived(
  matchup,
  $matchup => $matchup.votesA + $matchup.votesB
);

export const votePercentages = derived(
  matchup,
  $matchup => {
    const total = $matchup.votesA + $matchup.votesB;
    if (total === 0) return { a: 50, b: 50 };

    return {
      a: ($matchup.votesA / total) * 100,
      b: ($matchup.votesB / total) * 100
    };
  }
);

Use in components:

<div>Total: {$totalVotes.toLocaleString()}</div>
<div>Side A: {$votePercentages.a.toFixed(1)}%</div>

Derived stores only recompute when dependencies change. Efficient.

Combining Stores

Derive from multiple stores:

export const effectiveClickPower = derived(
  [matchup, upgrades],
  ([$matchup, $upgrades]) => {
    let power = 1;

    $matchup.activeUpgrades.forEach(upgrade => {
      if (upgrade.type === 'click_multiplier') {
        power *= upgrade.multiplier;
      }
    });

    return power;
  }
);

Automatically updates when either matchup or upgrades change.

Async Stores

Loading data:

import { readable } from 'svelte/store';

export const leaderboard = readable([], (set) => {
  // Initial load
  loadLeaderboard().then(set);

  // Subscribe to updates
  const channel = supabase
    .channel('leaderboard')
    .on('postgres_changes', { ... }, () => {
      loadLeaderboard().then(set);
    })
    .subscribe();

  // Cleanup
  return () => {
    channel.unsubscribe();
  };
});

Readable stores can't be set externally, only by their initializer.

Local Storage Persistence

Auto-save to localStorage:

function createPersistedStore<T>(key: string, initial: T) {
  const stored = localStorage.getItem(key);
  const data = stored ? JSON.parse(stored) : initial;

  const store = writable<T>(data);

  store.subscribe(value => {
    localStorage.setItem(key, JSON.stringify(value));
  });

  return store;
}

export const userPreferences = createPersistedStore('prefs', {
  darkMode: true,
  soundEnabled: true,
  particlesEnabled: true
});

Changes automatically save. Persists across sessions.

Store Composition

Build complex stores from simple ones:

function createVotingSession(matchupId: string) {
  const votes = writable(0);
  const side = writable<'a' | 'b' | null>(null);
  const tugs = writable(0);

  const canVote = derived(
    [votes, side],
    ([$votes, $side]) => $side !== null && $votes < 1000
  );

  return {
    votes,
    side,
    tugs,
    canVote,
    vote: () => {
      votes.update(n => n + 1);
      tugs.update(n => n + 1);
    }
  };
}

Encapsulates related state and behavior.

Store vs Component State

When to use each:

Use stores for:

  • Shared state across components
  • State that persists between route changes
  • Global app state
  • Data from external sources

Use component state for:

  • UI-only state (dropdowns, modals)
  • Temporary state (form inputs)
  • State local to one component
<script>
  // Store - shared across app
  import { matchup } from './stores';

  // Component state - local to this component
  let isModalOpen = false;
  let inputValue = '';
</script>

Debugging Stores

Log changes:

matchup.subscribe(value => {
  console.log('Matchup changed:', value);
});

Or use Svelte DevTools browser extension to inspect store values.

Takeaway

Svelte stores are simple but powerful. No reducers, no actions, no middleware. Just reactive values.

The $ syntax is pure magic. Auto-subscribe in components, auto-cleanup on unmount.

Derived stores eliminate manual dependency tracking. Values stay in sync automatically.

Custom store methods (like castVote()) make stores feel like proper APIs while keeping reactivity.

localStorage persistence is trivial with the subscribe callback pattern.

For complex apps, stores scale better than prop drilling or context. But they're simple enough for small components too.

Coming up next: SEO for viral content and making matchups rank on Google.