State Management

Comprehensive guide to state management in DesQTA

State Management Overview

DesQTA uses a multi-layered state management approach combining Svelte 5 runes, Svelte stores, and service-level state. This guide explains each layer and when to use them.

State Management Layers

┌─────────────────────────────────────┐
│   Component State ($state)          │  ← Local, component-specific
├─────────────────────────────────────┤
│   Global Stores (writable)          │  ← Shared across components
├─────────────────────────────────────┤
│   Service State (classes)           │  ← Business logic state
├─────────────────────────────────────┤
│   Backend State (Rust)              │  ← Persistent, secure state
└─────────────────────────────────────┘

Layer 1: Component State ($state)

Use $state for local component state that doesn't need to be shared.

Basic Usage

<script lang="ts">
  // Simple state
  let count = $state(0);
  let name = $state('');
  let isOpen = $state(false);
  
  // Complex state
  let user = $state<User | null>(null);
  let items = $state<Item[]>([]);
  
  // Update state
  function increment() {
    count++;
  }
  
  function addItem(item: Item) {
    items = [...items, item];
  }
</script>

<button onclick={increment}>Count: {count}</button>

State Updates

// ✅ Good - Direct assignment
count = 10;
items = [...items, newItem];
user = { ...user, name: 'New Name' };

// ✅ Good - Array methods that create new arrays
items = items.filter(i => i.id !== id);
items = items.map(i => ({ ...i, updated: true }));

// ❌ Avoid - Mutating arrays/objects directly
items.push(newItem); // Won't trigger reactivity
user.name = 'New Name'; // Won't trigger reactivity

State Initialization

// Initialize from props
let { initialCount }: { initialCount: number } = $props();
let count = $state(initialCount);

// Initialize from async data
let data = $state<Data | null>(null);

onMount(async () => {
  data = await fetchData();
});

Layer 2: Derived State ($derived)

Use $derived for computed values that depend on other state.

Basic Derived State

let count = $state(0);
let doubled = $derived(count * 2);
let isEven = $derived(count % 2 === 0);

Complex Derived State

let assessments = $state<Assessment[]>([]);
let filter = $state('');

// Filtered and sorted assessments
let filteredAssessments = $derived(
  assessments
    .filter(a => a.title.includes(filter))
    .sort((a, b) => a.dueDate.getTime() - b.dueDate.getTime())
);

// Count of filtered items
let filteredCount = $derived(filteredAssessments.length);

Derived from Multiple Sources

let user = $state<User | null>(null);
let theme = $state('dark');

let displayName = $derived(
  user ? `${user.firstName} ${user.lastName}` : 'Guest'
);

let themeClass = $derived(
  `theme-${theme} ${user ? 'user-theme' : 'guest-theme'}`
);

Conditional Derived State

let selectedTab = $state<'list' | 'board' | 'calendar'>('list');

let currentView = $derived(
  selectedTab === 'list' ? 'list-view' :
  selectedTab === 'board' ? 'board-view' :
  'calendar-view'
);

Layer 3: Effects ($effect)

Use $effect for side effects that need to run when state changes.

Basic Effects

let count = $state(0);

$effect(() => {
  console.log('Count changed:', count);
  // Update DOM, call API, etc.
});

Effects with Cleanup

let isActive = $state(false);

$effect(() => {
  if (!isActive) return;
  
  const interval = setInterval(() => {
    console.log('Tick');
  }, 1000);
  
  // Cleanup function
  return () => {
    clearInterval(interval);
  };
});

Conditional Effects

let user = $state<User | null>(null);

$effect(() => {
  if (!user) return;
  
  // Only runs when user exists
  loadUserData(user.id);
});

Effects with Dependencies

let userId = $state(1);
let includeDetails = $state(false);

$effect(() => {
  // Runs when userId OR includeDetails changes
  loadUser(userId, includeDetails);
});

Layer 4: Global Stores

Use Svelte stores for state that needs to be shared across multiple components.

Creating Stores

// src/lib/stores/theme.ts
import { writable, derived } from 'svelte/store';

export const theme = writable<'light' | 'dark' | 'system'>('system');
export const accentColor = writable('#3b82f6');

// Derived store
export const themeClass = derived(
  theme,
  $theme => `theme-${$theme}`
);

Using Stores

<script lang="ts">
  import { theme, accentColor } from '$lib/stores/theme';
  
  // Auto-subscribe with $ prefix
  $: currentTheme = $theme;
  $: currentAccent = $accentColor;
  
  // Update store
  function toggleTheme() {
    theme.update(t => t === 'dark' ? 'light' : 'dark');
  }
  
  function setAccent(color: string) {
    accentColor.set(color);
  }
</script>

<div class="theme-{$theme}">
  <p>Current theme: {$theme}</p>
  <p>Accent: {$accentColor}</p>
  <button onclick={toggleTheme}>Toggle</button>
</div>

Store Patterns in DesQTA

Theme Store

// src/lib/stores/theme.ts
export const theme = writable<'light' | 'dark' | 'system'>('system');
export const accentColor = writable('#3b82f6');
export const currentTheme = writable('default');
export const themeManifest = writable<ThemeManifest | null>(null);

// Load theme from settings
export async function loadTheme() {
  const subset = await invoke('get_settings_subset', { keys: ['theme'] });
  theme.set(subset?.theme || 'system');
}

User Store (Example)

// src/lib/stores/user.ts
import { writable } from 'svelte/store';
import type { UserInfo } from '$lib/services/authService';

