Debugging Reactive State Like a Pro

Debugging reactive applications can be frustrating. Traditional console.log statements only show you a snapshot of your data at the moment you added them—they don’t tell you when or why values change. You end up littering your code with logs, guessing at what’s happening, and still missing the critical state changes that cause bugs.

Svelte 5’s $inspect rune solves this problem elegantly. It’s like console.log with superpowers: it automatically re-runs whenever any of its tracked values change, shows you exactly what changed and when, and even provides stack traces to pinpoint the source of state mutations.

This tutorial covers everything from basic usage to advanced debugging patterns, including the powerful .with() callback method and the $inspect.trace() rune for tracing effect execution.


What Is $inspect?

$inspect is a development-only rune that logs reactive values to the console whenever they change. Unlike console.log, which executes once, $inspect continuously watches your data and reports every mutation.

Basic Usage

Here’s the simplest example:

<script>
	let count = $state(0)
	let message = $state('hello')

	$inspect(count, message) // Logs whenever count OR message changes
</script>

<button onclick={() => count++}>Increment</button>
<input bind:value={message} />

What happens:

  1. On initial render, $inspect logs: init 0 "hello"
  2. Click the button → logs: update 1 "hello" (with stack trace)
  3. Type in the input → logs: update 1 "hello!" (with stack trace)

The stack trace is the killer feature—it shows you exactly which line of code triggered the state change.

Why Use $inspect Instead of console.log?

console.log$inspect
Runs once at that lineRe-runs on every change
Shows value at that momentShows value when it changes
No context about what triggered itIncludes stack trace to the source
Stays in production buildsAutomatically removed in production
Shallow inspectionDeep reactive tracking

Understanding the Output

$inspect output follows a predictable pattern:

init <value1> <value2> ...     // First run (initialization)
update <value1> <value2> ...   // Subsequent runs (state changed)

The values are logged in the same order you passed them to $inspect.


Deep Reactivity Tracking

One of $inspect’s most powerful features is deep tracking. It doesn’t just watch top-level variables—it tracks changes to nested properties within objects and arrays.

Tracking Nested Object Changes

<script>
	let user = $state({
		name: 'Alice',
		preferences: {
			theme: 'dark',
			notifications: true
		}
	})

	$inspect(user) // Tracks ALL nested changes
</script>

<button onclick={() => (user.name = 'Bob')}> Change Name </button>

<button onclick={() => (user.preferences.theme = 'light')}> Change Theme </button>

<button onclick={() => (user.preferences.notifications = false)}> Toggle Notifications </button>

Output when clicking each button:

init { name: 'Alice', preferences: { theme: 'dark', notifications: true } }
update { name: 'Bob', preferences: { theme: 'dark', notifications: true } }
update { name: 'Bob', preferences: { theme: 'light', notifications: true } }
update { name: 'Bob', preferences: { theme: 'light', notifications: false } }

Every nested mutation is caught and logged.

Tracking Array Mutations

Arrays are tracked with the same deep precision:

<script>
	let todos = $state([
		{ id: 1, text: 'Learn Svelte 5', done: false },
		{ id: 2, text: 'Build an app', done: false }
	])

	$inspect(todos)
</script>

<button onclick={() => todos.push({ id: 3, text: 'Deploy', done: false })}> Add Todo </button>

<button onclick={() => (todos[0].done = true)}> Complete First </button>

<button onclick={() => todos.splice(0, 1)}> Remove First </button>

Every push, splice, property change, or other mutation triggers the inspect callback.

Watching Multiple Values

You can pass multiple values to track them together:

<script>
	let firstName = $state('Alice')
	let lastName = $state('Chen')
	let age = $state(30)

	// Track all three in one inspect call
	$inspect(firstName, lastName, age)

	// Or use an object for labeled output
	$inspect({ firstName, lastName, age })
</script>

Labeled output (using an object):

init { firstName: 'Alice', lastName: 'Chen', age: 30 }
update { firstName: 'Alice', lastName: 'Smith', age: 30 }

Using an object wrapper makes the output much more readable when tracking multiple values.


Custom Callbacks with .with()

The default console.log behavior is useful, but sometimes you need more control. The .with() method lets you define exactly what happens when values change.

Basic .with() Usage

<script>
	let count = $state(0)

	$inspect(count).with((type, count) => {
		if (type === 'init') {
			console.log('Count initialized to:', count)
		} else {
			console.log('Count updated to:', count)
		}
	})
</script>

