Script Modules: Beyond Component Instances

Every Svelte component you’ve written so far has an instance script—the <script> block that runs for each component instance. But what if you need code that runs once for the entire component type, not per instance? What if you want to share state or utilities across all instances of a component?

Enter script modules—Svelte’s mechanism for component-level code sharing. In Svelte 5, the syntax evolved from <script context="module"> to the cleaner <script module>, but the concept remains powerful: code that executes once when the component module loads, shared across all instances.

This tutorial explores script modules comprehensively: their syntax, use cases, limitations, and how they integrate with Svelte 5’s reactive system.


Chapter 1: Understanding Script Modules

1.1 Instance Scripts vs. Module Scripts

Instance Scripts (<script>):

  • Run once per component instance
  • Have access to props, state, and lifecycle
  • Can be reactive with runes like $state, $derived, $effect

Module Scripts (<script module>):

  • Run once when the component module is first loaded
  • Shared across all instances of the component
  • Cannot access instance-specific data (props, state)
  • Execute in module scope, not component scope
<!-- Component.svelte -->
<script module>
	// This runs once for the entire component type
	console.log('Component module loaded')

	// Shared across all instances
	let instanceCount = 0

	export function getInstanceCount() {
		return instanceCount
	}
</script>

<script>
	// This runs for each component instance
	console.log('New instance created')

	// Access module-level function
	const count = getInstanceCount()
</script>

1.2 The Execution Model

Module scripts execute during the module loading phase, before any component instances are created:

<!-- Logger.svelte -->
<script module>
	// ✅ Runs immediately when module loads
	console.log('Logger module initialized')

	// Module-level state
	let logHistory = []

	export function log(message) {
		logHistory.push({ message, timestamp: Date.now() })
		console.log(`[${new Date().toISOString()}] ${message}`)
	}

	export function getLogHistory() {
		return [...logHistory]
	}
</script>

<script>
	let { componentName } = $props()

	// Use module function
	log(`${componentName} component mounted`)
</script>

1.3 Access Rules and SSR Lifecycle

  • Module → Instance: Declarations in the module script are available to the instance script and markup because they share the same compiled module scope. They are not reactive to instance state—treat them like regular module-level variables.
  • Instance → Module: Module scripts cannot see instance-only values (props, runes) because those are created per instance.
  • Server considerations: In SvelteKit SSR, module state is shared across requests unless you explicitly isolate it (e.g., by creating fresh state inside a request-scoped module). Avoid storing per-request data in module scope.
  • Environment guards: Module scripts run wherever the component module loads. Use import { browser } from '$app/environment' (SvelteKit) or feature checks before touching window/document.

Chapter 2: Common Use Cases for Script Modules

2.1 Shared Utilities and Constants

Module scripts excel at providing utilities that don’t need per-instance state:

<!-- DateUtils.svelte -->
<script module>
	// Constants shared across instances
	export const DATE_FORMATS = {
		SHORT: 'MM/dd/yyyy',
		LONG: 'MMMM dd, yyyy',
		ISO: 'yyyy-MM-dd'
	}

	// Utility functions
	export function formatDate(date, format = DATE_FORMATS.SHORT) {
		// Implementation...
		return new Intl.DateTimeFormat('en-US').format(date)
	}

	export function isWeekend(date) {
		const day = date.getDay()
		return day === 0 || day === 6
	}

	export function addDays(date, days) {
		const result = new Date(date)
		result.setDate(result.getDate() + days)
		return result
	}
</script>

<script>
	let { date } = $props()

	// Use shared utilities
	$: formatted = formatDate(date)
	$: isWeekendDay = isWeekend(date)
</script>

2.2 Instance Tracking and Analytics

Track component usage across your application:

