OOP as State Management: What Svelte 5 Runes Made Obvious

by Igor Tosic, Software Engineer

When the Scaffold Disappears

There's a moment in woodworking — or so I've been told — when a craftsman stops thinking about the chisel and starts thinking only about the wood. The tool becomes invisible. It's no longer an intermediary between intention and creation; it's simply gone.

I've been chasing that feeling in software for years.

With React and Redux, I never quite got there. The tool was always present. Every feature began with the same liturgy: define the action type, write the action creator, build the reducer case, wire up the selector, connect the component. It was structured. It was predictable. And it was loud. The architecture was always announcing itself, always demanding attention before you could think about the actual problem you were solving.

Then I built an CRM demo system with SvelteKit 5, and something shifted. Not a dramatic revelation — more like realizing you've been holding your breath, and you finally exhale.

I want to show you what that exhale looks like — by tracing one feature, end to end. A client list page. Filtering, pagination, search, contacts, modals. The full thing. From the server to the store to the component and back.

The Store: A Class, Not a Configuration

In my previous articles, I explored the migration from React/Redux to SvelteKit and the philosophical implications of Svelte 5 runes. But I want to go deeper now. Not into what changed, but why it matters — following the client list flow through every layer.

Let's start where most state management stories start: the store itself.

// src/lib/store/clients.svelte.ts
import type {
  Client,
  LeadStatus,
  PaginationState,
  ContactResponse,
} from '$lib/types'

export class ClientStore {
  // State as fields
  clients = $state<Client[]>([])
  pagination = $state<PaginationState>({
    currentPage: 1,
    totalPages: 1,
    totalItems: 0,
    itemsPerPage: 10,
  })
  filters = $state<{
    search: string
    lead: LeadStatus | null
  }>({
    search: '',
    lead: null,
  })
  loading = $state(false)
  error = $state<string | null>(null)

  // Contact sub-domain
  selectedClientContacts = $state<ContactResponse[]>([])
  contactModalOpen = $state(false)
  contactModalMode = $state<'create' | 'edit' | 'view'>('create')
  selectedContact = $state<ContactResponse | null>(null)

  // Methods as actions
  setClientsData(data: {
    clients: Client[]
    pagination: PaginationState
    filters?: { search?: string; lead?: LeadStatus | null }
  }): void {
    this.clients = data.clients
    this.pagination = data.pagination
    if (data.filters) {
      this.filters = {
        ...this.filters,
        search: data.filters.search || '',
        lead: data.filters.lead || null,
      }
    }
  }

  updateFilters(newFilters: {
    search?: string
    lead?: LeadStatus | null
  }): void {
    this.filters = { ...this.filters, ...newFilters }
  }

  setLoading(isLoading: boolean): void {
    this.loading = isLoading
  }

  // Contact modal orchestration
  viewContact(contact: ContactResponse): void {
    this.selectedContact = contact
    this.contactModalMode = 'view'
    this.contactModalOpen = true
  }

  createContact(): void {
    this.selectedContact = null
    this.contactModalMode = 'create'
    this.contactModalOpen = true
  }

  closeContactModal(): void {
    this.contactModalOpen = false
    this.selectedContact = null
  }

  // Nested updates
  addContactToClient(clientId: number, contact: ContactResponse): void {
    this.clients = this.clients.map((client) =>
      client.id === clientId
        ? {
            ...client,
            contacts: [...client.contacts, contact],
            contact_count: client.contacts.length + 1,
          }
        : client,
    )
    this.selectedClientContacts = [...this.selectedClientContacts, contact]
  }
}

export const clientStore = new ClientStore()

Stop and read this carefully. This is not Redux. This is not Vuex. This is object-oriented programming applied to state management.

The store is a class. It has fields (state) and methods (actions). When you call clientStore.setClientsData(...), you're calling a method on an instance. The state properties use the $state rune, which means Svelte automatically tracks their changes and updates any component that reads them.

There are no action creators. No reducers. No switch statements. No middleware. No connect(). No useSelector. No dispatch. The mental model is: an instance of a class that holds data and knows how to modify itself.

The $state rune is not a function call — it's a compiler instruction. Svelte sees it and generates exactly the reactive plumbing your code needs. At runtime, this is just a JavaScript class. Your IDE understands it. TypeScript validates it. A junior developer reads it and knows what it does.