<button onclick={() => count++}>Increment</button>

The callback receives:

  1. type: Either "init" (first run) or "update" (subsequent runs)
  2. Remaining arguments: The values you passed to $inspect, in order

Triggering the Debugger

One powerful pattern is triggering the browser’s debugger on state changes:

<script>
	let criticalValue = $state(null)

	$inspect(criticalValue).with((type, value) => {
		if (type === 'update' && value === null) {
			// Something unexpectedly nullified our value!
			debugger // Pause execution here
		}
	})
</script>

When the debugger pauses, you can examine the call stack, local variables, and step through code to understand what happened.

Console Trace for Call Stacks

For detailed call stack information without pausing execution:

<script>
	let items = $state([])

	$inspect(items).with((type, items) => {
		if (type === 'update') {
			console.trace('Items changed:', items.length, 'items')
		}
	})
</script>

console.trace() prints the call stack along with your message—perfect for tracking down where mutations originate.

Conditional Logging

Log only when specific conditions are met:

<script>
	let formData = $state({
		email: '',
		password: '',
		confirmPassword: ''
	})

	$inspect(formData).with((type, data) => {
		// Only log password changes (be careful with sensitive data!)
		if (type === 'update') {
			if (data.password !== data.confirmPassword) {
				console.warn('Password mismatch detected')
			}
		}
	})
</script>

Logging to External Services

During development, you might want to send state changes to an external logging service:

<script>
	let appState = $state({
		/* ... */
	})

	$inspect(appState).with((type, state) => {
		// Send to development logging service
		fetch('/dev-api/log', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify({
				type,
				state: $state.snapshot(state),
				timestamp: Date.now()
			})
		})
	})
</script>

Counting Updates

Track how many times a value changes:

<script>
	let data = $state({
		/* ... */
	})
	let updateCount = 0

	$inspect(data).with((type) => {
		if (type === 'update') {
			updateCount++
			console.log(`Update #${updateCount}`)
		}
	})
</script>

This pattern helps identify excessive re-renders or unexpected update frequency.


Function Tracing with $inspect.trace()

Added in Svelte 5.14, $inspect.trace() is a specialized tool for understanding why effects and derived values re-run. Instead of watching specific values, it traces the reactive dependencies of the surrounding function.

Basic $inspect.trace() Usage

<script>
	let firstName = $state('Alice')
	let lastName = $state('Chen')
	let age = $state(30)

	$effect(() => {
		$inspect.trace() // Must be the FIRST statement

		console.log(`${firstName} ${lastName} is ${age} years old`)
	})
</script>

<button onclick={() => (firstName = 'Bob')}>Change First Name</button>
<button onclick={() => (lastName = 'Smith')}>Change Last Name</button>
<button onclick={() => age++}>Increment Age</button>

When you click “Change First Name”, the console shows:

<effect> — $effect {
  firstName: 'Alice' -> 'Bob'
}

This tells you exactly which reactive dependency triggered the effect to re-run.

Adding Labels for Clarity

When you have multiple effects, labels help identify which one is running:

<script>
	let x = $state(0)
	let y = $state(0)

	$effect(() => {
		$inspect.trace('Position Effect')
		updatePosition(x, y)
	})

	$effect(() => {
		$inspect.trace('Validation Effect')
		validateCoordinates(x, y)
	})
</script>

Output:

Position Effect — $effect {
  x: 0 -> 1
}
Validation Effect — $effect {
  x: 0 -> 1
}

Tracing Derived Values

$inspect.trace() also works inside $derived.by():

<script>
	let items = $state([
		{ name: 'Apple', price: 1.5, quantity: 3 },
		{ name: 'Banana', price: 0.75, quantity: 5 }
	])
	let taxRate = $state(0.08)

	let total = $derived.by(() => {
		$inspect.trace('Total Calculation')

		const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
		return subtotal * (1 + taxRate)
	})
</script>

When taxRate changes:

Total Calculation — $derived {
  taxRate: 0.08 -> 0.10
}

When an item’s price changes:

Total Calculation — $derived {
  items[0].price: 1.5 -> 2
}

Important: Placement Rules

`$inspect.trace()` Must Be the First Statement

$inspect.trace() only works when it is the very first line of the function body. If any other statement runs before it — even a variable declaration — Svelte cannot set up the trace correctly and will throw an error.

$inspect.trace() must be the first statement in the function body:

<script>
	$effect(() => {
		$inspect.trace() // PREFERRED: first statement

		// ... rest of the effect
	})

	$effect(() => {
		const something = 'value'
		$inspect.trace() // AVOID: not the first statement
	})
</script>

Real-World Debugging Patterns

Pattern 1: Form State Debugging

Debug complex form state with validation:

<script>
	let form = $state({
		email: '',
		password: '',
		confirmPassword: '',
		acceptTerms: false
	})

	let errors = $state({
		email: null,
		password: null,
		confirmPassword: null,
		acceptTerms: null
	})

	let touched = $state({
		email: false,
		password: false,
		confirmPassword: false,
		acceptTerms: false
	})

	// Debug the entire form state
	$inspect({ form, errors, touched }).with((type, data) => {
		if (type === 'update') {
			console.group('Form State Change')
			console.log('Form:', data.form)
			console.log('Errors:', data.errors)
			console.log('Touched:', data.touched)
			console.groupEnd()
		}
	})

	// Validate on changes
	$effect(() => {
		$inspect.trace('Validation Effect')

		// Email validation
		if (touched.email) {
			errors.email = !form.email.includes('@') ? 'Invalid email' : null
		}

		// Password validation
		if (touched.password) {
			errors.password = form.password.length < 8 ? 'Password must be at least 8 characters' : null
		}

		// Confirm password validation
		if (touched.confirmPassword) {
			errors.confirmPassword =
				form.password !== form.confirmPassword ? 'Passwords do not match' : null
		}
	})
</script>

Pattern 2: API Request State Machine

Track the full lifecycle of async operations:

<script>
	let requestState = $state({
		status: 'idle', // 'idle' | 'loading' | 'success' | 'error'
		data: null,
		error: null,
		lastFetchedAt: null
	})

	$inspect(requestState).with((type, state) => {
		const statusEmoji = {
			idle: '⏸️',
			loading: '',
			success: '',
			error: ''
		}

		console.log(
			`${statusEmoji[state.status]} Request state: ${state.status}`,
			state.data ? `(${JSON.stringify(state.data).slice(0, 50)}...)` : ''
		)

		if (state.status === 'error') {
			console.error('Request failed:', state.error)
		}
	})

	async function fetchData() {
		requestState.status = 'loading'
		requestState.error = null

		try {
			const response = await fetch('/api/data')
			if (!response.ok) error(`HTTP ${response.status}`)

			requestState.data = await response.json()
			requestState.status = 'success'
			requestState.lastFetchedAt = new Date().toISOString()
		} catch (error) {
			requestState.error = error.message
			requestState.status = 'error'
		}
	}
</script>

Pattern 3: Shopping Cart Debugging

Track every mutation in a shopping cart:

<script>
	let cart = $state({
		items: [],
		couponCode: null,
		discountApplied: false
	})

	$inspect(cart).with((type, cart) => {
		if (type === 'init') {
			console.log('🛒 Cart initialized')
			return
		}

		const itemCount = cart.items.reduce((sum, item) => sum + item.quantity, 0)
		const total = cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0)

		console.log(`🛒 Cart updated:
  Items: ${itemCount}
  Total: $${total.toFixed(2)}
  Coupon: ${cart.couponCode || 'none'}
  Discount: ${cart.discountApplied ? 'applied' : 'not applied'}`)
	})

	function addItem(product) {
		const existing = cart.items.find((item) => item.id === product.id)

		if (existing) {
			existing.quantity++
		} else {
			cart.items.push({ ...product, quantity: 1 })
		}
	}

	function removeItem(productId) {
		const index = cart.items.findIndex((item) => item.id === productId)
		if (index !== -1) {
			cart.items.splice(index, 1)
		}
	}

	function applyCoupon(code) {
		cart.couponCode = code
		cart.discountApplied = code === 'SAVE20'
	}
</script>

Pattern 4: Component Lifecycle Debugging

Understand component mount/unmount behavior:

<script>
	import { onMount, onDestroy } from 'svelte'

	let { userId } = $props()

	let componentState = $state({
		mounted: false,
		dataLoaded: false,
		renderCount: 0
	})

	$inspect({ userId, componentState }).with((type, data) => {
		console.log(`[User ${data.userId}] ${type}:`, data.componentState)
	})

	onMount(() => {
		componentState.mounted = true
		console.log(`[User ${userId}] Component mounted`)
	})

	onDestroy(() => {
		console.log(`[User ${userId}] Component destroying`)
	})

	$effect(() => {
		componentState.renderCount++
	})
</script>