<!-- Button.svelte -->
<script module>
	let clickCount = 0
	let instances = new Set()

	export function recordClick(componentId) {
		clickCount++
		console.log(`Button clicked (total: ${clickCount})`)
	}

	export function registerInstance(id) {
		instances.add(id)
	}

	export function unregisterInstance(id) {
		instances.delete(id)
	}

	export function getStats() {
		return {
			totalClicks: clickCount,
			activeInstances: instances.size
		}
	}
</script>

<script>
	let { id, children, ...props } = $props()

	// Register this instance
	$effect(() => {
		registerInstance(id)
		return () => unregisterInstance(id)
	})

	function handleClick() {
		recordClick(id)
		// ... other click logic
	}
</script>

<button onclick={handleClick} {...props}>
	{@render children?.()}
</button>

2.3 Global Component State Management

For component-specific global state that doesn’t belong in a store:

<!-- ThemeProvider.svelte -->
<script module>
	// Global theme state for all ThemeProvider instances
	let currentTheme = 'light'
	let subscribers = new Set()

	export function setTheme(theme) {
		currentTheme = theme
		// Notify all subscribers
		subscribers.forEach((callback) => callback(theme))
	}

	export function getTheme() {
		return currentTheme
	}

	export function subscribeToTheme(callback) {
		subscribers.add(callback)
		return () => subscribers.delete(callback)
	}
</script>

<script>
	let { children } = $props()

	// Subscribe to theme changes
	$effect(() => {
		const unsubscribe = subscribeToTheme((theme) => {
			// Update component state
			document.documentElement.setAttribute('data-theme', theme)
		})

		return unsubscribe
	})
</script>

<div class="theme-provider">
	{@render children?.()}
</div>

2.4 Component Registry Pattern

Create discoverable component systems:

<!-- ComponentRegistry.svelte -->
<script module>
	const registry = new Map()

	export function registerComponent(name, component) {
		registry.set(name, component)
		console.log(`Registered component: ${name}`)
	}

	export function getComponent(name) {
		return registry.get(name)
	}

	export function getAllComponents() {
		return Array.from(registry.entries())
	}

	export function unregisterComponent(name) {
		return registry.delete(name)
	}
</script>

<!-- Usage in other components -->
<script>
	import ComponentRegistry from './ComponentRegistry.svelte'

	// Register this component
	$effect(() => {
		ComponentRegistry.registerComponent('MyComponent', MyComponent)
		return () => ComponentRegistry.unregisterComponent('MyComponent')
	})
</script>

Chapter 3: Exporting Snippets from Module Scripts

Svelte 5.5.0 introduced the ability to export snippets from module scripts, enabling powerful component composition patterns.

3.1 Basic Snippet Export

<!-- FormSnippets.svelte -->
<script module>
	export { inputField, selectField, checkboxField }
</script>

