Goblin-mode svelte-ninja

Svelte 5 and SvelteKit: runes reactivity, component composition, routing, data loading, form handling.

install
source · Clone the upstream repo
git clone https://github.com/JasonWarrenUK/goblin-mode
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/JasonWarrenUK/goblin-mode "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/svelte-ninja" ~/.claude/skills/jasonwarrenuk-goblin-mode-svelte-ninja && rm -rf "$T"
manifest: skills/svelte-ninja/SKILL.md
source content

Svelte/SvelteKit Patterns

Comprehensive guide to Svelte 5 and SvelteKit development patterns. Emphasizes runes-based reactivity ($state, $derived, $effect, $props), component composition, SvelteKit routing, data loading, form handling, and performance optimization.


When This Skill Applies

Use this skill when:

  • Building Svelte components
  • Managing reactive state with runes
  • Implementing SvelteKit routes and pages
  • Creating load functions or form actions
  • Handling component composition
  • Optimizing Svelte/SvelteKit performance
  • Questions about Svelte 5 patterns or SvelteKit conventions

Svelte 5 Fundamentals

Runes Overview

Runes are compiler instructions (marked with

$
) that enable explicit reactivity:

  • $state
    - Reactive state
  • $derived
    - Computed values
  • $effect
    - Side effects
  • $props
    - Component props
  • $bindable
    - Two-way bindable props

Key principle: Reactivity is explicit, not implicit. Works in

.js
,
.ts
, and
.svelte
files.


Reactive State with $state

Basic State

<script>
	let count = $state(0);
	
	function increment() {
		count++; // Just a number, no wrapper needed
	}
</script>

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

Key points:

  • $state
    creates reactive state
  • State is the value itself (not
    .value
    or
    getCount()
    )
  • Updates trigger UI re-renders

Deep Reactivity

<script>
	let todos = $state([
		{ id: 1, text: 'Learn Svelte 5', done: false }
	]);
	
	function toggle(id) {
		const todo = todos.find(t => t.id === id);
		todo.done = !todo.done; // Deep reactivity works
	}
	
	function addTodo(text) {
		todos.push({ id: Date.now(), text, done: false });
		// Array methods trigger reactivity
	}
</script>

Deep reactivity:

  • Objects and arrays are automatically proxied
  • Nested mutations trigger updates
  • Array methods (
    .push
    ,
    .splice
    , etc.) work reactively

State in .svelte.js Files

// counter.svelte.js
export function createCounter(initial = 0) {
	let count = $state(initial);
	
	return {
		get count() { return count; },
		increment: () => count++,
		reset: () => count = initial
	};
}
<!-- App.svelte -->
<script>
	import { createCounter } from './counter.svelte.js';
	
	const counter = createCounter(5);
</script>

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

Benefits:

  • Share state logic across components
  • Testable outside Svelte components
  • No store boilerplate needed

Derived State with $derived

Basic Derivations

<script>
	let count = $state(0);
	let doubled = $derived(count * 2);
	let isEven = $derived(count % 2 === 0);
</script>

<p>{count} doubled is {doubled}</p>
<p>Count is {isEven ? 'even' : 'odd'}</p>

Key points:

  • Automatically recalculates when dependencies change
  • Memoized (only recalculates when needed)
  • Must be free of side-effects

Complex Derivations with $derived.by

<script>
	let numbers = $state([1, 2, 3, 4, 5]);
	
	let stats = $derived.by(() => {
		const total = numbers.reduce((a, b) => a + b, 0);
		const average = total / numbers.length;
		return { total, average };
	});
</script>

<p>Total: {stats.total}, Average: {stats.average}</p>

Use $derived.by when:

  • Logic doesn't fit in short expression
  • Need multiple statements
  • Complex calculations required

$derived vs $effect

$derived - Computing values (pure, returns value):

<script>
	let count = $state(0);
	let doubled = $derived(count * 2); // ✓ Good
</script>

$effect - Side effects (impure, no return value):

<script>
	let count = $state(0);
	
	$effect(() => {
		console.log('Count changed:', count); // ✓ Good
	});
</script>

Side Effects with $effect

Basic Effects

<script>
	let count = $state(0);
	
	$effect(() => {
		// Runs on mount and whenever count changes
		document.title = `Count: ${count}`;
	});
</script>