Pattern 5: Derived Value Chain Debugging

Track how derived values cascade:

<script>
	let basePrice = $state(100)
	let quantity = $state(1)
	let taxRate = $state(0.08)
	let discountPercent = $state(0)

	let subtotal = $derived.by(() => {
		$inspect.trace('Subtotal')
		return basePrice * quantity
	})

	let discount = $derived.by(() => {
		$inspect.trace('Discount')
		return subtotal * (discountPercent / 100)
	})

	let taxableAmount = $derived.by(() => {
		$inspect.trace('Taxable Amount')
		return subtotal - discount
	})

	let tax = $derived.by(() => {
		$inspect.trace('Tax')
		return taxableAmount * taxRate
	})

	let total = $derived.by(() => {
		$inspect.trace('Total')
		return taxableAmount + tax
	})

	// Also log the final values
	$inspect({ subtotal, discount, taxableAmount, tax, total })
</script>

When you change quantity, you’ll see the entire cascade:

Subtotal — $derived { quantity: 1 -> 2 }
Discount — $derived { subtotal: 100 -> 200 }
Taxable Amount — $derived { subtotal: 100 -> 200, discount: 0 -> 0 }
Tax — $derived { taxableAmount: 100 -> 200 }
Total — $derived { taxableAmount: 100 -> 200, tax: 8 -> 16 }

Common Pitfalls and Solutions

Pitfall 1: Forgetting It’s Development-Only

Remove `$inspect` Before Merging to Main

$inspect is stripped from production builds, so there is no runtime cost in production. However, it logs values to the console during development and staging — including any data you pass it. Treat it like a debugger statement: remove it before it reaches a shared environment.

$inspect is automatically removed in production builds. Don’t rely on it for production logging.

<script>
	let data = $state(null)

	// AVOID: Won't work in production
	$inspect(data).with((type, data) => {
		sendToAnalytics(data) // This never runs in production!
	})

	// PREFERRED: Use a proper logging solution for production
	$effect(() => {
		if (import.meta.env.DEV) {
			console.log('Data changed:', data)
		}
		// Production logging handled elsewhere
	})
</script>

Pitfall 2: Inspect in Wrong Scope

$inspect must be in a reactive context (component script, .svelte.js file, or effect):

<script>
	let count = $state(0)

	// PREFERRED: top-level of component script
	$inspect(count)

	function handleClick() {
		// AVOID: Doesn't work: inside regular function
		$inspect(count) // This won't track changes!

		count++
	}
</script>

Pitfall 3: Too Many Inspect Calls

Excessive $inspect calls can flood your console:

<script>
	let items = $state([
		/* 1000 items */
	])

	// AVOID: Logs on EVERY nested change
	$inspect(items)

	// PREFERRED: inspect specific things
	$inspect(items.length) // Only track array length

	// PREFERRED: use conditional logging
	$inspect(items).with((type, items) => {
		if (items.length > 100) {
			console.warn('Large array detected:', items.length)
		}
	})
</script>

Pitfall 4: Forgetting $inspect.trace() Placement

$inspect.trace() must be the first statement:

<script>
	$effect(() => {
		// AVOID: something before $inspect.trace()
		const now = Date.now()
		$inspect.trace()
	})

	$effect(() => {
		// PREFERRED: $inspect.trace() is first
		$inspect.trace()
		const now = Date.now()
	})
</script>

Pitfall 5: Logging Proxied Objects

Reactive objects are Proxies, which can look confusing in the console:

<script>
	let user = $state({ name: 'Alice' })

	// Console might show: Proxy { name: 'Alice' }
	$inspect(user)

	// PREFERRED: For cleaner output, use $state.snapshot
	$inspect($state.snapshot(user))
</script>

Pitfall 6: Infinite Loops with .with()

Never Mutate Reactive State Inside `.with()`

Writing to a $state variable inside a .with() callback will trigger the reactive graph, which re-runs $inspect, which calls .with() again — an infinite loop. Use plain (non-reactive) variables for any accumulation you need inside the callback.

Be careful not to trigger state changes inside .with():

<script>
	let count = $state(0)
	let history = $state([])

	// AVOID: Danger: This creates an infinite loop!
	$inspect(count).with((type, count) => {
		history.push(count) // Mutates state -> triggers inspect -> mutates state...
	})

	// PREFERRED: Safe: Track count changes without mutating reactive state
	let historyArray = [] // Non-reactive
	$inspect(count).with((type, count) => {
		historyArray.push(count)
		console.log('History:', historyArray)
	})