This is simpler than Redux. But more importantly, it's honest — it doesn't pretend to be functional programming if you're writing imperative code anyway.

The River and the Pipes: How Data Reaches the Client List

Now that we have the store, how does data reach it? This is where SvelteKit distinguishes itself not just from React, but from the entire mental model of client-side state management.

In Redux-world, data typically flows like this: the component mounts, dispatches an action, the action triggers an API call (through thunks or sagas or whatever middleware is fashionable this quarter), the response hits the reducer, the state updates, and the component re-renders through a selector.

That's a lot of pipes. Carefully assembled, meticulously maintained pipes.

SvelteKit proposes something different: What if the data just... arrived?

Act I: The Server Speaks First (+page.server.ts)

Every page in SvelteKit can have a +page.server.ts file. This runs exclusively on the server — it has access to cookies, databases, internal APIs. For our client list, it fetches clients from the backend with whatever filters the URL carries:

// src/routes/clients/+page.server.ts
import { listClients } from '$lib/server/clients'
import type { PageServerLoad } from './$types'
import type { LeadStatus } from '$lib/types'

export const load: PageServerLoad = async ({ cookies, url }) => {
  const page = Number(url.searchParams.get('page') || '1')
  const search = url.searchParams.get('search') || ''
  const lead = url.searchParams.get('lead') as LeadStatus | null

  const clientsResponse = await listClients(cookies, {
    page,
    itemsPerPage: 10,
    search,
    lead: lead || undefined,
  })

  return {
    clients: clientsResponse.data,
    pagination: clientsResponse.pagination,
    filters: { search, lead },
  }
}

No client-side fetch. No loading spinner on first render. No flash of empty content. The data is there when the HTML arrives. The server did its job, quietly, before the browser ever opened its mouth to ask.

This is fundamentally different from Redux or any client-side state library. In those patterns, the client constructs API calls, handles loading states, manages error states. Here, the server is the authority. The server decides what data the client needs. The server handles authentication through cookies — no tokens in JavaScript, no auth headers in client code.

Act II: The Bridge (+page.ts)

But here's the question: we have our ClientStore class on the client. How does server data reach it?

This is where +page.ts enters — the universal load function that runs on both server and client. It receives the data from +page.server.ts and acts as the bridge:

// src/routes/clients/+page.ts
import type { PageLoad } from './$types'
import { clientStore } from '$lib/store/clients.svelte'
import { browser } from '$app/environment'

export const load: PageLoad = async ({ data, depends }) => {
  depends('app:clients')

  if (browser) {
    clientStore.setClientsData({
      clients: data.clients,
      pagination: data.pagination,
      filters: data.filters,
    })
    clientStore.setLoading(false)
  }

  return data
}

Study this for a moment. The data parameter contains everything +page.server.ts returned. The depends("app:clients") call registers a dependency key — when we later call invalidate("app:clients"), SvelteKit knows to re-run this entire chain.

The if (browser) guard is the key insight: during SSR, this code runs on the server where we don't need to hydrate a client-side store. But when the page loads in the browser — or when the user navigates client-side — we pour the server data into our ClientStore instance. One method call: clientStore.setClientsData(...). The class does the rest.

This is the pattern that Redux could never offer, because Redux wasn't designed with a full-stack framework in mind. Here: server loads the data, the bridge hydrates the store, components read from the store.

No prop drilling through seventeen layers. No context providers wrapping your tree. No useEffect that fires after the first render to "sync" state. The store is ready when the component mounts.

Act III: The Component Breathes (+page.svelte → client-list.svelte)

And now the page component itself:

<!-- src/routes/clients/+page.svelte -->
<script lang="ts">
  import ClientList from "$lib/components/clients/client-list.svelte";
</script>

<ClientList />

That's the entire file. No props. No data destructuring. No store subscriptions. The component delegates to ClientList, which reads directly from the store:

