Кладовочка

О разном, чтобы было под рукой онлайн

Svelte 5: шпаргалка — руны, $state, $derived, $effect, $props, сниппеты, анимации, SvelteKit

Svelte 5 — компилируемый фреймворк с рунами (runes) вместо виртуального DOM. Руны делают реактивность явной и переносимой в .svelte.ts модули. Шпаргалка построена на реальных паттернах из боевых проектов.

Руны: таблица за 15 секунд

Руна Назначение Заменяет в Svelte 4 Аналог в React
$state(x) Реактивная переменная let x = 0 useState(0)
$state.raw(x) Неглубокий $state (без Proxy) — useRef (но reactive)
$derived(expr) Производное значение $: x = expr useMemo
$derived.by(() => {...}) Сложное производное $: { ... } useMemo(() => {...})
$effect(() => {...}) Побочный эффект $: { ... } useEffect
$effect.pre(() => {...}) Эффект до обновления DOM beforeUpdate useLayoutEffect
$props() Пропсы компонента export let x props.x / destruct
$bindable() Двусторонний проп bind:value value + onChange
$inspect(x) Отладка реактивности console.log —
$host() Контекст custom element — —

$state — реактивная переменная

<script>
  let count = $state(0)                     // примитив
  let user = $state({ name: 'Анна' })        // объект — глубокий Proxy
  let items = $state([1, 2, 3])              // массив — глубокий Proxy

  count++                                    // прямое изменение — реактивно
  user.name = 'Иван'                         // глубокое изменение — реактивно
  items.push(4)                             // push, splice, pop — реактивны

  // Паттерн: замена ссылки вместо мутации (частый в проектах)
  cells = { ...cells, [key]: value }         // новый объект → триггер реактивности

  // Паттерн: сложные контейнеры
  let expanded = $state<Set<string>>(new Set(["root"]))  // Set
  let progress = $state<Map<string, CardProgress>>(new Map())  // Map

  // Паттерн: localStorage-состояние
  let saved = $state(loadFromStorage<State>('key', defaultState))
  function persist() { saveToStorage('key', saved) }

  // $state.raw — без глубокого Proxy (быстрее для больших объектов)
  let bigData = $state.raw({ /* 10000 записей */ })
  bigData.items[0].name = 'new'               // ❌ НЕ триггерит!
  bigData = { ...bigData, items: [...] }      // ✅ замена ссылки триггерит
</script>

Где работает: .svelte, .svelte.js, .svelte.ts. В обычных .js/.ts — ошибка компиляции.

$state в классах (нужны аксессоры, классы не проксируются):

// counter.svelte.ts
class Counter {
  count = $state(0)
  increment() { this.count++ }
}

$derived и $derived.by — производные значения

<script>
  let width = $state(100)
  let height = $state(200)

  // Простое выражение
  let area = $derived(width * height)

  // Цепочка производных (реальный паттерн из проектов)
  let rgb = $derived(hexToRgb(color))
  let hsl = $derived(rgbToHsl(rgb.r, rgb.g, rgb.b))
  let contrast = $derived(contrastRatio(color, '#ffffff'))

  // Паттерн: $derived возвращает функцию (идиоматический приём!)
  let isSelected = $derived((qId: number, optIdx: number) =>
    saved.answers[String(qId)] === String(optIdx)
  )
</script>

$derived.by — многострочная логика

<script>
  // Ранний возврат + защита от NaN/infinity
  let paybackMonths = $derived.by(() => {
    if (monthlyIncome <= 0) return null
    const m = investment / monthlyIncome
    if (!isFinite(m)) return null
    return m
  })

  // Поиск с фильтрацией
  let filtered = $derived.by(() => {
    if (!query) return items
    return items.filter(i => i.name.toLowerCase().includes(query.toLowerCase()))
  })

  // Цикл с поиском уровня (реальный паттерн из игр)
  let levelInfo = $derived.by(() => {
    const levels = theme.levels
    for (let i = levels.length - 1; i >= 0; i--) {
      if (unlockedCount >= levels[i].minUpgrades) return levels[i]
    }
    return levels[0]
  })

  // Сортировка массива
  let sorted = $derived.by(() =>
    [...data].sort((a, b) => {
      const cmp = a[sortField].localeCompare(b[sortField])
      return sortDir === 'asc' ? cmp : -cmp
    })
  )