export const user = writable<UserInfo | undefined>(undefined);
export const isAuthenticated = derived(user, $user => !!$user);

Layer 5: Service State

Services can maintain their own internal state for business logic.

Service with State

// src/lib/services/authService.ts
class AuthService {
  private user: UserInfo | null = null;
  private sessionValid: boolean = false;
  
  async checkSession(): Promise<boolean> {
    this.sessionValid = await invoke('check_session_exists');
    return this.sessionValid;
  }
  
  async loadUser(): Promise<UserInfo | null> {
    if (!this.user) {
      this.user = await fetchUserInfo();
    }
    return this.user;
  }
  
  clearUser() {
    this.user = null;
    this.sessionValid = false;
  }
}

export const authService = new AuthService();

State Synchronization

Syncing Component State with Stores

<script lang="ts">
  import { theme } from '$lib/stores/theme';
  
  // Sync local state with store
  let localTheme = $state($theme);
  
  // Update both when changed
  function updateTheme(newTheme: 'light' | 'dark' | 'system') {
    localTheme = newTheme;
    theme.set(newTheme);
  }
  
  // Subscribe to store changes
  $effect(() => {
    localTheme = $theme;
  });
</script>

Syncing with Backend

let settings = $state<Settings>({});

// Load from backend
onMount(async () => {
  const backendSettings = await invoke('get_settings');
  settings = backendSettings;
});

// Save to backend when changed
$effect(() => {
  if (settings) {
    invoke('save_settings', { settings });
  }
});

State Persistence

Using useDataLoader

import { useDataLoader } from '$lib/utils/useDataLoader';

let assessments = $state<Assessment[]>([]);

onMount(async () => {
  const data = await useDataLoader({
    cacheKey: 'assessments',
    ttlMinutes: 10,
    context: 'assessments',
    functionName: 'loadAssessments',
    fetcher: async () => {
      return await invoke('get_assessments');
    },
    onDataLoaded: (data) => {
      assessments = data;
    }
  });
});

Using IndexedDB

import { setIdb, getWithIdbFallback } from '$lib/services/idbCache';

// Save state
async function saveState(state: State) {
  await setIdb('myState', state);
}

// Load state
async function loadState(): Promise<State | null> {
  return await getWithIdbFallback('myState', 'myState', () => null);
}

State Management Patterns

Pattern 1: Form State

let formData = $state({
  name: '',
  email: '',
  message: ''
});

let errors = $state<Record<string, string>>({});
let isSubmitting = $state(false);

async function submit() {
  isSubmitting = true;
  errors = {};
  
  try {
    await submitForm(formData);
    // Reset form
    formData = { name: '', email: '', message: '' };
  } catch (e) {
    errors = e.errors;
  } finally {
    isSubmitting = false;
  }
}

Pattern 2: List State with Filtering

let items = $state<Item[]>([]);
let filter = $state('');
let sortBy = $state<'name' | 'date'>('name');

let filteredItems = $derived(
  items
    .filter(item => item.name.includes(filter))
    .sort((a, b) => {
      if (sortBy === 'name') {
        return a.name.localeCompare(b.name);
      }
      return a.date.getTime() - b.date.getTime();
    })
);

Pattern 3: Modal State

let isOpen = $state(false);
let modalData = $state<ModalData | null>(null);

function openModal(data: ModalData) {
  modalData = data;
  isOpen = true;
}

function closeModal() {
  isOpen = false;
  // Clear data after animation
  setTimeout(() => {
    modalData = null;
  }, 300);
}

Pattern 4: Async Data State

let data = $state<Data | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);

async function loadData() {
  loading = true;
  error = null;
  
  try {
    data = await fetchData();
  } catch (e) {
    error = e instanceof Error ? e.message : 'Unknown error';
  } finally {
    loading = false;
  }
}

Best Practices

1. Choose the Right Layer

// ✅ Component state - only used in one component
let isExpanded = $state(false);

// ✅ Store - shared across components
export const theme = writable('dark');

// ✅ Service state - business logic
class AuthService {
  private user: UserInfo | null = null;
}

2. Minimize State

// ✅ Good - Derive instead of storing
let items = $state<Item[]>([]);
let filteredItems = $derived(items.filter(i => i.active));

// ❌ Avoid - Storing derived data
let items = $state<Item[]>([]);
let filteredItems = $state<Item[]>([]);

3. Keep State Local

// ✅ Good - Local state when possible
let isOpen = $state(false);

// ❌ Avoid - Global store for local state
export const modalOpen = writable(false);

4. Use TypeScript

// ✅ Good - Type everything
let user = $state<User | null>(null);
let items = $state<Item[]>([]);

// ❌ Avoid - Any types
let user = $state<any>(null);

Common Pitfalls

Pitfall 1: Mutating State

// ❌ Wrong - Mutating array
items.push(newItem);

// ✅ Correct - Creating new array
items = [...items, newItem];

Pitfall 2: Storing Derived Data

// ❌ Wrong - Storing computed value
let doubled = $state(0);
$effect(() => {
  doubled = count * 2;
});

// ✅ Correct - Using $derived
let doubled = $derived(count * 2);

Pitfall 3: Unnecessary Effects

// ❌ Wrong - Effect for simple computation
let doubled = $state(0);
$effect(() => {
  doubled = count * 2;
});

// ✅ Correct - Use $derived
let doubled = $derived(count * 2);

Next Steps