<!-- src/lib/components/clients/client-list.svelte -->
<script lang="ts">
  import SearchBar from "./search-bar.svelte";
  import Filters from "./filters.svelte";
  import ClientTable from "./client-table.svelte";
  import RefreshButton from "./refresh-button.svelte";
  import { Button } from "$lib/components/ui/button/index.js";
  import Plus from "@lucide/svelte/icons/plus";
  import { goto } from "$app/navigation";
  import { clientStore } from "$lib/store/clients.svelte";

  const clientsList = $derived(clientStore.clients);
  const paginationData = $derived(clientStore.pagination);
  const currentFilters = $derived(clientStore.filters);

  const clientsFound = $derived(
    `${paginationData.totalItems} ${paginationData.totalItems === 1 ? "client" : "clients"} found`,
  );

  function handlePageChange(pageNum: number) {
    if (pageNum === paginationData.currentPage) return;
    const url = new URL(window.location.href);
    url.searchParams.set("page", String(pageNum));
    clientStore.setLoading(true);
    goto(url.toString(), { replaceState: false });
  }
</script>

<div class="space-y-4">
  <div class="flex items-center justify-between">
    <h1 class="text-2xl font-bold">Clients</h1>
    <div class="flex items-center gap-2">
      <RefreshButton />
      <Button href="/clients/new">
        <Plus class="mr-2 h-4 w-4" />
        Add Client
      </Button>
    </div>
  </div>

  <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
    <SearchBar />
    <Filters activeFilter={currentFilters.lead || "all"} />
  </div>

  <div class="text-muted-foreground text-sm">{clientsFound}</div>

  <ClientTable clients={clientsList} />
</div>

Notice what's happening. $derived creates reactive references to the store. When clientStore.clients changes, clientsList updates. When clientStore.pagination changes, the pagination buttons re-render. The compiler generates code that does this tracking for you.

No selectors. No memoization. No useCallback worries. No virtual DOM diffing. No React.memo. No shouldComponentUpdate. Just: "This value depends on that value. Update it when that value changes."

And SearchBar, Filters, ClientTable — each of these child components imports clientStore independently. They don't receive it as a prop. They don't need a Provider ancestor. They just reach for the store and use it. The class instance is the shared state.

The Feedback Loop: Filtering in Action

Here's where you see the full cycle come alive. The Filters component:

<!-- src/lib/components/clients/filters.svelte -->
<script lang="ts">
  import { clientStore } from "$lib/store/clients.svelte";
  import { Button } from "$lib/components/ui/button/index.js";
  import { goto } from "$app/navigation";
  import type { LeadStatus } from "$lib/types";

  const { activeFilter = "all" } = $props<{ activeFilter?: LeadStatus | "all" }>();

  const isLoading = $derived(clientStore.loading);

  function handleFilterChange(filter: LeadStatus | "all") {
    if (filter === "all") {
      clientStore.updateFilters({ lead: null });
    } else {
      clientStore.updateFilters({ lead: filter });
    }

    clientStore.setLoading(true);

    const url = new URL(window.location.href);
    if (filter === "all") {
      url.searchParams.delete("lead");
    } else {
      url.searchParams.set("lead", filter);
    }
    url.searchParams.delete("page");

    goto(url.toString(), { replaceState: false });
  }
</script>

And the SearchBar:

<!-- src/lib/components/clients/search-bar.svelte -->
<script lang="ts">
  import { Input } from "$lib/components/ui/input/index.js";
  import { Button } from "$lib/components/ui/button/index.js";
  import Search from "@lucide/svelte/icons/search";
  import RefreshCcw from "@lucide/svelte/icons/refresh-ccw";
  import { clientStore } from "$lib/store/clients.svelte";
  import { goto } from "$app/navigation";

  // Track search query in local state, initialize from store
  let searchQuery = $state(clientStore.filters.search || "");
  let searchTimeout: ReturnType<typeof setTimeout>;

  // Handle search as user types with debounce
  function handleSearchInput() {
    // Update the store directly for immediate UI feedback
    clientStore.updateFilters({ search: searchQuery });

    // Clear existing timeout
    clearTimeout(searchTimeout);

    // Debounce the actual navigation
    searchTimeout = setTimeout(() => {
      performSearch();
    }, 500); // 500ms debounce
  }

  function performSearch() {
    clientStore.setLoading(true);

    // Build URL with search term
    const url = new URL(window.location.href);
    if (searchQuery) {
      url.searchParams.set("search", searchQuery);
    } else {
      url.searchParams.delete("search");
    }
    url.searchParams.delete("page"); // Reset to first page when searching

    // Use goto to trigger server-side navigation
    goto(url.toString(), { replaceState: false });
  }