</script>

Правила:

  • Ленивое вычисление — только при изменении зависимостей
  • Не может быть асинхронным (не await внутри)
  • Не мутировать состояние! $derived.by — только чтение и возврат значения
  • Ранние return в $derived.by разрешены и поощряются

$effect — побочные эффекты: полное руководство

Когда срабатывает $effect

Эффекты выполняются в микротаске после обновления DOM, батчатся, и отслеживают зависимости автоматически. Порядок жизненного цикла:

Монтирование:     setup → DOM-рендер → $effect (первый запуск)
Обновление:       state change → DOM-рендер → teardown старого эффекта → $effect
Размонтирование:  teardown → удаление DOM

Паттерн 1: DOM-класс и тема

<script>
  let isDark = $state(false)
  $effect(() => {
    document.documentElement.classList.toggle('dark', isDark)
  })
</script>

Паттерн 2: Слушатели событий с очисткой

<script>
  $effect(() => {
    function handler(e: Event) { e.preventDefault(); installable = true }
    window.addEventListener('beforeinstallprompt', handler)
    return () => window.removeEventListener('beforeinstallprompt', handler)
  })
</script>

Паттерн 3: Интервалы и таймеры (игровые циклы)

<script>
  $effect(() => {
    init()  // запускает tickTimer (100ms), saveTimer (10s), elapsedTimer (1s)
    return () => {
      clearInterval(tickTimer)
      clearInterval(saveTimer)
      clearInterval(elapsedTimer)
    }
  })
</script>

Паттерн 4: Условный эффект с early return

<script>
  let dropdownOpen = $state(false)
  let dropdownRef = $state<HTMLElement | null>(null)

  $effect(() => {
    if (!dropdownOpen) return              // early return = без подписок
    function handler(e: MouseEvent) {
      if (dropdownRef && !dropdownRef.contains(e.target as Node))
        dropdownOpen = false
    }
    function onKey(e: KeyboardEvent) {
      if (e.key === 'Escape') dropdownOpen = false
    }
    document.addEventListener('click', handler, true)
    document.addEventListener('keydown', onKey)
    return () => {
      document.removeEventListener('click', handler, true)
      document.removeEventListener('keydown', onKey)
    }
  })
</script>

Паттерн 5: Race conditions — поиск с отменой

<script>
  let query = $state('')
  let results = $state<Item[]>([])
  let loading = $state(false)

  $effect(() => {
    const controller = new AbortController()
    const currentQuery = query

    if (!currentQuery) {
      results = []
      return
    }

    loading = true
    fetch(`/api/search?q=${currentQuery}`, { signal: controller.signal })
      .then(r => r.json())
      .then(data => {
        results = data          // пришёл ответ на актуальный запрос
        loading = false
      })
      .catch(e => {
        if (e.name !== 'AbortError') {
          console.error(e)
          loading = false
        }
      })

    return () => controller.abort()      // отмена предыдущего запроса
  })
</script>

Паттерн 6: Media query — фабрика реактивных хуков