</script>

Performance Considerations

$inspect Is Zero-Cost in Production

The Svelte compiler completely removes $inspect calls from production builds. There’s no runtime overhead—it’s as if the code never existed.

// Development build includes $inspect
$inspect(count)

// Production build: the line is gone entirely

Deep Tracking Has Overhead in Development

While $inspect is free in production, deep tracking does have development overhead. For very large data structures, consider:

  1. Inspecting specific properties instead of entire objects
  2. Using conditional callbacks that only log when necessary
  3. Removing inspect calls when not actively debugging
<script>
	let hugeDataset = $state([
		/* thousands of items */
	])

	// AVOID: Heavy: tracks every nested change
	$inspect(hugeDataset)

	// PREFERRED: Lighter: only track length
	$inspect(hugeDataset.length)

	// PREFERRED: conditional logging
	$inspect(hugeDataset).with((type, data) => {
		// Only log every 10th update
		if (Math.random() < 0.1) {
			console.log('Sample update:', data.slice(0, 5))
		}
	})
</script>

Integration with Browser DevTools

Leveraging the Stack Trace

By default, $inspect updates include a stack trace. Click on the trace in your browser’s console to jump directly to the code that triggered the change.

Using the debugger Statement

Pause execution exactly when state changes:

<script>
	let criticalValue = $state('normal')

	$inspect(criticalValue).with((type, value) => {
		if (value === 'error') {
			debugger // Execution pauses here
		}
	})
</script>

When paused, you can:

  • Examine the call stack
  • Inspect local and global variables
  • Step through code line by line
  • Set watch expressions

Console Filtering

Use console groups and labels to filter $inspect output:

<script>
	let auth = $state({ user: null, token: null })
	let ui = $state({ theme: 'dark', sidebar: true })

	$inspect(auth).with((type, auth) => {
		console.groupCollapsed('[Auth]', type)
		console.log(auth)
		console.groupEnd()
	})

	$inspect(ui).with((type, ui) => {
		console.groupCollapsed('[UI]', type)
		console.log(ui)
		console.groupEnd()
	})
</script>

Now you can collapse/expand different categories in the console.


Quick Reference

Basic Inspect

// Single value
$inspect(count)

// Multiple values
$inspect(count, name, items)

// Object for labeled output
$inspect({ count, name, items })

Custom Callback

$inspect(value).with((type, value) => {
	// type is 'init' or 'update'
	// value is the current value

	console.log(type, value)
	// or: debugger
	// or: console.trace()
	// or: custom logic
})

Function Tracing

$effect(() => {
	$inspect.trace() // No label
	// ... effect code
})

$effect(() => {
	$inspect.trace('My Effect Label') // With label
	// ... effect code
})

let value = $derived.by(() => {
	$inspect.trace('Derived Calculation')
	return computeValue()
})

Common Patterns

// Trigger debugger on specific condition
$inspect(value).with((type, v) => {
	if (v === problematicValue) debugger
})

// Count updates
let updates = 0
$inspect(value).with((type) => {
	if (type === 'update') console.log(`Update #${++updates}`)
})

// Log with timestamp
$inspect(value).with((type, v) => {
	console.log(`[${new Date().toISOString()}]`, type, v)
})

// Grouped logging
$inspect(value).with((type, v) => {
	console.group(`State ${type}`)
	console.log(v)
	console.trace()
	console.groupEnd()
})

Key Takeaways

The $inspect rune transforms debugging from guesswork into precision:

  1. $inspect(values) watches reactive state — Automatically logs whenever any tracked value changes, with deep reactivity support.

  2. .with() customizes the callback — Replace the default console.log with any logic: debugger statements, conditional logging, or custom formatting.

  3. $inspect.trace() traces dependencies — See exactly which reactive values caused an effect or derived to re-run.

  4. Zero production overhead$inspect is completely removed from production builds.

Remove `$inspect` Before Merging to Main

Remove $inspect calls before committing to main. The compiler strips them from production builds, so there is no runtime overhead — but leaving them in source means sensitive values are logged during development and staging, which is a data exposure risk.

  1. Deep tracking catches everything — Nested object and array mutations are automatically detected.

  2. Stack traces pinpoint sources — Click through to the exact line that triggered the state change.

With these tools, you’ll spend less time hunting for bugs and more time building features. The reactive debugging experience in Svelte 5 is genuinely delightful once you master it.


See Also

Official Documentation

External Resources