</script>

Watch the cycle:

  1. User clicks "Hot" filterclientStore.updateFilters({ lead: "hot" }) — instant UI feedback
  2. goto() changes the URL → SvelteKit sees URL change
  3. +page.server.ts re-runs → server fetches only hot leads from backend API
  4. +page.ts receives new dataclientStore.setClientsData(...) hydrates the store
  5. Components re-render$derived(clientStore.clients) picks up the new filtered list

The same cycle for search. The same cycle for pagination. The URL is the source of truth for what the server fetches; the store is the source of truth for what the UI shows. They're not competing — they're collaborating.

In React, this pattern would require TanStack Query or SWR alongside Redux, creating two parallel state systems fighting for authority. Or you'd wire together useEffect, useState, useCallback, router listeners, and cache invalidation logic across half a dozen files. In SvelteKit, the framework is the data layer.

The Redux Mental Model, Honestly

Let's be concrete about what this replaces. Imagine the same filtering feature in Redux:

// Redux: actions.ts
export const SET_CLIENTS_DATA = 'SET_CLIENTS_DATA'
export const UPDATE_FILTERS = 'UPDATE_FILTERS'
export const SET_LOADING = 'SET_LOADING'

// Redux: reducer.ts
const clientsReducer = (state = initialState, action) => {
  switch (action.type) {
    case 'SET_CLIENTS_DATA':
      return {
        ...state,
        clients: action.payload.clients,
        pagination: action.payload.pagination,
      }
    case 'UPDATE_FILTERS':
      return {
        ...state,
        filters: { ...state.filters, ...action.payload },
      }
    case 'SET_LOADING':
      return { ...state, loading: action.payload }
    default:
      return state
  }
}

// Redux: component.tsx
const clients = useSelector((state) => state.clients.clients)
const filters = useSelector((state) => state.clients.filters)
const dispatch = useDispatch()

const handleFilter = (lead) => {
  dispatch({ type: 'UPDATE_FILTERS', payload: { lead } })
  dispatch({ type: 'SET_LOADING', payload: true })
  // ...then navigate, and hope the useEffect catches the new data
}

Now the same thing in Svelte 5:

// Svelte 5: in the component
const clients = $derived(clientStore.clients)
const filters = $derived(clientStore.filters)

const handleFilter = (lead) => {
  clientStore.updateFilters({ lead })
  clientStore.setLoading(true)
  goto(url, { replaceState: false })
}
AspectReduxSvelte 5 Class Store
State definitionInitial state object + reducerClass fields with $state
UpdatesDispatch actions → reducer switchCall methods directly
Computed valuescreateSelector + useSelectorget accessors or $derived
Async operationsThunk/Saga middlewareDirect async methods
BoilerplateActions + reducers + selectors + Provider + connectClass + export instance
TypeScriptGood (with toolkit)Excellent (class members are typed)
Bundle size impact~2KB+ (Redux + React-Redux)0KB (compiler feature)
Component accessuseSelector + useDispatchImport and use
Multiple storesOne global store (convention)Multiple class instances (natural)
Learning curveActions, reducers, selectors, middleware, ProviderClasses + three runes

The Svelte version isn't a little simpler. It's a different order of magnitude of simplicity. And not because it's doing less — the same features are there. Filtering. Loading states. Pagination. Shared state across sibling components. It's simpler because the framework eliminated the ceremony.

Computed Properties: When Getters Are Enough

The same pattern extends beyond the client list. Consider the UserStore, which demonstrates how class getters become reactive computed properties:

// src/lib/store/user.svelte.ts
export class UserStore {
  user = $state<User | null>(null)

  get isAuthenticated() {
    return this.user !== null
  }

  get permissions(): UserPermissions | null {
    if (!this.user) return null
    return {
      canCreateClients: this.user.can_manage_clients,
      canDeleteClients: this.user.can_delete_clients,
      isAdmin: this.user.is_superuser || this.user.is_staff,
    }
  }

  get displayName(): string | null {
    if (!this.user) return null
    return this.user.first_name && this.user.last_name
      ? `${this.user.first_name} ${this.user.last_name}`
      : this.user.username
  }
}

export const userStore = new UserStore()