<script>
  function useMediaQuery(query: string) {
    let matches = $state(false)
    $effect(() => {
      const mq = window.matchMedia(query)
      matches = mq.matches
      function handler(e: MediaQueryListEvent) { matches = e.matches }
      mq.addEventListener('change', handler)
      return () => mq.removeEventListener('change', handler)
    })
    return () => matches       // возвращает геттер
  }

  const getIsMobile = useMediaQuery('(max-width: 1200px)')
  let isMobile = $state(false)
  $effect(() => { isMobile = getIsMobile() })

  {#if isMobile}
    <MobileNav />
  {/if}
</script>

Паттерн 7: Async init (fire-and-forget)

<script>
  let status = $state<'loading' | 'menu' | 'playing'>('loading')

  $effect(() => {
    initDB()
  })

  async function initDB() {
    try {
      await seedBuiltinDecks()
      status = 'menu'
    } catch {
      status = 'menu'
    }
  }
</script>

Паттерн 8: Синхронизация пропса с локальным состоянием

<script>
  let { value } = $props()
  let internalValue = $state(value)       // svelte-ignore state_referenced_locally
  $effect(() => { internalValue = value })  // зеркалируем изменения пропса
</script>

$effect.pre — до обновления DOM

<script>
  let items = $state([])
  let container = $state<HTMLElement | null>(null)
  let prevScrollHeight = 0

  $effect.pre(() => {
    if (container) prevScrollHeight = container.scrollHeight
  })

  $effect(() => {
    if (container) {
      container.scrollTop = container.scrollHeight - prevScrollHeight
    }
  })
</script>

$effect.root — ручное управление скоупом

import { $effect } from 'svelte/reactivity'

const cleanup = $effect.root(() => {
  $effect(() => { /* эффект 1 */ })
  $effect(() => { /* эффект 2 */ })
  // оба эффекта живут в общем скоупе
})

// Позже — вручную уничтожить все эффекты
cleanup()

$effect.tracking() — проверка контекста

import { $effect } from 'svelte/reactivity'

function maybeReactiveRead() {
  if ($effect.tracking()) {
    // Код читается внутри $effect или $derived — подписка активна
    return expensiveQuery()
  } else {
    // Код вызван из обработчика клика или onMount — подписка не нужна
    return cachedValue
  }
}

untrack() — чтение без подписки

<script>
  import { untrack } from 'svelte'
  let count = $state(0)
  let log = $state<string[]>([])

  $effect(() => {
    // count изменился → хотим записать в log, но БЕЗ подписки на log!
    untrack(() => {
      log = [...log, `count стал ${count}`]
    })
  })
</script>

$props — пропсы компонента

<!-- Стиль A: typed interface + destructure (основной в проектах) -->
<script lang="ts">
  interface Props {
    page: PageData
    menu?: MenuItem[]
    t?: (key: string, params?: Record<string, unknown>) => string
    onClose?: () => void
    children?: Snippet
    footer?: Snippet
  }
  let { page, menu = [], t = (k: string) => k, onClose, children, footer }: Props = $props()
</script>

<!-- Стиль B: inline generic (для простых компонентов) -->
<script lang="ts">
  let { count = $bindable(0), label = 'Count' } = $props<{
    count?: number
    label?: string
  }>()
</script>

<!-- Стиль C: js (без TS) -->
<script>
  let { initialCount = 0, label = 'Count', t = (k) => k } = $props()
</script>

Важно: деструктуризация пропсов в Svelte 5 не ломает реактивность (в отличие от Vue). Пропсы с коллбэками (onClose, onApply) — не $bindable, используются как обычные пропсы-функции.

Сниппеты: замена слотов

Сниппеты — это переиспользуемые блоки разметки внутри компонента, которые рендерятся через {@render}. Они заменили <slot> из Svelte 4: <slot> больше не существует в Svelte 5.

Базовое использование

{#snippet card(title, description)}
  <article class="card">
    <h2>{title}</h2>
    <p>{description}</p>
  </article>
{/snippet}

{@render card('Заголовок', 'Описание')}

Параметры: типы, дефолты, деструктуризация

Сниппеты — это функции разметки. Их параметры поддерживают:

  • типы (TypeScript)
  • значения по умолчанию
  • деструктуризацию (включая вложенную)
<!-- ✅ Типы + дефолты + деструктуризация -->
{#snippet card(
  { title, description = 'нет описания' }: { title: string; description?: string },
  variant: 'primary' | 'ghost' = 'primary'
)}
  <article class="card card--{variant}">
    <h2>{title}</h2>
    <p>{description}</p>
  </article>
{/snippet}

{@render card({ title: 'Hello' })}
{@render card({ title: 'Hi', description: 'опционально' }, 'ghost')}

Ограничения:

  • ❌ rest-параметры (...args) НЕ поддерживаются — сниппет не может принять произвольное число аргументов.
  • Параметры сниппета не реактивны сами по себе; если нужно производное значение — используйте $derived внутри тела сниппета (см. ниже про @const).

Область видимости и рекурсия

Сниппеты видны в своей лексической области (siblings и их дети). Сниппет может ссылаться на себя и на другие сниппеты того же scope — это даёт рекурсию «бесплатно»:

{#snippet blastoff()}<span>🚀</span>{/snippet}

{#snippet countdown(n: number)}
  {#if n > 0}
    <span>{n}...</span>
    {@render countdown(n - 1)}
  {:else}
    {@render blastoff()}
  {/if}
{/snippet}

{@render countdown(3)}
<!-- 3... 2... 1... 🚀 -->

Продвинутый: рекурсивное дерево (реальный паттерн из проекта)

{#snippet treeNode(key: string, val: any, path: string, depth: number)}
  {@const isContainer = typeof val === 'object' && val !== null}
  {@const isExpanded = expanded.has(path)}

  <div class="node" style="padding-left: {depth * 16}px">
    <button onclick={() => toggle(path)}>
      {isExpanded ? '▼' : '▶'} {key}
    </button>

    {#if isContainer && isExpanded}
      {#each Object.entries(val) as [k, v]}
        {@render treeNode(k, v, `${path}.${k}`, depth + 1)}
      {/each}
    {/if}
  </div>
{/snippet}

{@render treeNode('root', data, '$', 0)}

Сниппеты как пропсы — явный контракт

Сниппеты — это значения первого класса, их можно передавать как пропсы. Это и есть «явный контракт»: типы проверяются на этапе компиляции, а ?.() на этапе рендера.

<!-- Child.svelte — типизированный контракт -->
<script lang="ts">
  import type { Snippet } from 'svelte';

  let { children, footer }: { children: Snippet; footer?: Snippet } = $props();
</script>

<div class="modal-body">{@render children()}</div>
{#if footer}
  <div class="modal-footer">{@render footer()}</div>
{/if}

Snippet<Args> — generic, где Args — tuple типов параметров (сниппет может принимать несколько):

Тип Сигнатура Когда использовать
Snippet () => void Без параметров (children, header())
Snippet<[T]> (arg: T) => void Один параметр
Snippet<[T, U]> (a: T, b: U) => void Два параметра
Snippet<[T] | []> (arg?: T) => void Необязательный параметр

Generics: один тип для data и row

Чтобы row принимал именно тот тип, что лежит в data, используйте <script lang="ts" generics="T">:

<!-- Table.svelte -->
<script lang="ts" generics="T">
  import type { Snippet } from 'svelte';

  let {
    data,
    row
  }: {
    data: T[];
    row: Snippet<[T]>;
  } = $props();
</script>

<table>
  {#each data as item}
    {@render row(item)}     <!-- T подставлен автоматически -->
  {/each}
</table>

Два способа передать сниппет в компонент

Explicit (через пропс): — сниппет объявлен на стороне родителя и явно передан.

{#snippet header()}<th>fruit</th>{/snippet}
<!-- [archlet] Failed to render <Table/> -->

Implicit (как children): — сниппет объявлен внутри тега компонента и автоматически становится его пропсом.

<!-- [archlet] Failed to render <Table>...</Table> -->

Implicit children

Любой контент вне {#snippet} внутри <Component>...</Component> автоматически попадает в children:

<div id="modal-ztn78vq" class="archlet-modal" role="dialog" aria-modal="true" aria-labelledby="modal-ztn78vq-title" data-open="false">
<div class="archlet-modal-backdrop"></div>
<div class="archlet-modal-content">
<div class="archlet-modal-header">

<button class="archlet-modal-close" aria-label="Close">✕</button>
</div>
<div class="archlet-modal-body">
<p>Это станет children автоматически</p>
</div>
</div>
</div><script>
(function(){
const modal=document.getElementById('modal-ztn78vq');
if(!modal)return;
function close(){
modal.setAttribute('data-open','false');
modal.dispatchEvent(new CustomEvent('modal-close'));
document.body.style.overflow='';
}
function show(){
modal.setAttribute('data-open','true');
document.body.style.overflow='hidden';
const focusable=modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if(focusable)focusable.focus();
}
modal.querySelector('.archlet-modal-backdrop')?.addEventListener('click',function(e){
if(e.target===this)close();
});
modal.querySelector('.archlet-modal-close')?.addEventListener('click',close);
modal.addEventListener('keydown',function(e){
if(e.key==='Escape'){close();e.stopPropagation();}
if(e.key==='Tab'){
const focusable=modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
const first=focusable[0],last=focusable[focusable.length-1];
if(e.shiftKey&&document.activeElement===first){last.focus();e.preventDefault();}
if(!e.shiftKey&&document.activeElement===last){first.focus();e.preventDefault();}
}
});
window['__archlet_modal_modal-ztn78vq_show']=show;
window['__archlet_modal_modal-ztn78vq_close']=close;
})();
</script>

⚠️ Ловушка: если внутри компонента вы передаёте именованные сниппеты и обычный контент одновременно — обычный контент перезапишет children. Всегда либо оборачивайте в {#snippet children()}...{/snippet}, либо передавайте только через именованные сниппеты.

Опциональные сниппеты и fallback

Сниппет, объявленный как ?: в $props(), может быть undefined. Два способа обработать:

<!-- Способ 1: optional chaining — ничего не рендерим -->
{@render footer?.()}

<!-- Способ 2: fallback через {:else} (заменяет <slot>fallback</slot>) -->
{#if children}
  {@render children()}
{:else}
  <p>Контент не передан</p>
{/if}

Экспорт сниппетов между файлами

Сниппет можно объявить в <script module> и переиспользовать между файлами (Svelte ≥ 5.5):

<!-- snippets.svelte -->
<script module>
  export { add };
</script>

{#snippet add(a: number, b: number)}
  {a} + {b} = {a + b}
{/snippet}
<!-- App.svelte -->
<script>
  import { add } from './snippets.svelte';
</script>

{@render add(1, 2)}

Отличия от слотов Svelte 4

Svelte 4 Svelte 5 Зачем
<slot /> {@render children?.()} <slot> больше не существует
<slot name="header" /> {@render header?.()} Именованные → именованные сниппеты
<slot>fallback</slot> {#if children}{@render children()}{:else}fallback{/if} Дефолт через {:else}
<svelte:fragment slot="x">…</svelte:fragment> {#snippet x()}…{/snippet} Блочные сниппеты
let:item={row} (slot props) Передавайте данные через пропсы и рендерите сниппет Нет slot props
$$slots.header header !== undefined $$slots удалён
<slot> без параметров {#snippet name(a, b)} — может принимать произвольные параметры Сниппеты — функции
Неявный «магический» канал Сниппеты — обычные пропсы с типами Явный контракт на этапе компиляции

Ключевые преимущества сниппетов:

  • Принимают произвольные параметры с типами — как обычные функции
  • Передаются как обычные пропсы → автокомплит, типы, рефакторинг
  • {@render} принимает выражения: {@render (cond ? a : b)()}
  • Рекурсия и self-reference из коробки
  • Экспорт между файлами через <script module>

Шаблонный синтаксис

<!-- Условия -->
{#if count > 10}
  <p>Много: {count}</p>
{:else if count > 0}
  <p>Мало: {count}</p>
{:else}
  <p>Ноль</p>
{/if}

<!-- Циклы — key обязателен для производительности -->
{#each items as item, index (item.id)}
  <li>{index}: {item.name}</li>
{:else}
  <li>Список пуст</li>
{/each}

<!-- Await -->
{#await promise}
  <p>Загрузка...</p>
{:then value}
  <p>Результат: {value}</p>
{:catch error}
  <p>Ошибка: {error.message}</p>
{/await}

<!-- {#key} — принудительный перерендер при смене выражения -->
{#key item.id}
  <ExpensiveComponent {item} />
{/key}

<!-- @const — локальные константы -->
{@const total = items.reduce((s, i) => s + i.price, 0)}
<p>Итого: {total}</p>

<!-- ⚠️ @const — legacy. В новом коде используйте {const x = $derived(y)} -->
{@const area = box.width * box.height}            <!-- ❌ legacy -->
{const area = $derived(box.width * box.height)}  <!-- ✅ рекомендуется -->

<!-- @html — сырой HTML (осторожно с XSS!) -->
{@html rawHtml}

{@const} — ограничения и альтернатива:

{@const} допустим только как непосредственный потомок:

  • {#if ...}, {#each ...}, {#snippet ...}
  • <Component />
  • <svelte:boundary>

В других местах (между свободными тегами) — нельзя. В новом коде рекомендуется {const x = $derived(y)}:

Задача @const (legacy) Современный стиль
Локальная константа без зависимостей {@const x = 10} {const x = 10}
Производное значение (реактивно) {@const y = items.length} {const y = $derived(items.length)}
Сложная логика {@const z = items.reduce(...)} {const z = $derived.by(() => items.reduce(...))}

Директивы

<!-- bind:value — двустороннее связывание -->
<input bind:value={name} />
<textarea bind:value={text} />
<input type="checkbox" bind:checked={agree} />
<select bind:value={selected} />

<!-- bind:this — ссылка на DOM-элемент -->
<canvas bind:this={canvasRef}></canvas>
<!-- canvasRef: $state<HTMLCanvasElement | null> -->

<!-- События — HTML-атрибуты в Svelte 5 (не on:click!) -->
<button onclick={handler}>Клик</button>
<form onsubmit|preventDefault={handleSubmit}>
<div onkeydown|stopPropagation={handleKey}>
<!-- Модификаторы: |preventDefault, |stopPropagation, |once, |self, |trusted -->

<!-- class:active — условный класс -->
<div class:active={isActive} class:disabled={!enabled}></div>

<!-- use:action — действия над DOM -->
<div use:tooltip={{ text: 'Подсказка' }}></div>

Анимации и переходы

<script>
  import { fade, fly, slide, scale, blur, crossfade } from 'svelte/transition'
  import { cubicOut } from 'svelte/easing'
  import { flip } from 'svelte/animate'

  let visible = $state(true)
</script>

<!-- transition: двусторонний (in + out) -->
<div transition:fade={{ duration: 300 }}>
<div transition:fly={{ y: 200, duration: 500, easing: cubicOut }}>
<div transition:slide>
<div transition:scale={{ start: 0.5 }}>

<!-- in / out раздельно -->
<div in:fly={{ x: -200 }} out:slide>

<!-- Анимация перестановок в списке -->
{#each items as item (item.id)}
  <div animate:flip={{ duration: 300 }}>
    {item.name}
  </div>
{/each}

<!-- Кастомный переход -->
<script>
  function typewriter(node: HTMLElement, { speed = 50 }: { speed?: number }) {
    const text = node.textContent || ''
    node.textContent = ''
    let i = 0
    const interval = setInterval(() => {
      if (i < text.length) node.textContent += text[i++]
      else clearInterval(interval)
    }, speed)
    return {
      duration: text.length * speed,
      tick: (t: number) => { /* прогресс 0..1 */ }
    }
  }
</script>
<div transition:typewriter={{ speed: 30 }}>Текст печатается...</div>

Действия (actions)

<script>
  function tooltip(node: HTMLElement, options: { text: string }) {
    const tip = document.createElement('div')
    tip.textContent = options.text
    tip.className = 'tooltip'

    function show() { node.parentNode?.appendChild(tip) }
    function hide() { tip.remove() }

    node.addEventListener('mouseenter', show)
    node.addEventListener('mouseleave', hide)

    return {
      update(newOpts: { text: string }) { tip.textContent = newOpts.text },
      destroy() {
        node.removeEventListener('mouseenter', show)
        node.removeEventListener('mouseleave', hide)
        tip.remove()
      }
    }
  }
</script>

<div use:tooltip={{ text: 'Подсказка при наведении' }}>Наведи на меня</div>

Специальные элементы

<!-- <svelte:boundary> — граница ошибок (как error boundary в React) -->
<svelte:boundary>
  <ComponentThatMayThrow />
  {#snippet failed(error, reset)}
    <div class="error">
      <p>Ошибка: {error.message}</p>
      <button onclick={reset}>Попробовать снова</button>
    </div>
  {/snippet}
</svelte:boundary>

<!-- <svelte:window> — события окна -->
<svelte:window onkeydown={handleKey} onresize={handleResize} />

<!-- <svelte:head> — элементы в <head> -->
<svelte:head>
  <title>{page.title}</title>
  <meta name="description" content={page.description} />
</svelte:head>

<!-- <svelte:element> — динамический тег -->
<script>
  let tag = $state<'h1' | 'h2' | 'h3'>('h1')
</script>
<svelte:element this={tag}>Динамический заголовок</svelte:element>

<!-- <svelte:options> — опции компиляции -->
<svelte:options immutable={true} accessors={true} />

Сторы (stores) и контекст

Writable store и мост в руны

import { writable } from 'svelte/store'
import { fromStore, toStore } from 'svelte/store'

// Svelte 4 store
export const count = writable(0)

// В Svelte 5: мост store → руны
let countRune = fromStore(count)     // реактивный $state из store
let countStore = toStore(countRune)  // обратно: $state → store

Модульный стор на $state (реальный паттерн)

// stores/counter.svelte.ts
export const counter = $state({
  count: 0,
  label: "Счётчик",
})

export function increment() { counter.count += 1 }
export function decrement() { counter.count -= 1 }
export function reset() { counter.count = 0 }

// Использование — прямой импорт, без контекста/пропсов
import { counter, increment } from './stores/counter.svelte.ts'

Фабрика с геттерами (устойчивость к HMR)

// stores/createCounter.svelte.ts
export function createCounter(initial = 0) {
  let count = $state(initial)
  let doubled = $derived(count * 2)

  function increment() { count++ }
  function reset() { count = initial }

  return {
    get count() { return count },        // геттеры — защита от HMR-потерь
    get doubled() { return doubled },
    increment,
    reset
  }
}

Контекст (setContext / getContext)

<!-- Предок -->
<script>
  import { setContext } from 'svelte'
  const theme = $state({ mode: 'dark' })
  setContext('theme', theme)
</script>

<!-- Потомок -->
<script>
  import { getContext } from 'svelte'
  const theme = getContext<{ mode: string }>('theme')
</script>
<p>Тема: {theme.mode}</p>

createSubscriber — мост к внешним API

// media-query.svelte.ts
import { createSubscriber } from 'svelte/reactivity'

export class MediaQuery {
  #mq: MediaQueryList
  #subscribe: () => void

  constructor(query: string) {
    this.#mq = window.matchMedia(query)
    this.#subscribe = createSubscriber((update) => {
      this.#mq.addEventListener('change', update)
      return () => this.#mq.removeEventListener('change', update)
    })
  }

  get matches(): boolean {
    this.#subscribe()              // регистрирует зависимость при чтении
    return this.#mq.matches
  }
}

// WebSocket — другой пример
class ReactiveWS {
  #ws: WebSocket
  #subscribe: () => void
  #data = $state<any>(null)

  constructor(url: string) {
    this.#ws = new WebSocket(url)
    this.#subscribe = createSubscriber((update) => {
      this.#ws.addEventListener('message', (e) => {
        this.#data = JSON.parse(e.data)
        update()
      })
      return () => this.#ws.close()
    })
  }

  get data() { this.#subscribe(); return this.#data }
}

SvelteKit: ключевые концепции

// +page.ts — загрузка данных
export const load = async ({ params, url, fetch }) => {
  const res = await fetch(`/api/user/${params.id}`)
  return { user: await res.json() }
}

// +page.server.ts — серверная загрузка (секреты, БД)
export const load = async ({ locals }) => {
  const db = await connectDB()
  return { users: await db.query('SELECT * FROM users') }
}

// +page.svelte — страница
<script>
  let { data } = $props()  // data приходит из load-функции
</script>
<h1>{data.user.name}</h1>

// actions — серверные действия (формы)
export const actions = {
  default: async ({ request }) => {
    const formData = await request.formData()
    const name = formData.get('name')
    // сохранить в БД
    return { success: true }
  }
}
<!-- +page.svelte — форма с actions -->
<script>
  let { form } = $props()
</script>

<form method="POST">
  <input name="name" />
  <button>Отправить</button>
</form>

{#if form?.success}
  <p>Сохранено!</p>
{/if}

Переход со Svelte 4 на Svelte 5

Svelte 4 Svelte 5 Примечание
let count = 0 let count = $state(0) Явная реактивность
$: doubled = count * 2 let doubled = $derived(count * 2) Простое выражение
$: { сложная логика } $derived.by(() => { ... }) Многострочная логика
$: { сайд-эффект } $effect(() => { ... }) С очисткой через return
export let name let { name } = $props() Деструктуризация
export let age = 18 let { age = 18 } = $props() Значения по умолчанию
<slot /> {@render children?.()} Сниппеты
<slot name="header" /> {@render header?.()} Именованные сниппеты
{@const x = y} {const x = $derived(y)} @const помечен legacy
on:click={handler} onclick={handler} Без двоеточия
on:click|preventDefault={h} onclick|preventDefault={h} Синтаксис сохранён
$store (автоподписка) fromStore(store) Явный мост
beforeUpdate / afterUpdate $effect.pre / $effect Через руны

Реальные паттерны из проектов

<!-- Паттерн: localStorage + $state (используется в 15+ инструментах) -->
<script>
  let data = $state(loadFromStorage('my-tool', defaultState))

  function setCell(key: string, value: string) {
    if (value === '') {
      const next = { ...data.cells }; delete next[key]
      data = { ...data, cells: next }      // новый объект → триггер
    } else {
      data = { ...data, cells: { ...data.cells, [key]: value } }
    }
    saveToStorage('my-tool', data)         // персистентность
  }
</script>

<!-- Паттерн: один иконный компонент с async-загрузкой -->
<script>
  import { onMount } from 'svelte'

  let { name, size = 24 } = $props()
  let iconHtml = $state('')
  let loaded = $state(false)

  onMount(async () => {
    try {
      iconHtml = await fetchAndRenderIcon(name, { size })
    } catch { /* fallback-иконка */ }
    loaded = true
  })
</script>

{#if loaded}
  {@html iconHtml}
{/if}

<!-- Паттерн: i18n с fallback-переводчиком -->
<script>
  let { t = (k: string) => k } = $props()
</script>
<span>{t('hello.world')}</span>

Частые проблемы и их решения

Проблема ❌ Ошибка ✅ Решение
$state в .ts/.js файле let x = $state(0) в utils.ts Переименовать в utils.svelte.ts
$derived не обновляется после мутации Деструктуризация const [first] = items Обращаться через items[0], не деструктурировать
Бесконечный цикл в $effect Запись в state, от которого зависит effect untrack(() => { setState(value) })
Race condition: ответ пришёл на старый запрос Нет отмены fetch в $effect AbortController + return cleanup
Двойное срабатывание $effect в dev StrictMode — намеренно В production однократно
Смешанный режим (runes + legacy) let x = 0; let y = $state(0) Полная миграция файла на руны
HMR теряет состояние в .svelte.ts Vite пересоздаёт модуль Фабрики с геттерами (createX())
$effect не ждёт async $effect(async () => { await fn() }) .then() внутри синхронного $effect
$props() rest мутабелен rest.class = 'x' — сбросится Не мутировать rest-объект
Деструктуризация $state объекта const { name } = $state({name: 'A'}) const obj = $state(...); obj.name
$derived не реагирует на push в $state.raw push мутирует, но Proxy нет Заменять ссылку: data = [...data, item]

Полезные ресурсы


Что дальше

Связанные шпаргалки: React, Vue 3, TypeScript, Git Advanced, Docker