Svelte Skill
Overview
This skill provides expertise for building reactive web applications with Svelte. It covers component architecture, the reactivity system, stores for state management, real-time updates with WebSockets, and SvelteKit for full-stack applications.
Why Svelte
Comparison with Vanilla JS
Aspect Vanilla JS Svelte
Reactivity Manual DOM updates Automatic - count++ just works
Components Template strings Single-file components
State Global variables Stores with subscriptions
Bundle size 0kb (but more code) ~2kb runtime
Learning curve None Gentle (closest to vanilla)
Key Benefits
-
Compile-time magic - No virtual DOM, compiles to efficient vanilla JS
-
Less boilerplate - let count = 0 is reactive by default
-
Built-in transitions - transition:fade for animations
-
Scoped CSS - Styles in components don't leak
-
Stores - Simple reactive state that works with WebSockets
Core Concepts
Reactivity
Svelte's reactivity is based on assignments:
<script> let count = 0;
// Reactive statements run when dependencies change $: doubled = count * 2; $: console.log('count changed to', count);
function increment() { count++; // This triggers UI update automatically } </script>
<button on:click={increment}> Count: {count} (doubled: {doubled}) </button>
Array/Object Reactivity
Svelte tracks assignments, not mutations:
<script> let items = ['a', 'b', 'c'];
// BAD: mutation doesn't trigger update function addBad() { items.push('d'); // UI won't update! }
// GOOD: reassignment triggers update function addGood() { items = [...items, 'd']; // UI updates }
// Also works: assign back to self function addAlso() { items.push('d'); items = items; // Triggers update } </script>
Component Structure
Single-file components with script, markup, and style:
<!-- PlayerCard.svelte --> <script> // Props with defaults export let name; export let cash = 0; export let isActive = false;
// Local state let expanded = false;
// Event dispatcher import { createEventDispatcher } from 'svelte'; const dispatch = createEventDispatcher();
function handleClick() { dispatch('select', { name }); } </script>
<div class="player-card" class:active={isActive} on:click={handleClick}> <h3>{name}</h3> <p>Cash: £{cash}</p>
{#if expanded} <slot /> <!-- Nested content goes here --> {/if} </div>
<style> /* Scoped to this component only */ .player-card { padding: 1rem; border: 2px solid #333; border-radius: 8px; }
.player-card.active { border-color: #4a9eff; background: rgba(74, 158, 255, 0.1); } </style>
Using Components
<!-- Game.svelte --> <script> import PlayerCard from './PlayerCard.svelte';
let players = [ { id: 1, name: 'Germany', cash: 15 }, { id: 2, name: 'Britain', cash: 12 } ]; let activePlayerId = 1;
function handleSelect(event) { console.log('Selected:', event.detail.name); } </script>
{#each players as player (player.id)} <PlayerCard name={player.name} cash={player.cash} isActive={player.id === activePlayerId} on:select={handleSelect}
<p>Ships: {player.ships?.length ?? 0}</p>
</PlayerCard> {/each}
Stores
Writable Stores
For shared state across components:
// stores/gameState.js import { writable, derived } from 'svelte/store';
// Create a writable store export const gameState = writable(null);
// Derived stores compute from other stores export const currentPlayer = derived( gameState, $state => $state?.players?.[$state?.currentPlayerIndex] );
export const isMyTurn = derived( [gameState, currentPlayer], ([$state, $player]) => $player?.id === myPlayerId );
// Helper functions to update state export function updateGameState(newState) { gameState.set(newState); }
export function updatePlayer(playerId, changes) { gameState.update(state => ({ ...state, players: { ...state.players, [playerId]: { ...state.players[playerId], ...changes } } })); }
Using Stores in Components
<script> import { gameState, currentPlayer, isMyTurn } from './stores/gameState.js';
// $ prefix auto-subscribes to store $: console.log('Game state updated:', $gameState); </script>
<div> <h2>Turn: {$gameState?.turn}</h2> <p>Current player: {$currentPlayer?.name}</p>
{#if $isMyTurn} <button>Take Action</button> {:else} <p>Waiting for {$currentPlayer?.name}...</p> {/if} </div>
Custom Stores
Create stores with custom methods:
// stores/player.js import { writable } from 'svelte/store';
function createPlayerStore() { const { subscribe, set, update } = writable({ cash: 0, officers: 0, engineers: 0, gasCubes: { hydrogen: 0, helium: 0 } });
return { subscribe, set, reset: () => set({ cash: 0, officers: 0, engineers: 0, gasCubes: { hydrogen: 0, helium: 0 } }), addCash: (amount) => update(p => ({ ...p, cash: p.cash + amount })), spendCash: (amount) => update(p => ({ ...p, cash: p.cash - amount })), buyGas: (type, amount) => update(p => ({ ...p, gasCubes: { ...p.gasCubes, [type]: p.gasCubes[type] + amount } })) }; }
export const player = createPlayerStore();
Real-Time Updates with WebSocket
Socket Store Pattern
// stores/socket.js import { writable, get } from 'svelte/store'; import { io } from 'socket.io-client'; import { gameState } from './gameState.js';
export const connected = writable(false); export const connectionError = writable(null);
let socket = null;
export function connect(serverUrl) { socket = io(serverUrl, { reconnection: true, reconnectionAttempts: 10, reconnectionDelay: 1000 });
socket.on('connect', () => { connected.set(true); connectionError.set(null); console.log('Connected to server'); });
socket.on('disconnect', () => { connected.set(false); });
socket.on('connect_error', (error) => { connectionError.set(error.message); });
// Game state updates from server socket.on('state-update', (newState) => { gameState.set(newState); });
socket.on('state-sync', (fullState) => { gameState.set(fullState); });
return socket; }
export function joinGame(gameId, playerId) { if (socket) { socket.emit('join-game', { gameId, playerId }); } }
export function sendAction(action) { if (socket) { socket.emit('game-action', action); } }
export function disconnect() { if (socket) { socket.disconnect(); socket = null; connected.set(false); } }
Using Socket in Components
<!-- Game.svelte --> <script> import { onMount, onDestroy } from 'svelte'; import { connect, joinGame, sendAction, disconnect, connected } from './stores/socket.js'; import { gameState, currentPlayer } from './stores/gameState.js';
export let gameId; export let playerId;
onMount(() => { connect('http://localhost:3000'); joinGame(gameId, playerId); });
onDestroy(() => { disconnect(); });
function handleEndTurn() { sendAction({ type: 'END_TURN' }); } </script>
{#if !$connected} <div class="connecting">Connecting to server...</div> {:else if !$gameState} <div class="loading">Loading game state...</div> {:else} <div class="game"> <h1>Turn {$gameState.turn}</h1> <p>Current player: {$currentPlayer?.name}</p>
<button on:click={handleEndTurn}>End Turn</button>
</div> {/if}
Conditional Rendering and Loops
If/Else Blocks
{#if loading} <Spinner /> {:else if error} <ErrorMessage {error} /> {:else if items.length === 0} <EmptyState /> {:else} <ItemList {items} /> {/if}
Each Blocks with Keys
<!-- Key is crucial for list updates --> {#each ships as ship (ship.id)} <Ship {...ship} on:launch={handleLaunch} /> {:else} <p>No ships in hangar</p> {/each}
Await Blocks
{#await fetchGameState()} <p>Loading...</p> {:then state} <GameBoard {state} /> {:catch error} <p>Error: {error.message}</p> {/await}
Transitions and Animations
Built-in Transitions
<script> import { fade, fly, slide, scale } from 'svelte/transition'; import { flip } from 'svelte/animate';
let visible = true; let items = []; </script>
{#if visible} <div transition:fade={{ duration: 300 }}> Fades in and out </div> {/if}
<!-- One-way transitions --> {#if showNotification} <div in:fly={{ y: -50, duration: 300 }} out:fade> Notification! </div> {/if}
<!-- Animate list reordering --> {#each items as item (item.id)} <div animate:flip={{ duration: 300 }}> {item.name} </div> {/each}
Custom Transitions
<script>
function whoosh(node, { duration = 400 }) {
return {
duration,
css: (t) => {
const eased = t; // Could use easing function
return transform: scale(${eased}) rotate(${(1 - eased) * 360}deg); opacity: ${eased}; ;
}
};
}
</script>
{#if show} <div transition:whoosh>Whoooosh!</div> {/if}
Event Handling
DOM Events
<button on:click={handleClick}>Click</button> <button on:click={() => count++}>Inline</button>
<!-- Event modifiers --> <button on:click|preventDefault={submit}>Submit</button> <button on:click|stopPropagation={handleClick}>Stop Bubble</button> <button on:click|once={init}>Initialize Once</button> <form on:submit|preventDefault={handleSubmit}>...</form>
<!-- Keyboard events --> <input on:keydown|self={(e) => e.key === 'Enter' && submit()} />
Component Events
<!-- Child component --> <script> import { createEventDispatcher } from 'svelte'; const dispatch = createEventDispatcher();
function handleSelect() { dispatch('select', { id: item.id, name: item.name }); } </script>
<!-- Parent component --> <Card on:select={(e) => console.log(e.detail.name)} />
<!-- Forward DOM events --> <button on:click> This click bubbles to parent </button>
Bindings
Two-Way Binding
<script> let name = ''; let agreed = false; let selected = 'a'; let quantity = 1; </script>
<input bind:value={name} /> <input type="checkbox" bind:checked={agreed} /> <input type="number" bind:value={quantity} min="1" max="10" />
<select bind:value={selected}> <option value="a">Option A</option> <option value="b">Option B</option> </select>
<!-- Group binding --> <script> let selectedColors = []; </script> {#each ['red', 'green', 'blue'] as color} <label> <input type="checkbox" bind:group={selectedColors} value={color} /> {color} </label> {/each}
Element Bindings
<script> let inputElement; let divWidth; let divHeight; </script>
<input bind:this={inputElement} /> <button on:click={() => inputElement.focus()}>Focus</button>
<div bind:clientWidth={divWidth} bind:clientHeight={divHeight}> Size: {divWidth}x{divHeight} </div>
SvelteKit
Project Structure
my-app/ ├── src/ │ ├── lib/ # Shared components and utilities │ │ ├── components/ │ │ │ ├── PlayerCard.svelte │ │ │ └── GameBoard.svelte │ │ ├── stores/ │ │ │ ├── gameState.js │ │ │ └── socket.js │ │ └── utils/ │ ├── routes/ # File-based routing │ │ ├── +page.svelte # / │ │ ├── +layout.svelte # Shared layout │ │ ├── game/ │ │ │ ├── +page.svelte # /game │ │ │ └── [id]/ │ │ │ └── +page.svelte # /game/:id │ │ └── api/ # API routes │ │ └── games/ │ │ └── +server.js │ ├── app.html │ └── app.css ├── static/ # Static assets ├── svelte.config.js └── package.json
Page Load Functions
// routes/game/[id]/+page.js
export async function load({ params, fetch }) {
const response = await fetch(/api/games/${params.id});
if (!response.ok) { throw error(404, 'Game not found'); }
const game = await response.json();
return { game, gameId: params.id }; }
<!-- routes/game/[id]/+page.svelte --> <script> export let data; // From load function
$: ({ game, gameId } = data); </script>
<h1>Game: {game.name}</h1>
API Routes
// routes/api/games/+server.js import { json } from '@sveltejs/kit';
export async function GET({ url }) { const games = await db.getGames(); return json(games); }
export async function POST({ request }) { const { name, playerId } = await request.json(); const game = await db.createGame(name, playerId); return json(game, { status: 201 }); }
TypeScript Support
<script lang="ts"> interface Player { id: string; name: string; cash: number; faction: 'germany' | 'britain' | 'usa' | 'italy'; }
interface Ship { id: string; name: string; status: 'hangar' | 'on_route' | 'destroyed'; }
export let player: Player; export let ships: Ship[] = [];
let selectedShip: Ship | null = null;
function selectShip(ship: Ship): void { selectedShip = ship; } </script>
Migration from Vanilla JS
Before (Vanilla)
// Vanilla JS pattern let gameState = null; const stateElement = document.getElementById('game-state');
function render() {
stateElement.innerHTML = <h2>Turn ${gameState.turn}</h2> <p>Cash: £${gameState.players[userId].cash}</p> ${gameState.players[userId].ships.map(ship =>
<div class="ship">${ship.name}</div>
).join('')} ;
}
async function fetchState() {
const res = await fetch(/api/state/${gameId});
gameState = await res.json();
render();
}
// Poll every 2 seconds setInterval(fetchState, 2000);
After (Svelte)
<script> import { onMount } from 'svelte'; import { gameState } from './stores/gameState.js'; import { connect, joinGame } from './stores/socket.js';
export let gameId; export let userId;
$: player = $gameState?.players?.[userId];
onMount(() => { connect('http://localhost:3000'); joinGame(gameId, userId); }); </script>
{#if $gameState} <h2>Turn {$gameState.turn}</h2> <p>Cash: £{player.cash}</p>
{#each player.ships as ship (ship.id)} <div class="ship">{ship.name}</div> {/each} {:else} <p>Loading...</p> {/if}
Best Practices
Component Organization
lib/components/ ├── ui/ # Generic reusable components │ ├── Button.svelte │ ├── Modal.svelte │ └── Tooltip.svelte ├── game/ # Game-specific components │ ├── GameBoard.svelte │ ├── PlayerPanel.svelte │ └── ShipCard.svelte └── layout/ # Layout components ├── Header.svelte └── Sidebar.svelte
Props and Events Naming
<script> // Props: noun or adjective export let player; export let isActive = false; export let maxItems = 10;
// Events: on:verbNoun pattern import { createEventDispatcher } from 'svelte'; const dispatch = createEventDispatcher();
// dispatch('select'), dispatch('launch'), dispatch('close') </script>
<!-- Usage follows same pattern --> <ShipCard ship={myShip} isSelected={selectedId === myShip.id} on:launch={handleLaunch} on:select={handleSelect} />
Reactive Statement Order
<script> export let items; export let filter;
// Derived values first (these update when deps change) $: filteredItems = items.filter(i => i.type === filter); $: totalCount = filteredItems.length;
// Side effects last (log, dispatch events, etc.) $: if (totalCount === 0) { console.log('No items match filter'); } </script>
Avoiding Common Mistakes
<script> // MISTAKE 1: Mutating without reassignment let items = [1, 2, 3]; items.push(4); // Won't trigger update! items = [...items, 4]; // Correct
// MISTAKE 2: Destructuring props loses reactivity export let player; const { name } = player; // name won't update! $: ({ name } = player); // Reactive destructure
// MISTAKE 3: Not using key in each {#each items as item} // Bad for updates {#each items as item (item.id)} // Good
// MISTAKE 4: Store in template without $ import { count } from './stores'; // {count} shows store object, not value // {$count} shows the value </script>
Testing Svelte Components
// PlayerCard.test.js import { render, fireEvent } from '@testing-library/svelte'; import PlayerCard from './PlayerCard.svelte';
describe('PlayerCard', () => { it('displays player name and cash', () => { const { getByText } = render(PlayerCard, { props: { name: 'Germany', cash: 15 } });
expect(getByText('Germany')).toBeInTheDocument();
expect(getByText('Cash: £15')).toBeInTheDocument();
});
it('dispatches select event on click', async () => { const { getByRole, component } = render(PlayerCard, { props: { name: 'Germany', cash: 15 } });
const selectHandler = vi.fn();
component.$on('select', selectHandler);
await fireEvent.click(getByRole('button'));
expect(selectHandler).toHaveBeenCalledWith(
expect.objectContaining({
detail: { name: 'Germany' }
})
);
}); });
When This Skill Activates
Use this skill when:
-
Building Svelte components
-
Managing state with Svelte stores
-
Implementing real-time updates via WebSocket
-
Migrating vanilla JS to Svelte
-
Setting up SvelteKit projects
-
Adding TypeScript to Svelte
-
Creating reactive UI patterns
-
Optimizing Svelte performance