Перейти к содержимому

10. Derived и кастомные Stores

Если writable — это «ящик с данными», то derived — это вычислительная машина, которая автоматически пересчитывает значение, когда исходные данные меняются. Думай о derived как о формуле в Excel: ты вводишь числа в ячейки, а формула пересчитывает результат сама 🧮


Представь: у тебя есть список задач и ты хочешь показать количество выполненных. Можно хранить отдельный счётчик и обновлять его вручную… или просто вычислять из основного списка:

Без derived: С derived:
let todos = writable([...]) const todos = writable([...])
let completed = writable(0) const completed = derived(
todos,
// Не забудь обновить! t => t.filter(t => t.done).length
completed.set(newCount) ) // Автоматически!

derived даёт синхронизацию бесплатно — никаких ручных обновлений!


import { writable, derived } from 'svelte/store'
// Исходный store
const count = writable(0)
// Производный store — удваивает значение
const doubled = derived(count, $count => $count * 2)
// Сколько угодно производных от одного источника!
const tripled = derived(count, $count => $count * 3)
const isEven = derived(count, $count => $count % 2 === 0)
const absolute = derived(count, $count => Math.abs($count))
// В компоненте — подписываемся как обычно
// $doubled, $tripled, $isEven обновляются автоматически!
<script>
import { writable, derived } from 'svelte/store'
const temperature = writable(0) // Цельсий
const fahrenheit = derived(
temperature,
$temp => ($temp * 9) / 5 + 32
)
const kelvin = derived(
temperature,
$temp => $temp + 273.15
)
</script>
<input type="number" bind:value={$temperature} />
<p>Цельсий: {$temperature}°C</p>
<p>Фаренгейт: {$fahrenheit.toFixed(1)}°F</p>
<p>Кельвин: {$kelvin.toFixed(2)}K</p>

Если вычисление синхронное — первое значение появляется сразу. Но можно явно задать начальное:

const count = writable(5)
// Без начального значения — derived начинает с undefined пока не вычислится
const doubled = derived(count, $count => $count * 2)
// С явным начальным значением
const doubledWithInit = derived(
count,
($count) => $count * 2,
0 // начальное значение — третий аргумент
)

Вот где начинается магия! derived может принимать массив stores:

import { writable, derived } from 'svelte/store'
const firstName = writable('Яша')
const lastName = writable('Иванов')
const age = writable(10)
// Берём из нескольких источников!
const fullName = derived(
[firstName, lastName],
([$firstName, $lastName]) => `${$firstName} ${$lastName}`
)
const greeting = derived(
[firstName, age],
([$firstName, $age]) => {
if ($age < 18) return `Привет, ${$firstName}! 👋`
return `Добро пожаловать, ${$firstName}!`
}
)
// Можно комбинировать любые stores
const userProfile = derived(
[firstName, lastName, age],
([$firstName, $lastName, $age]) => ({
fullName: `${$firstName} ${$lastName}`,
isAdult: $age >= 18,
initials: `${$firstName[0]}${$lastName[0]}`,
})
)
<script>
// Корзина и скидки из разных частей приложения
const cartItems = writable<CartItem[]>([])
const discountPercent = writable(0)
const promoCode = writable<string | null>(null)
const cartSummary = derived(
[cartItems, discountPercent, promoCode],
([$items, $discount, $promo]) => {
const subtotal = $items.reduce((sum, item) => sum + item.price * item.qty, 0)
const discountAmount = subtotal * ($discount / 100)
const promoDiscount = $promo === 'YASHA10' ? subtotal * 0.1 : 0
const total = subtotal - discountAmount - promoDiscount
return {
subtotal,
discountAmount,
promoDiscount,
total,
itemCount: $items.reduce((sum, item) => sum + item.qty, 0),
}
}
)
</script>
<p>Товаров: {$cartSummary.itemCount}</p>
<p>Итого: {$cartSummary.total.toFixed(2)} ₽</p>

Иногда вычисление производного значения требует асинхронного запроса. Для этого в derived предусмотрен callback с set:

import { writable, derived } from 'svelte/store'
const userId = writable<number>(1)
// Async derived: вторая функция получает (stores, set) вместо просто stores
const userProfile = derived(
userId,
($userId, set) => {
// Немедленно устанавливаем состояние загрузки
set({ loading: true, data: null, error: null })
const controller = new AbortController()
fetch(`https://jsonplaceholder.typicode.com/users/${$userId}`, {
signal: controller.signal,
})
.then(res => res.json())
.then(data => set({ loading: false, data, error: null }))
.catch(err => {
if (err.name !== 'AbortError') {
set({ loading: false, data: null, error: err.message })
}
})
// Функция очистки — вызывается при смене userId!
return () => controller.abort()
},
{ loading: true, data: null, error: null } // начальное значение
)
<script>
import { writable, derived } from 'svelte/store'
const searchQuery = writable('')
const searchResults = derived(
searchQuery,
($query, set) => {
if (!$query.trim()) {
set([])
return
}
const timer = setTimeout(() => {
fetch(`/api/search?q=${encodeURIComponent($query)}`)
.then(r => r.json())
.then(results => set(results))
.catch(() => set([]))
}, 300) // debounce встроенный!
// Очистка: отменяем таймер если query изменился
return () => clearTimeout(timer)
},
[] as SearchResult[]
)
</script>
<input bind:value={$searchQuery} placeholder="Поиск..." />
{#each $searchResults as result}
<li>{result.title}</li>
{/each}

Custom store — это объект с методом subscribe (обязательно!) плюс любые методы, которые ты захочешь. Svelte автоматически определяет store по наличию subscribe:

import { writable } from 'svelte/store'
import type { Writable } from 'svelte/store'
// Простой custom store — обёртка над writable с ограниченным API
function createCounter(initialValue = 0) {
const { subscribe, set, update } = writable(initialValue)
return {
subscribe, // ОБЯЗАТЕЛЬНО! Иначе не store
increment: () => update(n => n + 1),
decrement: () => update(n => n - 1),
reset: () => set(0),
setValue: (n: number) => set(n),
}
}
// Использование
const counter = createCounter(10)
// $counter — реактивно!
// counter.increment() — встроенный метод
// НЕТ прямого counter.set() — API ограничен!
<script>
const counter = createCounter(0)
</script>
<button on:click={counter.decrement}>−</button>
<span>{$counter}</span>
<button on:click={counter.increment}>+</button>
<button on:click={counter.reset}>Reset</button>

import { writable } from 'svelte/store'
interface Todo {
id: number
text: string
done: boolean
}
function createTodoStore() {
const { subscribe, update, set } = writable<Todo[]>([])
let nextId = 1
return {
subscribe,
add(text: string) {
update(todos => [...todos, { id: nextId++, text, done: false }])
},
toggle(id: number) {
update(todos =>
todos.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
)
},
remove(id: number) {
update(todos => todos.filter(todo => todo.id !== id))
},
clear() {
set([])
},
clearCompleted() {
update(todos => todos.filter(todo => !todo.done))
},
}
}
export const todos = createTodoStore()

import { writable, derived } from 'svelte/store'
function createUndoable<T>(initialValue: T) {
const history = writable<T[]>([initialValue])
const cursor = writable(0) // текущая позиция в истории
// Текущее значение — производное от истории и курсора
const current = derived(
[history, cursor],
([$history, $cursor]) => $history[$cursor]
)
const canUndo = derived(cursor, $cursor => $cursor > 0)
const canRedo = derived(
[history, cursor],
([$history, $cursor]) => $cursor < $history.length - 1
)
return {
subscribe: current.subscribe,
canUndo: canUndo,
canRedo: canRedo,
set(value: T) {
history.update($history => {
let $cursor = 0
cursor.update(c => { $cursor = c; return c; })
// Обрезаем будущее (как в любом редакторе)
const newHistory = [...$history.slice(0, $cursor + 1), value]
cursor.set(newHistory.length - 1)
return newHistory
})
},
undo() {
cursor.update(c => Math.max(0, c - 1))
},
redo() {
history.update($history => {
cursor.update(c => Math.min($history.length - 1, c + 1))
return $history
})
},
}
}
// Использование
const text = createUndoable('')
// $text — текущий текст
// text.set('новый') — изменить с записью в историю
// text.undo() — отменить
// text.redo() — повторить
// $text.canUndo — можно ли отменить?

function createPaginationStore(totalItems: number, itemsPerPage = 10) {
const { subscribe, update, set } = writable({
currentPage: 1,
itemsPerPage,
totalItems,
})
const pagination = {
subscribe,
// Производные вычисления
get totalPages() {
let state = { currentPage: 1, itemsPerPage, totalItems }
subscribe(s => { state = s })()
return Math.ceil(state.totalItems / state.itemsPerPage)
},
setPage(page: number) {
update(s => ({
...s,
currentPage: Math.max(1, Math.min(page, Math.ceil(s.totalItems / s.itemsPerPage))),
}))
},
nextPage() { this.setPage(this.page + 1) },
prevPage() { this.setPage(this.page - 1) },
setTotalItems(total: number) {
update(s => ({ ...s, totalItems: total, currentPage: 1 }))
},
getRange() {
let state = { currentPage: 1, itemsPerPage, totalItems }
subscribe(s => { state = s })()
const start = (state.currentPage - 1) * state.itemsPerPage
const end = Math.min(start + state.itemsPerPage, state.totalItems)
return { start, end }
},
}
return pagination
}

import { writable, derived } from 'svelte/store'
interface FormField {
value: string
error: string | null
touched: boolean
}
type FormSchema<T extends Record<string, unknown>> = {
[K in keyof T]: {
initial: string
validate: (value: string) => string | null
}
}
function createFormStore<T extends Record<string, string>>(
schema: FormSchema<T>
) {
const keys = Object.keys(schema) as (keyof T)[]
const fields = writable<Record<keyof T, FormField>>(
Object.fromEntries(
keys.map(key => [
key,
{ value: schema[key].initial, error: null, touched: false },
])
) as Record<keyof T, FormField>
)
const isValid = derived(fields, $fields =>
keys.every(key => {
const field = $fields[key]
const error = schema[key].validate(field.value)
return error === null
})
)
const isDirty = derived(fields, $fields =>
keys.some(key => $fields[key].value !== schema[key].initial)
)
return {
fields,
isValid,
isDirty,
setValue(key: keyof T, value: string) {
fields.update($fields => ({
...$fields,
[key]: {
...$fields[key],
value,
touched: true,
error: schema[key].validate(value),
},
}))
},
touch(key: keyof T) {
fields.update($fields => ({
...$fields,
[key]: {
...$fields[key],
touched: true,
error: schema[key].validate($fields[key].value),
},
}))
},
reset() {
fields.set(
Object.fromEntries(
keys.map(key => [
key,
{ value: schema[key].initial, error: null, touched: false },
])
) as Record<keyof T, FormField>
)
},
getValues() {
let result = {} as T
fields.subscribe($fields => {
result = Object.fromEntries(
keys.map(key => [key, $fields[key].value])
) as T
})()
return result
},
}
}
// Использование!
const loginForm = createFormStore({
email: {
initial: '',
validate: (v) => v.includes('@') ? null : 'Некорректный email',
},
password: {
initial: '',
validate: (v) => v.length >= 6 ? null : 'Минимум 6 символов',
},
})

Когда данные должны меняться только внутри (внешний код не может set):

import { readable } from 'svelte/store'
// Текущее время — меняется само, нельзя изменить снаружи
const time = readable(new Date(), function start(set) {
const interval = setInterval(() => {
set(new Date())
}, 1000)
// Функция очистки
return function stop() {
clearInterval(interval)
}
})
// Состояние геолокации
const geolocation = readable<GeolocationPosition | null>(null, set => {
const id = navigator.geolocation.watchPosition(
position => set(position),
() => set(null)
)
return () => navigator.geolocation.clearWatch(id)
})

Тебе нужно хранить данные?
├─ Данные меняются СНАРУЖИ компонента?
│ ├─ ДА → writable()
│ └─ НЕТ → readable()
├─ Данные ВЫЧИСЛЯЮТСЯ из других stores?
│ └─ derived()
├─ Нужны КАСТОМНЫЕ МЕТОДЫ (add, remove, toggle)?
│ └─ createCustomStore() — фабричная функция
└─ Данные нужны только ВНУТРИ компонента?
└─ Просто let в <script> — это реактивно в Svelte!
Подсказка: Начни с writable. Если:
- Нужно вычислять → derived
- Нужны методы → custom store
- Нельзя изменить снаружи → readable
- Только в компоненте → let (Svelte реактивность)

stores/counter.ts
// ❌ Синглтон — одно состояние на всё приложение
export const counter = writable(0) // Один экземпляр на всех!
// ✅ Фабрика — каждый компонент получает свой экземпляр
// stores/counter.ts
export function createCounter(initial = 0) {
return writable(initial)
}
// В компоненте:
// const myCounter = createCounter(5) // Свой счётчик!

import { count } from './stores'
// Подписка с немедленным вызовом
const unsubscribe = count.subscribe(value => {
console.log('count изменился:', value)
})
// Не забудь отписаться! (Svelte делает это автоматически в компоненте)
unsubscribe()
// Получить текущее значение БЕЗ подписки
let currentValue: number
const unsub = count.subscribe(v => currentValue = v)
unsub() // Отписываемся сразу
// currentValue теперь содержит текущее значение

import { writable, derived, readable } from 'svelte/store'
import type { Readable, Writable } from 'svelte/store'
// Явная типизация
const count: Writable<number> = writable(0)
const name: Writable<string> = writable('')
const user: Writable<User | null> = writable(null)
// Typed custom store
interface CounterStore extends Readable<number> {
increment: () => void
decrement: () => void
reset: () => void
}
function createTypedCounter(initial = 0): CounterStore {
const { subscribe, update, set } = writable(initial)
return {
subscribe,
increment: () => update(n => n + 1),
decrement: () => update(n => n - 1),
reset: () => set(0),
}
}