From Making It Work to Keeping It Maintainable
As applications grow, the challenge shifts from “how do I make this work?” to “how do I keep this maintainable?” Features accumulate—authentication, shopping cart, notifications, user preferences, search—each with its own state, logic, and UI. Without clear boundaries, these features bleed into each other. Authentication logic shows up in the cart. Cart state gets read from components that have no business knowing about it. Changes ripple unpredictably through the codebase.
Context provides a powerful architectural pattern: treat each feature as a self-contained unit with a clear boundary. The context becomes the feature’s public API—a contract between the feature and the rest of the application. Implementation details stay hidden. Dependencies become explicit. Testing becomes straightforward.
This isn’t just code organization for its own sake. Feature boundaries solve real problems: they make refactoring safe (change internals without breaking consumers), enable team collaboration (own a feature end-to-end), and create natural documentation (the context interface tells you exactly what a feature does).
This article teaches you to build feature modules with context: how to design public APIs, what to keep private, when to replace global stores, and how to compose features that depend on each other.
The Problem Space
Let’s examine what goes wrong without clear feature boundaries.
The Scattered Feature Problem
Consider a shopping cart feature that evolved organically:
// cart-store.ts - Global store, accessible everywhere
export const cartItems = writable<CartItem[]>([])
export const cartTotal = derived(cartItems, items =>
items.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
// helpers.ts - Utility functions spread around
export function addToCart(product: Product) {
cartItems.update(items => [...items, { ...product, quantity: 1 }])
}
// validation.ts - Validation logic elsewhere
export function canAddToCart(product: Product, items: CartItem[]) {
const existing = items.find(i => i.id === product.id)
return !existing || existing.quantity < 99
}
// CartButton.svelte - Component with business logic mixed in
<script>
import { cartItems, cartTotal } from '$lib/stores/cart-store'
import { addToCart, canAddToCart } from '$lib/helpers'
import { get } from 'svelte/store'
export let product
function handleClick() {
if (canAddToCart(product, get(cartItems))) {
addToCart(product)
// Also need to track analytics...
analytics.track('add_to_cart', { productId: product.id })
// And maybe show a notification...
notifications.show('Added to cart!')
}
}
</script> The cart feature is spread across four files with no clear boundary. Any component can import cartItems and mutate it directly. Business logic (validation, analytics, notifications) lives in UI components. When you need to change how the cart works—say, adding server sync—you have to hunt through the entire codebase.
Problems This Creates
No encapsulation. Any component can import and mutate cartItems directly, bypassing validation:
// Someone on another team, unaware of the 99-item limit
cartItems.update((items) => {
const item = items.find((i) => i.id === '123')
if (item) item.quantity = 1000 // Oops
return items
}) Testing difficulty. To test CartButton, you need to mock global stores, import helpers, and set up the entire cart state:
// Brittle test with many dependencies
vi.mock('$lib/stores/cart-store')
vi.mock('$lib/helpers')
// Now set up all the mocked state... SSR hazards. Module-level stores persist between requests on the server. User A’s cart could leak into User B’s response:
// This runs once when the module loads, not per-request
export const cartItems = writable([]) // Shared across all SSR requests! Implicit dependencies. Components silently depend on global state. Moving or refactoring them might break things in non-obvious ways.
What Feature Boundaries Provide
A properly encapsulated feature looks like this:
┌─────────────────────────────────────────────────────────────┐
│ FEATURE: Shopping Cart │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ PRIVATE (Internal Implementation) │ │
│ │ │ │
│ │ • Raw $state arrays and objects │ │
│ │ • Validation functions │ │
│ │ • API call helpers │ │
│ │ • Computed derivations │ │
│ │ • Persistence logic │ │
│ │ • Side effects │ │
│ └───────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ PUBLIC (Context API) │ │
│ │ │ │
│ │ • items (readonly) │ │
│ │ • count, total (computed) │ │
│ │ • add(), remove(), updateQuantity() │ │
│ │ • checkout() │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘ The context is the only way in or out. Consumers don’t know (or care) how the cart stores items, validates quantities, or persists data. They just call cart.add(product).
The Svelte 5 Mental Model
Feature boundaries in Svelte 5 are built on three core principles:
Context as Contract
The context object defines what a feature exposes. It’s a contract: “Here’s what you can read, here’s what you can do.” Everything else is implementation detail.
// This interface IS the public API
interface CartContext {
// What you can read
readonly items: CartItem[]
readonly count: number
readonly total: number
readonly isEmpty: boolean
// What you can do
add(product: Product): void
remove(productId: string): void
updateQuantity(productId: string, quantity: number): void
clear(): void
} The interface serves as documentation. A developer using the cart feature reads this and knows exactly what’s available.
Provider as Implementation
The provider component implements the contract. It creates state, defines logic, and exposes only what the context interface specifies:
<script lang="ts">
import { setContext } from 'svelte'
import type { Snippet } from 'svelte'
let { children }: { children: Snippet } = $props()
// Private - not in the context interface
let items = $state<CartItem[]>([])
function validateQuantity(quantity: number) {
return Math.max(1, Math.min(99, quantity))
}
// Public - matches the context interface
const cart: CartContext = {
get items() {
return items
},
get count() {
return items.reduce((sum, i) => sum + i.quantity, 0)
}
// ... rest of implementation
}
setContext('cart', cart)
</script>
{@render children()} The provider owns all the implementation complexity. Consumers see only the clean API.
Scoped by Component Tree
Context is automatically scoped to the component tree. Components inside the provider access the cart; components outside don’t. This scoping is explicit and visible in your layout structure:
<!-- src/routes/shop/+layout.svelte -->
<CartProvider>
<!-- Everything here can access the cart -->
{@render children()}
</CartProvider> Different sections can have different implementations. The marketing site doesn’t need a cart. The admin dashboard might need a different kind of cart. Context makes this natural.
Implementation
Let’s build a complete feature module step by step.
Step 1: Define the Contract
Start with the TypeScript interface. This forces you to think about the API before implementation:
// src/lib/features/cart/types.ts
export interface CartItem {
id: string
name: string
price: number
quantity: number
image?: string
}
export interface CartContext {
// State (readonly to consumers)
readonly items: readonly CartItem[]
readonly count: number
readonly subtotal: number
readonly isEmpty: boolean
// Computed
readonly formattedTotal: string
// Actions
add(product: { id: string; name: string; price: number; image?: string }): void
remove(productId: string): void
updateQuantity(productId: string, quantity: number): void
clear(): void
// Status
readonly isLoading: boolean
readonly error: string | null
} Notice that items returns readonly CartItem[]. This signals to TypeScript (and developers) that consumers shouldn’t mutate the array directly.
Step 2: Create the Context Key
Use a Symbol for collision-proof keys:
// src/lib/features/cart/context.ts
export const CART_KEY = Symbol('cart') Symbols ensure that even if another feature uses the string 'cart', there’s no collision.
Step 3: Build the Provider
The provider implements everything:
<!-- src/lib/features/cart/CartProvider.svelte -->
<script lang="ts">
import { setContext } from 'svelte'
import { browser } from '$app/environment'
import { CART_KEY } from './context'
import type { CartContext, CartItem } from './types'
import type { Snippet } from 'svelte'
interface Props {
children: Snippet
}
let { children }: Props = $props()
// ═══════════════════════════════════════════════════════════
// PRIVATE STATE
// ═══════════════════════════════════════════════════════════
let items = $state<CartItem[]>([])
let isLoading = $state(false)
let error = $state<string | null>(null)
// ═══════════════════════════════════════════════════════════
// PRIVATE HELPERS
// ═══════════════════════════════════════════════════════════
function findItem(id: string): CartItem | undefined {
return items.find((item) => item.id === id)
}
function findItemIndex(id: string): number {
return items.findIndex((item) => item.id === id)
}
function validateQuantity(quantity: number): number {
return Math.max(1, Math.min(99, quantity))
}
// ═══════════════════════════════════════════════════════════
// PERSISTENCE (Private)
// ═══════════════════════════════════════════════════════════
const STORAGE_KEY = 'shopping_cart'
function saveToStorage() {
if (browser) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
} catch (e) {
console.error('Failed to save cart:', e)
}
}
}
function loadFromStorage() {
if (browser) {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const parsed = JSON.parse(saved)
if (Array.isArray(parsed)) {
items = parsed
}
}
} catch (e) {
console.error('Failed to load cart:', e)
items = []
}
}
}
// Initialize from storage
loadFromStorage()
// ═══════════════════════════════════════════════════════════
// PUBLIC API (Context)
// ═══════════════════════════════════════════════════════════
const cartContext: CartContext = {
// Readonly state
get items() {
return items
},
get count() {
return items.reduce((sum, item) => sum + item.quantity, 0)
},
get subtotal() {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
},
get isEmpty() {
return items.length === 0
},
get formattedTotal() {
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(total / 100)
},
get isLoading() {
return isLoading
},
get error() {
return error
},
// Actions
add(product) {
error = null
const existing = findItem(product.id)
if (existing) {
const newQuantity = validateQuantity(existing.quantity + 1)
if (newQuantity !== existing.quantity) {
existing.quantity = newQuantity
saveToStorage()
}
} else {
items.push({
id: product.id,
name: product.name,
price: product.price,
image: product.image,
quantity: 1
})
saveToStorage()
}
},
remove(productId) {
error = null
const index = findItemIndex(productId)
if (index !== -1) {
items.splice(index, 1)
saveToStorage()
}
},
updateQuantity(productId, quantity) {
error = null
const item = findItem(productId)
if (item) {
if (quantity <= 0) {
this.remove(productId)
} else {
item.quantity = validateQuantity(quantity)
saveToStorage()
}
}
},
clear() {
error = null
items = []
saveToStorage()
}
}
setContext(CART_KEY, cartContext)
// ═══════════════════════════════════════════════════════════
// SIDE EFFECTS (Private)
// ═══════════════════════════════════════════════════════════
$effect(() => {
// Update document title with cart count
if (browser && cartContext.count > 0) {
const originalTitle = document.title.replace(/^\(\d+\)\s*/, '')
document.title = `(${cartContext.count}) ${originalTitle}`
}
})
</script>
{@render children()} Step 4: Create the Consumer Hook
Provide a typed way to access the context:
// src/lib/features/cart/useCart.ts
import { getContext, hasContext } from 'svelte'
import { CART_KEY } from './context'
import type { CartContext } from './types'
export function useCart(): CartContext {
if (!hasContext(CART_KEY)) {
throw new Error(
'useCart() must be called from a component inside <CartProvider>. ' +
'Make sure your component is wrapped with CartProvider in a parent layout.'
)
}
return getContext<CartContext>(CART_KEY)
}
export function useCartOptional(): CartContext | null {
return hasContext(CART_KEY) ? getContext<CartContext>(CART_KEY) : null
} The error message tells developers exactly what went wrong and how to fix it.
Step 5: Export the Public API
Control what’s publicly accessible:
// src/lib/features/cart/index.ts
// Provider component
export { default as CartProvider } from './CartProvider.svelte'
// Consumer hook
export { useCart, useCartOptional } from './useCart'
// Types for consumers
export type { CartContext, CartItem } from './types'
// Feature-specific components
export { default as CartDrawer } from './components/CartDrawer.svelte'
export { default as CartIcon } from './components/CartIcon.svelte'
export { default as AddToCartButton } from './components/AddToCartButton.svelte'
// Note: CART_KEY is NOT exported - it's an implementation detail Notice that CART_KEY isn’t exported. Consumers don’t need it; they use useCart().
Step 6: Use the Feature
<!-- src/routes/shop/+layout.svelte -->
<script lang="ts">
import { CartProvider } from '$lib/features/cart'
import type { Snippet } from 'svelte'
let { children }: { children: Snippet } = $props()
</script>
<CartProvider>
{@render children()}
</CartProvider> <!-- src/routes/shop/products/[id]/+page.svelte -->
<script lang="ts">
import { useCart, AddToCartButton } from '$lib/features/cart'
let { data } = $props()
const cart = useCart()
</script>
<h1>{data.product.name}</h1>
<p>{data.product.description}</p>
<AddToCartButton product={data.product} />
{#if !cart.isEmpty}
<p>You have {cart.count} items in your cart ({cart.formattedTotal})</p>
{/if} Replacing Global Stores
One of the most valuable applications of feature boundaries is replacing global stores with scoped context.
The Global Store Problem
Traditional Svelte stores are module-level singletons:
// stores/user.ts
import { writable } from 'svelte/store'
// This is created once when the module loads
export const user = writable<User | null>(null) This creates several issues:
SSR state leakage. On the server, module state persists across requests. User A’s data could appear in User B’s response.
Testing isolation. Tests share the same store instance, requiring careful cleanup between tests.
No natural scope. The store exists everywhere, even in parts of the app that don’t need it.
Context Alternative
// src/lib/features/auth/AuthProvider.svelte
<script lang="ts">
import { setContext } from 'svelte'
import type { Snippet } from 'svelte'
let { children }: { children: Snippet } = $props()
// State is created fresh for each provider instance
let user = $state<User | null>(null)
let isLoading = $state(true)
const auth = {
get user() { return user },
get isAuthenticated() { return user !== null },
get isLoading() { return isLoading },
async login(credentials: Credentials) {
isLoading = true
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(credentials)
})
user = await response.json()
} finally {
isLoading = false
}
},
logout() {
user = null
}
}
setContext('auth', auth)
// Initialize on mount
$effect(() => {
checkSession()
})
async function checkSession() {
try {
const response = await fetch('/api/auth/me')
if (response.ok) {
user = await response.json()
}
} finally {
isLoading = false
}
}
</script>
{@render children()} Benefits:
- SSR safe — Each request gets a fresh provider instance
- Testable — Provide mock context in tests
- Scoped — Auth only exists where you wrap with
AuthProvider
When Global Stores Are Still Appropriate
Global stores aren’t always wrong. Use them for:
Truly global, static data:
// App version, feature flags, build-time config
export const appConfig = {
version: __APP_VERSION__,
environment: import.meta.env.MODE
} Singletons that must be shared:
// WebSocket connection, analytics instance
export const analytics = new Analytics({ ... }) Cross-tree communication:
When siblings at different tree levels need to share state and there’s no common ancestor, a store might be appropriate. But consider whether restructuring the component tree could solve this with context instead.
Feature Isolation Patterns
Different Implementations, Same Interface
Different parts of your app might need the same concept implemented differently:
src/routes/
├── shop/
│ └── +layout.svelte # Standard cart
├── marketplace/
│ └── +layout.svelte # Multi-vendor cart
└── gift-shop/
└── +layout.svelte # Gift-enabled cart Each section provides its own cart implementation:
<!-- src/routes/gift-shop/+layout.svelte -->
<script lang="ts">
import { setContext } from 'svelte'
import type { Snippet } from 'svelte'
let { children }: { children: Snippet } = $props()
// Extended cart item with gift options
interface GiftCartItem {
id: string
name: string
price: number
quantity: number
giftWrap: boolean
giftMessage: string
recipientEmail: string | null
}
let items = $state<GiftCartItem[]>([])
const cart = {
get items() {
return items
},
get count() {
return items.reduce((sum, i) => sum + i.quantity, 0)
},
// Gift-specific add method
add(product: Product, giftOptions?: GiftOptions) {
items.push({
id: product.id,
name: product.name,
price: product.price,
quantity: 1,
giftWrap: giftOptions?.wrap ?? false,
giftMessage: giftOptions?.message ?? '',
recipientEmail: giftOptions?.recipientEmail ?? null
})
},
// Gift-specific method
updateGiftOptions(productId: string, options: Partial<GiftOptions>) {
const item = items.find((i) => i.id === productId)
if (item) {
if (options.wrap !== undefined) item.giftWrap = options.wrap
if (options.message !== undefined) item.giftMessage = options.message
if (options.recipientEmail !== undefined) item.recipientEmail = options.recipientEmail
}
},
remove(productId: string) {
/* ... */
},
clear() {
/* ... */
}
}
// Same key, different implementation
setContext('cart', cart)
</script>
{@render children()} Components using getContext('cart') get the appropriate implementation based on their location in the tree.
Composing Dependent Features
Features often depend on each other. Notifications might need auth to know who to notify. Cart might need user preferences for currency formatting.
<!-- NotificationProvider.svelte -->
<script lang="ts">
import { setContext, getContext, hasContext } from 'svelte'
import type { Snippet } from 'svelte'
let { children }: { children: Snippet } = $props()
// Depend on auth feature
interface AuthContext {
user: User | null
isAuthenticated: boolean
}
const auth = hasContext('auth') ? getContext<AuthContext>('auth') : null
let notifications = $state<Notification[]>([])
const notificationContext = {
get items() {
return notifications
},
get unreadCount() {
return notifications.filter((n) => !n.read).length
},
async refresh() {
if (!auth?.isAuthenticated) {
notifications = []
return
}
const response = await fetch('/api/notifications')
notifications = await response.json()
},
markAsRead(id: string) {
const notification = notifications.find((n) => n.id === id)
if (notification) {
notification.read = true
// Also sync to server...
}
}
}
setContext('notifications', notificationContext)
// React to auth changes
$effect(() => {
if (auth?.isAuthenticated) {
notificationContext.refresh()
} else {
notifications = []
}
})
</script>
{@render children()} Provider Nesting Order Matters
When features depend on each other, the order of provider nesting determines what’s available:
<!-- ✅ Correct: Auth is available when Notifications initializes -->
<AuthProvider>
<NotificationProvider>
{@render children()}
</NotificationProvider>
</AuthProvider>
<!-- ❌ Wrong: Notifications can't access auth - it doesn't exist yet -->
<NotificationProvider>
<AuthProvider>
{@render children()}
</AuthProvider>
</NotificationProvider> Common Mistakes and Anti-Patterns
Lets look at some common pitfalls when designing feature boundaries with context, along with explanations and fixes.
1: Exposing Raw State
// ❌ Consumers can mutate items directly
const cart = {
items, // Direct reference to $state array
add(product) {
items.push(product)
}
} Why it breaks: Consumers can bypass your methods and mutate state directly, breaking validation and side effects.
Fix: Return through getters:
// ✅ Items are accessed through getter
const cart = {
get items() {
return items
},
add(product) {
items.push(product)
}
} 2: Circular Dependencies
<!-- ❌ Cart depends on Notifications, Notifications depends on Cart -->
<CartProvider>
<!-- Cart shows notifications when items are added -->
<NotificationProvider>
<!-- Notifications need cart context to show "View Cart" -->
{@render children()}
</NotificationProvider>
</CartProvider> Why it breaks: Circular dependencies create initialization order problems and tangled logic.
Fix: Extract the shared concern:
<!-- ✅ Both features communicate through events or a mediator -->
<EventBusProvider>
<CartProvider>
<NotificationProvider>
{@render children()}
</NotificationProvider>
</CartProvider>
</EventBusProvider> Or have notifications be purely passive (they don’t need to know about cart):
<!-- Cart provider uses notification context to show messages -->
<NotificationProvider>
<CartProvider>
{@render children()}
</CartProvider>
</NotificationProvider> 3: Leaking Implementation Details
// index.ts
export { CART_KEY } from './context' // ❌ Exposing internal key
export { validateQuantity } from './helpers' // ❌ Exposing internal helper
export { cartItems } from './CartProvider.svelte' // ❌ Exposing raw state Why it breaks: Consumers start depending on internals. When you refactor, you break their code.
Fix: Export only the public API:
// index.ts
export { default as CartProvider } from './CartProvider.svelte'
export { useCart } from './useCart'
export type { CartContext, CartItem } from './types'
// Nothing else! 4: Over-Encapsulating
// ❌ Too many tiny features
<ThemeProvider>
<FontSizeProvider>
<ColorSchemeProvider>
<ReducedMotionProvider>
<LanguageProvider>
{@render children()}
</LanguageProvider>
</ReducedMotionProvider>
</ColorSchemeProvider>
</FontSizeProvider>
</ThemeProvider> Why it’s problematic: These are all related preferences. Splitting them creates unnecessary complexity.
Fix: Group related state into cohesive features:
// ✅ One provider for user preferences
<PreferencesProvider>
{@render children()}
</PreferencesProvider> Performance and Scaling Considerations
Context Lookup Cost
Context lookup is O(1)—it’s just a Map access. The cost is negligible compared to rendering. Don’t avoid context for performance reasons.
Provider Rendering
Providers themselves don’t re-render when their internal state changes. Only consumers that read changed values re-render:
<script>
let items = $state([])
let theme = $state('light')
setContext('app', {
get items() {
return items
},
get theme() {
return theme
}
})
</script>
<!-- This component doesn't re-render when items or theme change -->
<!-- Only children reading those values re-render -->
{@render children()} Large Feature Trees
For features with many consumers (like a global theme), Svelte’s granular reactivity ensures only affected components update:
<!-- Component A only reads theme -->
<script>
const app = getContext('app')
</script>
<div class="theme-{app.theme}">...</div>
<!-- Component B only reads items -->
<script>
const app = getContext('app')
</script>
{#each app.items as item}...{/each} When theme changes, Component A re-renders. Component B doesn’t—it only reads items.
When NOT to Use Feature Boundaries
Simple, Local State
Don’t wrap everything in context:
<!-- ✅ Just use local state -->
<script>
let email = $state('')
let password = $state('')
</script>
<!-- ❌ Overkill for local form state -->
<FormProvider>
<FormField name="email" />
<FormField name="password" />
</FormProvider>
<input bind:value={email} />
<input bind:value={password} /> Use context when state needs to be shared across multiple components in a subtree.
Truly Global Singletons
Some things really are global:
// These are fine as module-level exports
export const analytics = new AnalyticsClient()
export const logger = new Logger()
export const featureFlags = await loadFeatureFlags() If it’s a singleton that never changes based on component tree position, a module export is simpler than context.
Premature Abstraction
Don’t create feature boundaries until you need them:
// ❌ Starting with complex architecture for a simple feature
src/lib/features/counter/
├── CounterProvider.svelte
├── context.ts
├── types.ts
├── useCounter.ts
└── index.ts
// ✅ Start simple, extract when complexity grows
<script>
let count = $state(0)
</script> Extract to a feature module when the feature grows complex enough to benefit from encapsulation.
Testing Feature Modules
Feature boundaries make testing straightforward:
// cart.test.ts
import { render, screen } from '@testing-library/svelte'
import { setContext } from 'svelte'
import CartSummary from './components/CartSummary.svelte'
import { CART_KEY } from './context'
import type { CartContext } from './types'
function createMockCart(overrides: Partial<CartContext> = {}): CartContext {
return {
items: [],
count: 0,
subtotal: 0,
isEmpty: true,
formattedTotal: '$0.00',
isLoading: false,
error: null,
add: vi.fn(),
remove: vi.fn(),
updateQuantity: vi.fn(),
clear: vi.fn(),
...overrides
}
}
describe('CartSummary', () => {
it('shows empty message when cart is empty', () => {
render(CartSummary, {
context: new Map([[CART_KEY, createMockCart()]])
})
expect(screen.getByText('Your cart is empty')).toBeInTheDocument()
})
it('shows item count and total', () => {
render(CartSummary, {
context: new Map([
[
CART_KEY,
createMockCart({
count: 3,
isEmpty: false,
formattedTotal: '$29.99'
})
]
])
})
expect(screen.getByText(/3 items/)).toBeInTheDocument()
expect(screen.getByText('$29.99')).toBeInTheDocument()
})
it('calls clear when button clicked', async () => {
const clear = vi.fn()
render(CartSummary, {
context: new Map([
[
CART_KEY,
createMockCart({
isEmpty: false,
clear
})
]
])
})
await screen.getByRole('button', { name: /clear/i }).click()
expect(clear).toHaveBeenCalled()
})
}) No global state to mock or reset. Just provide the context the component expects.
Conclusion
Feature boundaries transform how you think about application architecture. Instead of a tangle of global stores and scattered utilities, you get self-contained modules with clear interfaces. The provider owns the complexity; consumers see only the clean API.
This pattern shines as applications grow. When the cart needs server sync, you change the provider—consumers don’t know or care. When you need a different cart for the gift shop, you create a new provider—same interface, different implementation. When you test, you provide mock context—no global state to manage.
The key insight is that context isn’t just about passing data through component trees. It’s about defining contracts between parts of your application. The context interface says “here’s what this feature does.” The provider says “here’s how it does it.” Consumers say “I don’t need to know—I just use the API.”
Start simple. Extract features when complexity justifies it. Let the boundaries emerge from real needs, not theoretical architecture. When you do extract, the patterns in this article will serve you well: typed interfaces as contracts, providers as implementations, hooks for consumption, and explicit exports for control.
Key Takeaways
Context defines the public API. The context object is the contract between a feature and its consumers. Everything not in the context is implementation detail.
Providers own implementation. Raw state, validation, persistence, side effects—all live inside the provider. Consumers interact only through the context API.
Replace global stores with context. Context provides scoped, SSR-safe, testable state. Use it instead of module-level stores for feature state.
Different implementations, same interface. Different parts of your app can provide different implementations of the same context key. Components get the implementation from their nearest ancestor.
Dependent features need ordering. When features depend on each other, nesting order matters. The outer provider initializes first.
Export only the public API. Control what consumers can access. Don’t export internal keys, helpers, or raw state.
Test with mock context. Feature boundaries make testing easy. Provide mock context that matches the interface, and test components in isolation.
What’s Next
Ready to go deeper? The next article, Designing Context API’s, shows you how to craft robust, ergonomic, and future-proof context interfaces for your own features. You’ll learn naming conventions, API design patterns, and how to balance flexibility with encapsulation—so your context modules are a joy to use and easy to maintain.
See Also
Related Articles
- Class-Based Context — Organize complex features with TypeScript classes
- Reactive Context Patterns — Getters,
$derived, and$effectin context - Context Best Practices — Design and organization patterns
- Authentication System — Complete auth feature example
- Shopping Cart — E-commerce cart feature example
Official Documentation
- Svelte Context — Official context documentation
- $state — Reactive state rune