When $effect runs:

  • Initially when component mounts
  • Whenever dependencies change
  • After DOM updates (unlike Svelte 4's
    $:
    )

Effect Cleanup

<script>
	let intervalId = $state(null);
	let elapsed = $state(0);
	
	$effect(() => {
		const id = setInterval(() => {
			elapsed++;
		}, 1000);
		
		// Cleanup runs when effect re-runs or component unmounts
		return () => clearInterval(id);
	});
</script>

$effect.pre (Before DOM Update)

<script>
	let messages = $state([]);
	let div;
	
	$effect.pre(() => {
		const isAtBottom = 
			div.scrollHeight - div.scrollTop === div.clientHeight;
		
		if (isAtBottom) {
			// After DOM updates, scroll to bottom
			$effect(() => {
				div.scrollTop = div.scrollHeight;
			});
		}
	});
</script>

Common $effect Patterns

Local storage sync:

<script>
	let preferences = $state(JSON.parse(
		localStorage.getItem('prefs') || '{}'
	));
	
	$effect(() => {
		localStorage.setItem('prefs', JSON.stringify(preferences));
	});
</script>

API calls:

<script>
	let userId = $state('123');
	let user = $state(null);
	
	$effect(() => {
		fetch(`/api/users/${userId}`)
			.then(r => r.json())
			.then(data => user = data);
	});
</script>

Avoid $effect for:

  • Derived values (use
    $derived
    )
  • DOM manipulation Svelte handles (bindings, directives)
  • Setting document.title (use
    <svelte:head>
    )

Component Props with $props

Basic Props

<!-- Button.svelte -->
<script>
	let { label, variant = 'primary' } = $props();
</script>

<button class="btn btn--{variant}">
	{label}
</button>

Usage:

<Button label="Click me" variant="secondary" />

Props with TypeScript

<script lang="ts">
	interface Props {
		label: string;
		variant?: 'primary' | 'secondary' | 'danger';
		onclick?: () => void;
	}
	
	let { label, variant = 'primary', onclick }: Props = $props();
</script>

<button class="btn btn--{variant}" {onclick}>
	{label}
</button>

Rest Props

<script>
	let { label, ...rest } = $props();
</script>

<button {...rest}>
	{label}
</button>

Bindable Props with $bindable

<!-- Input.svelte -->
<script>
	let { value = $bindable('') } = $props();
</script>

<input bind:value />

Usage:

<script>
	let text = $state('');
</script>

<Input bind:value={text} />
<p>You typed: {text}</p>

Component Patterns

Slot Patterns

Basic slot:

<!-- Card.svelte -->
<div class="card">
	<slot />
</div>

Named slots:

<!-- Modal.svelte -->
<div class="modal">
	<header>
		<slot name="header" />
	</header>
	<main>
		<slot />
	</main>
	<footer>
		<slot name="footer" />
	</footer>
</div>

Usage:

<Modal>
	<svelte:fragment slot="header">
		<h2>Title</h2>
	</svelte:fragment>
	
	<p>Content here</p>
	
	<svelte:fragment slot="footer">
		<button>Close</button>
	</svelte:fragment>
</Modal>

Slot props:

<!-- List.svelte -->
<script>
	let { items } = $props();
</script>