Standard JavaScript getters. But because they reference $state properties, Svelte automatically tracks them as derived values. They recompute when their dependencies change. No useMemo. No createSelector. No dependency arrays to get wrong.

In a component: userStore.displayName. One expression. Type-safe. Automatically reactive when userStore.user changes.

In Redux: useSelector(selectDisplayName) — where selectDisplayName is a separate function, in a separate file, that you hope stays in sync with the state shape.

When Not to Use a Store: The Wisdom of Constraint

Here's something often unmentioned in state management articles: not everything should be in a store.

In this demo system, the dashboard takes a different approach:

<!-- src/routes/dashboard/+page.svelte -->
<script lang="ts">
  import StatsCard from "$lib/components/dashboard/stats-card.svelte";
  import RecentClients from "$lib/components/dashboard/recent-clients.svelte";
  import type { PageData } from "./$types";

  let { data } = $props<{ data: PageData }>();
</script>

<StatsCard title="Total Clients" value={data.statistics.totalClients} description="Demo" />
<RecentClients clients={data.recentClients} />

No store. data props directly from the server. And that's intentional. And wise.

The dashboard is a read-only summary view. It aggregates statistics, shows recent items, displays charts. There's no filtering. No pagination. No modals. No user interaction that modifies this data. The data flows in one direction — from server to template — and stays there.

Stores should be used for:

  • Shared state across multiple components/routes
  • State that persists across navigation
  • Complex interactions (filtering, pagination, modals, CRUD)

Props should be used for:

  • Data that flows parent → child
  • Read-only displays, dashboards, summaries
  • Data that doesn't need to be shared with siblings

Component-local $state should be used for:

  • Form inputs, toggle states, temporary values
  • UI state not needed by other components

A well-designed application demonstrates this wisdom. The clients page uses a store because SearchBar, Filters, ClientTable, and RefreshButton all need shared access. The dashboard passes props because data flows in one direction. The framework doesn't impose a single pattern. It asks: does this state need to be shared?

This is the philosophical shift Redux missed. Redux assumes all state must be managed. Svelte 5 asks: does it need to be?

No Library. That's the Point.

Let me state something that should be obvious but somehow isn't: we use no state management library in this entire application.

No Redux. No Zustand. No Jotai. No Recoil. No MobX. No Pinia. No NgRx.

The entire store layer — user authentication with permission getters, client management with nested contacts and modal orchestration, task tracking with filtered pagination, calendar events with drag-to-create — is built with nothing but Svelte 5 runes and plain TypeScript classes.

In the React ecosystem, choosing a state management library is itself a project. Evaluating options, learning APIs, configuring middleware, debugging integration issues. Teams spend days on this decision. Blog posts rank them. Conference talks compare them.

Svelte 5 sidesteps the question entirely. $state() makes a property reactive. $derived() computes from reactive properties. $effect() runs side effects. Classes organize your domain. Export an instance. Import it where you need it.

That's the whole API. There's nothing else to learn.

What This Means for the Craft

I've been thinking about why this matters beyond the technical arguments. Beyond bundle sizes and benchmark numbers and developer experience surveys.

When I write Redux, I spend mental energy on action naming conventions, reducer organization, selector memoization, middleware configuration, DevTools setup, type definitions that mirror structure three times over. When I write Svelte 5 stores, I think about my domain. Clients. Contacts. Filters. Pagination. That's it.

For a developer building a complex application, this compounds. Every feature is 30-40% faster to implement. Every bug is easier to trace — no action logs to decode. Every refactor is simpler — no reducer restructuring. Over months, this adds up to weeks of saved time.

But it's more than productivity. It's about the relationship between the developer and the code. Software should be shaped by the problem, not by the framework's expectations. When I design the ClientStore, I'm thinking about clients and contacts and how a sales team filters their pipeline. I'm not thinking about which middleware handles the async flow. I'm not wondering if my selector is properly memoized.

The framework becomes invisible. And when the framework is invisible, all that's left is the thing you're actually making.

The chisel disappears. You see only the wood.

More articles

The Death of Frameworks and Why I Stopped Worrying

How Svelte 5 Runes Made Me Realize the Wrong Question

Read more

The Svelte Shift: From React/Redux to SvelteKit5

The Data Flow Revolution: Built-in State Management

Read more

Ready to Transform Your Business?