{#snippet inputField(label, value, type = 'text', ...attrs)}
	<div class="form-field">
		<label>{label}</label>
		<input {type} bind:value {...attrs} />
	</div>
{/snippet}

{#snippet selectField(label, value, options, ...attrs)}
	<div class="form-field">
		<label>{label}</label>
		<select bind:value {...attrs}>
			{#each options as option}
				<option value={option.value}>{option.label}</option>
			{/each}
		</select>
	</div>
{/snippet}

{#snippet checkboxField(label, checked, ...attrs)}
	<div class="form-field">
		<label>
			<input type="checkbox" bind:checked {...attrs} />
			{label}
		</label>
	</div>
{/snippet}

3.2 Using Exported Snippets

<!-- ContactForm.svelte -->
<script>
	import { inputField, selectField, checkboxField } from './FormSnippets.svelte'

	let formData = $state({
		name: '',
		email: '',
		country: '',
		newsletter: false
	})

	const countries = [
		{ value: 'us', label: 'United States' },
		{ value: 'ca', label: 'Canada' },
		{ value: 'uk', label: 'United Kingdom' }
	]
</script>

<form>
	{@render inputField('Name', formData.name, 'text', { required: true })}
	{@render inputField('Email', formData.email, 'email', { required: true })}
	{@render selectField('Country', formData.country, countries)}
	{@render checkboxField('Subscribe to newsletter', formData.newsletter)}
</form>

3.3 Limitations of Exported Snippets

Exported snippets have strict scoping rules:

<!-- ❌ This will NOT work -->
<script>
	let instanceVar = "can't access this";
</script>

<script module>
	export { brokenSnippet };
</script>

{#snippet brokenSnippet()}
	<!-- Error: Cannot access instance variables -->
	<p>{instanceVar}</p>
{/snippet}

<!-- ✅ This WILL work -->
<script module>
	const MODULE_CONSTANT = "this is fine";
	export { workingSnippet };
</script>

{#snippet workingSnippet()}
	<p>{MODULE_CONSTANT}</p>
{/snippet}

Chapter 4: Advanced Patterns and Best Practices

4.1 Module-Level Reactive State

While module scripts can’t use runes directly, you can create reactive patterns:

<!-- ReactiveModule.svelte -->
<script module>
	// Module-level reactive state using custom events
	let _value = 'initial'
	const listeners = new Set()

	export function getValue() {
		return _value
	}

	export function setValue(newValue) {
		_value = newValue
		// Notify listeners
		listeners.forEach((listener) => listener(newValue))
	}

	export function subscribe(listener) {
		listeners.add(listener)
		return () => listeners.delete(listener)
	}
</script>

<script>
	let localValue = $state('')

	// Subscribe to module changes
	$effect(() => {
		const unsubscribe = subscribe((newValue) => {
			localValue = newValue
		})
		return unsubscribe
	})
</script>

4.2 Combining Module Scripts with Stores

For complex shared state, combine module scripts with Svelte stores:

<!-- UserPreferences.svelte -->
<script module>
	import { writable } from 'svelte/store'

	// Module-level store
	export const userPreferences = writable({
		theme: 'light',
		language: 'en',
		notifications: true
	})

	// Utility functions
	export function updatePreference(key, value) {
		userPreferences.update((prefs) => ({ ...prefs, [key]: value }))
	}

	export function resetPreferences() {
		userPreferences.set({
			theme: 'light',
			language: 'en',
			notifications: true
		})
	}
</script>

<script>
	import { userPreferences } from './UserPreferences.svelte'

	// Use in component
	let prefs = $state()
	userPreferences.subscribe((value) => (prefs = value))
</script>

4.3 Testing Module Scripts

Module scripts can be challenging to test due to their shared nature:

// ModuleScript.test.js
import { getInstanceCount, recordClick } from './Button.svelte'

// Reset module state between tests
beforeEach(() => {
	// If your module exposes reset functions, use them
	// Otherwise, you may need to re-import or use a different strategy
})

// Test module functions
test('tracks clicks across instances', () => {
	expect(getInstanceCount()).toBe(0)
	recordClick('test-id')
	expect(getInstanceCount()).toBe(1)
})

Chapter 5: Migration from Svelte 4

5.1 Syntax Changes

<!-- Svelte 4 -->
<script context="module">
	export const sharedValue = 'hello';
</script>

<!-- Svelte 5 -->
<script module>
	export const sharedValue = 'hello';
</script>

5.2 Behavioral Changes

  • Module scripts now run in strict mode
  • Better tree-shaking support
  • Improved error messages
  • Snippet export capability (new in 5.5.0)

Conclusion: When to Use Module Scripts

Module scripts are powerful but should be used judiciously:

Use module scripts for:

  • Shared utilities and constants
  • Component analytics and tracking
  • Global component state
  • Snippet libraries
  • Component registries

Don’t use module scripts for:

  • Instance-specific logic
  • Reactive state that varies per component
  • Side effects that should run per instance

Mastering module scripts unlocks new patterns for component communication and code organization in Svelte 5. They bridge the gap between individual component instances and application-level concerns, making your components more composable and maintainable.