<ul>
	{#each items as item}
		<li>
			<slot {item} />
		</li>
	{/each}
</ul>

Usage:

<List items={users}>
	{#snippet children({ item })}
		<strong>{item.name}</strong>
	{/snippet}
</List>

Composition Patterns

Compound components:

<!-- Tabs.svelte -->
<script>
	let { children } = $props();
	let activeTab = $state(0);
	
	export function setActive(index) {
		activeTab = index;
	}
</script>

<div class="tabs">
	{@render children({ activeTab, setActive })}
</div>

Higher-order components:

// withAuth.js
export function withAuth(Component) {
	return (props) => {
		const { user } = useAuth();
		if (!user) return 'Please log in';
		return new Component({ ...props, user });
	};
}

SvelteKit Routing

File-based Routing

src/routes/
├── +page.svelte              # /
├── about/
│   └── +page.svelte          # /about
├── blog/
│   ├── +page.svelte          # /blog
│   └── [slug]/
│       └── +page.svelte      # /blog/:slug
└── (app)/
    ├── dashboard/
    │   └── +page.svelte      # /dashboard
    └── settings/
        └── +page.svelte      # /settings

Route groups

(app)
: Don't affect URL, useful for layouts

Dynamic Routes

<!-- src/routes/blog/[slug]/+page.svelte -->
<script>
	let { data } = $props();
</script>

<h1>{data.post.title}</h1>
<div>{@html data.post.content}</div>
// src/routes/blog/[slug]/+page.js
export async function load({ params }) {
	const post = await fetchPost(params.slug);
	return { post };
}

Optional Parameters

src/routes/archive/[[year]]/[[month]]/+page.svelte

Matches:

  • /archive
  • /archive/2024
  • /archive/2024/12

Data Loading

+page.js Load Functions

// src/routes/blog/[slug]/+page.js
export async function load({ params, fetch }) {
	const post = await fetch(`/api/posts/${params.slug}`).then(r => r.json());
	
	return {
		post
	};
}

Load function context:

  • params
    - Route parameters
  • fetch
    - Enhanced fetch (credentials, relative URLs)
  • url
    - URL object
  • route
    - Route info
  • parent
    - Parent load data

+page.server.js Server Load

// src/routes/dashboard/+page.server.js
import { db } from '$lib/server/database';

export async function load({ locals }) {
	const user = locals.user;
	const stats = await db.query('SELECT * FROM stats WHERE user_id = $1', [user.id]);
	
	return {
		stats
	};
}

Server-only:

  • Access to database
  • Access to environment variables
  • Runs on server, never exposed to client

Streaming with Promises

// +page.js
export async function load({ fetch }) {
	const quick = fetch('/api/quick').then(r => r.json());
	const slow = fetch('/api/slow').then(r => r.json());
	
	return {
		quick: await quick,  // Wait for this
		slow                  // Stream this
	};
}
<!-- +page.svelte -->
<script>
	let { data } = $props();
</script>

<div>{data.quick.title}</div>

{#await data.slow}
	<p>Loading...</p>
{:then slow}
	<p>{slow.content}</p>
{/await}

Form Actions

Basic Form Actions

// src/routes/login/+page.server.js
export const actions = {
	default: async ({ request, cookies }) => {
		const data = await request.formData();
		const email = data.get('email');
		const password = data.get('password');
		
		const user = await authenticate(email, password);
		
		if (!user) {
			return { success: false, error: 'Invalid credentials' };
		}
		
		cookies.set('session', user.sessionId, { path: '/' });
		return { success: true };
	}
};
<!-- src/routes/login/+page.svelte -->
<script>
	import { enhance } from '$app/forms';
	let { form } = $props();
</script>

<form method="POST" use:enhance>
	<input name="email" type="email" required />
	<input name="password" type="password" required />
	<button>Log in</button>
	
	{#if form?.error}
		<p class="error">{form.error}</p>
	{/if}
</form>

Named Actions

// +page.server.js
export const actions = {
	create: async ({ request }) => {
		// Handle create
	},
	update: async ({ request }) => {
		// Handle update
	},
	delete: async ({ request }) => {
		// Handle delete
	}
};
<form method="POST" action="?/create">
	<!-- create form -->
</form>

<form method="POST" action="?/update">
	<!-- update form -->
</form>

Progressive Enhancement

<script>
	import { enhance } from '$app/forms';
</script>

<form 
	method="POST" 
	use:enhance={({ formData, cancel }) => {
		// Run before submission
		if (!confirm('Are you sure?')) {
			cancel();
		}
		
		return async ({ result, update }) => {
			// Run after response
			if (result.type === 'success') {
				await update();
				alert('Success!');
			}
		};
	}}
>
	<!-- form fields -->
</form>

Performance Optimization

Lazy Loading Components

<script>
	import { onMount } from 'svelte';
	
	let HeavyComponent;
	
	onMount(async () => {
		const module = await import('./HeavyComponent.svelte');
		HeavyComponent = module.default;
	});
</script>

{#if HeavyComponent}
	<svelte:component this={HeavyComponent} />
{/if}

Virtualizing Long Lists

<script>
	let items = $state(Array.from({ length: 10000 }, (_, i) => i));
	let scrollTop = $state(0);
	
	const itemHeight = 50;
	const visibleCount = 20;
	
	let visibleItems = $derived(() => {
		const start = Math.floor(scrollTop / itemHeight);
		return items.slice(start, start + visibleCount);
	});
</script>

<div class="viewport" bind:scrollTop>
	<div style="height: {items.length * itemHeight}px">
		<div style="transform: translateY({Math.floor(scrollTop / itemHeight) * itemHeight}px)">
			{#each visibleItems as item}
				<div class="item">{item}</div>
			{/each}
		</div>
	</div>
</div>

Memoizing Expensive Calculations

<script>
	let data = $state([/* large dataset */]);
	
	// Automatically memoized
	let processed = $derived.by(() => {
		return data
			.filter(/* expensive filter */)
			.map(/* expensive transform */)
			.sort(/* expensive sort */);
	});
</script>

Avoiding Unnecessary Re-renders

<script>
	let todos = $state([
		{ id: 1, text: 'Task 1', done: false }
	]);
	
	// Each todo is keyed, only changed todos re-render
</script>

{#each todos as todo (todo.id)}
	<TodoItem {todo} />
{/each}

Common Patterns

Form Validation

<script>
	let email = $state('');
	let password = $state('');
	
	let errors = $derived.by(() => {
		const errs = {};
		if (!email.includes('@')) errs.email = 'Invalid email';
		if (password.length < 8) errs.password = 'Too short';
		return errs;
	});
	
	let isValid = $derived(Object.keys(errors).length === 0);
</script>

<form>
	<input bind:value={email} />
	{#if errors.email}<span class="error">{errors.email}</span>{/if}
	
	<input type="password" bind:value={password} />
	{#if errors.password}<span class="error">{errors.password}</span>{/if}
	
	<button disabled={!isValid}>Submit</button>
</form>

Modal Management

<script>
	let isOpen = $state(false);
	
	$effect(() => {
		if (isOpen) {
			document.body.style.overflow = 'hidden';
		}
		
		return () => {
			document.body.style.overflow = '';
		};
	});
</script>

<button onclick={() => isOpen = true}>Open Modal</button>

{#if isOpen}
	<div class="modal-backdrop" onclick={() => isOpen = false}>
		<div class="modal" onclick={(e) => e.stopPropagation()}>
			<slot />
			<button onclick={() => isOpen = false}>Close</button>
		</div>
	</div>
{/if}

Debounced Input

<script>
	let searchQuery = $state('');
	let debouncedQuery = $state('');
	
	$effect(() => {
		const timeout = setTimeout(() => {
			debouncedQuery = searchQuery;
		}, 300);
		
		return () => clearTimeout(timeout);
	});
	
	// Use debouncedQuery for API calls
	$effect(() => {
		if (debouncedQuery) {
			fetch(`/api/search?q=${debouncedQuery}`);
		}
	});
</script>

<input bind:value={searchQuery} placeholder="Search..." />

Anti-Patterns

Don't: Use $effect for Derived Values

<!-- ✗ Bad -->
<script>
	let count = $state(0);
	let doubled = $state(0);
	
	$effect(() => {
		doubled = count * 2; // Wrong! Use $derived
	});
</script>

<!-- ✓ Good -->
<script>
	let count = $state(0);
	let doubled = $derived(count * 2);
</script>

Don't: Mutate Props Directly

<!-- ✗ Bad -->
<script>
	let { user } = $props();
	
	function updateName() {
		user.name = 'New Name'; // Wrong! Props are read-only
	}
</script>

<!-- ✓ Good -->
<script>
	let { user, onUpdate } = $props();
	
	function updateName() {
		onUpdate({ ...user, name: 'New Name' });
	}
</script>

Don't: Create Unnecessary $state

<!-- ✗ Bad -->
<script>
	let count = $state(0);
	let doubled = $state(0);
	let isEven = $state(false);
	
	function increment() {
		count++;
		doubled = count * 2;     // Derived!
		isEven = count % 2 === 0; // Derived!
	}
</script>

<!-- ✓ Good -->
<script>
	let count = $state(0);
	let doubled = $derived(count * 2);
	let isEven = $derived(count % 2 === 0);
</script>

Success Criteria

Svelte/SvelteKit code is well-structured when:

  • Runes used appropriately ($state for state, $derived for computed, $effect for side effects)
  • Components are composable and reusable
  • Load functions fetch data efficiently
  • Forms use progressive enhancement
  • Performance optimized (lazy loading, memoization)
  • TypeScript types are accurate
  • Code is maintainable and follows Svelte 5 conventions