Svelte and SvelteKit course · Module 7: Lifecycle
Practical Lifecycle Patterns
In this lesson4
Auto-save
<script>
import { onDestroy } from 'svelte';
let content = $state('');
// Debounced auto-save
$effect(() => {
const draft = content; // reading = dependency of the effect
const timeout = setTimeout(() => {
saveToServer(draft);
}, 1000);
// Cleanup before the next run cancels the previous save
return () => clearTimeout(timeout);
});
onDestroy(() => {
// Save before closing
saveToServer(content);
});
async function saveToServer(data) {
await fetch('/api/save', {
method: 'POST',
body: JSON.stringify({ content: data })
});
}
</script>
<textarea bind:value={content}></textarea>An effect tracks only the state it reads right away, in its body. If content were read only inside setTimeout, the effect would not know it depends on it and would not run again after the next text change.
Subscription Cleanup
<script>
import { onMount } from 'svelte';
let position = $state({ x: 0, y: 0 });
onMount(() => {
function handleMouseMove(e) {
position = { x: e.clientX, y: e.clientY };
}
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
});
</script>
<p>Position: {position.x}, {position.y}</p>Intersection Observer
<script>
let element;
let isVisible = $state(false);
$effect(() => {
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => {
isVisible = entry.isIntersecting;
},
{ threshold: 0.5 }
);
observer.observe(element);
return () => observer.disconnect();
});
</script>
<div bind:this={element} class:visible={isVisible}>
{isVisible ? 'Visible!' : 'Not visible'}
</div>SSR and onMount
In SvelteKit a component first renders on the server, where there is no window and no localStorage. onMount and $effect do not run on the server, but code at the top level of <script> does:
<script>
import { onMount } from 'svelte';
import { browser } from '$app/env'; // SvelteKit 3 (in SvelteKit 2: '$app/environment')
let windowWidth = $state(0);
// onMount does NOT run on the server
onMount(() => {
windowWidth = window.innerWidth;
});
// Top-level code also runs during SSR,
// so guard browser APIs with the browser flag
const savedName = browser ? localStorage.getItem('viking-name') : null;
</script>So put access to browser APIs in onMount or $effect, and outside them check browser.
Code for this lesson: App.svelte
<script>
// tick() - waiting for DOM update
import { tick } from 'svelte';
let messages = $state([
'Welcome to Valhalla!',
'Here warriors rest'
]);
let inputValue = $state('');
let listElement;
async function addMessage() {
if (!inputValue.trim()) return;
messages = [...messages, inputValue];
inputValue = '';
// Wait for DOM update
await tick();
// Now the DOM is updated
listElement?.scrollTo({
top: listElement.scrollHeight,
behavior: 'smooth'
});
}
// Text example
let text = $state('Change me');
let selection = $state({ start: 0, end: 0 });
let textareaElement;
async function transformText() {
// Save cursor position
const start = textareaElement.selectionStart;
const end = textareaElement.selectionEnd;
// Convert text to uppercase
text = text.toUpperCase();
// Wait for DOM update
await tick();
// Restore cursor position
textareaElement.selectionStart = start;
textareaElement.selectionEnd = end;
}
</script>
<main class="tick-demo">
<h1>⏳ tick() - DOM Synchronization</h1>
<section>
<h2>Auto-scroll to end of list</h2>
<div class="messages" bind:this={listElement}>
{#each messages as msg}
<p>{msg}</p>
{/each}
</div>
<div class="input-row">
<input
bind:value={inputValue}
placeholder="Write a message..."
onkeydown={(e) => e.key === 'Enter' && addMessage()}
/>
<button onclick={addMessage}>Send</button>
</div>
</section>
<section>
<h2>Preserving cursor position</h2>
<textarea
bind:this={textareaElement}
bind:value={text}
rows="3"
></textarea>
<button onclick={transformText}>UPPERCASE</button>
<p class="hint">Cursor will remain in the same position after transformation</p>
</section>
</main>
<style>
.tick-demo {
font-family: 'Georgia', serif;
max-width: 500px;
margin: 0 auto;
padding: 2rem;
background: #1a0f0a;
color: #e8d5b7;
min-height: 100vh;
}
h1 {
color: #d4af37;
text-align: center;
}
section {
background: rgba(212, 175, 55, 0.1);
border: 1px solid #d4af37;
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
}
h2 {
color: #d4af37;
font-size: 1rem;
margin-top: 0;
}
.messages {
height: 150px;
overflow-y: auto;
background: rgba(0,0,0,0.3);
border-radius: 4px;
padding: 0.5rem;
margin-bottom: 0.5rem;
}
.messages p {
margin: 0.25rem 0;
padding: 0.5rem;
background: rgba(212, 175, 55, 0.1);
border-radius: 4px;
}
.input-row {
display: flex;
gap: 0.5rem;
}
input, textarea {
flex: 1;
padding: 0.5rem;
background: rgba(0,0,0,0.3);
border: 1px solid #3d2914;
border-radius: 4px;
color: #e8d5b7;
}
textarea {
width: 100%;
resize: vertical;
margin-bottom: 0.5rem;
}
button {
padding: 0.5rem 1rem;
background: #3d2914;
color: #e8d5b7;
border: 1px solid #d4af37;
border-radius: 4px;
cursor: pointer;
}
.hint {
font-size: 0.85rem;
color: #8b7355;
font-style: italic;
margin-top: 0.5rem;
}
</style>Docs entries used in this lesson
Spotted a mistake in this lesson?
Hands-on tasks in the game
- Code editor
Complete the code to remove the event listener on unmount