Svelte and SvelteKit course · Module 7: Lifecycle

Practical Lifecycle Patterns

2 min read ·
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

Useful articles