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

9. Stores (хранилища)

Пропсы отлично работают для передачи данных между родителем и дочерним компонентом. Но что если нужно поделиться состоянием между компонентами, которые вообще не связаны в дереве? Вот тут-то и приходят на помощь Svelte Stores — реактивные хранилища данных, живущие вне компонентов! 🚀


Store — это объект с методом subscribe, который хранит значение и уведомляет всех подписчиков при его изменении. Это не компонент, не хук — просто реактивный контейнер данных.

┌─────────────────────────────────┐
│ Store (writable) │
│ │
│ value: 42 │
│ │
│ subscribe(fn) ─────────────► Компонент A
│ set(newVal) ─────────────► Компонент B
│ update(fn) ─────────────► Компонент C
└─────────────────────────────────┘

Любой объект, у которого есть метод subscribe(callback), является Store по контракту Svelte. Это открывает огромные возможности для кастомизации!


writable(initialValue) создаёт Store с тремя методами:

stores.js
import { writable } from 'svelte/store';
// Создаём store с начальным значением 0
export const count = writable(0);
МетодОписаниеПример
set(value)Заменяет значениеcount.set(42)
update(fn)Обновляет на основе текущегоcount.update(n => n + 1)
subscribe(fn)Подписывается на измененияcount.subscribe(v => console.log(v))

Чтобы понять, как работает Store изнутри, посмотрим на ручную подписку:

App.svelte
<script>
import { count } from './stores.js';
import { onDestroy } from 'svelte';
let localCount;
// Подписываемся: callback вызывается сразу с текущим значением,
// и затем при каждом изменении
const unsubscribe = count.subscribe(value => {
localCount = value;
});
// ВАЖНО: отписаться при уничтожении компонента!
onDestroy(unsubscribe);
</script>
<p>Текущее значение: {localCount}</p>
<button on:click={() => count.update(n => n + 1)}>+1</button>

Это работает, но довольно многословно. К счастью, Svelte придумал кое-что получше…


Svelte предоставляет специальный синтаксис: приставка $ перед именем Store. Компилятор автоматически:

  • Подписывается при монтировании компонента
  • Отписывается при уничтожении
  • Обновляет шаблон при каждом изменении
<script>
import { count } from './stores.js';
// Больше никаких subscribe/unsubscribe вручную!
</script>
<!-- $count = автоматически подписанное значение -->
<p>Значение: {$count}</p>
<button on:click={() => $count++}>+1 (прямое присваивание!)</button>
<button on:click={() => count.set(0)}>Сброс</button>

💡 Wow-момент: $count++ не просто читает значение — Svelte компилирует это в вызов count.set($count + 1) автоматически!


<script>
import { count } from './stores.js';
// 1. Через set()
function reset() { count.set(0); }
// 2. Через update()
function increment() { count.update(n => n + 1); }
function double() { count.update(n => n * 2); }
// 3. Через $ (прямое присваивание — самый элегантный способ!)
function setTen() { $count = 10; }
function incrementDirect() { $count++; }
function decrementDirect() { $count--; }
</script>
<p>Count: {$count}</p>
<button on:click={reset}>Reset</button>
<button on:click={increment}>+1 (update)</button>
<button on:click={double}>×2 (update)</button>
<button on:click={setTen}>= 10 (set)</button>
<button on:click={incrementDirect}>+1 ($)</button>

readable(initial, start) создаёт Store, в который внешний код не может записывать данные. Только внутренний producer изменяет значение. Идеально для: текущего времени, геолокации, размера окна.

stores.js
import { readable } from 'svelte/store';
// Часы — классический пример readable store
export const time = readable(new Date(), function start(set) {
// start() вызывается, когда появляется первый подписчик
const interval = setInterval(() => {
set(new Date());
}, 1000);
// stop() вызывается, когда подписчиков не осталось
return function stop() {
clearInterval(interval);
};
});
<script>
import { time } from './stores.js';
const formatter = new Intl.DateTimeFormat('ru', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
</script>
<!-- time обновляется каждую секунду, cleanup автоматический -->
<p>Сейчас: {formatter.format($time)}</p>
import { readable } from 'svelte/store';
export const geolocation = readable(null, (set) => {
if (!navigator.geolocation) {
set({ error: 'Геолокация недоступна' });
return () => {};
}
const watchId = navigator.geolocation.watchPosition(
(pos) => set({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
(err) => set({ error: err.message })
);
return () => navigator.geolocation.clearWatch(watchId);
});

Любой объект с методом subscribe(fn) является валидным Store в Svelte! subscribe должен:

  1. Вызвать fn немедленно с текущим значением
  2. Возвращать функцию unsubscribe
  3. Вызывать fn при каждом изменении
// Минимальный store вручную:
function createMinimalStore(value) {
const subscribers = new Set();
return {
subscribe(fn) {
fn(value); // 1. Сразу вызываем с текущим значением
subscribers.add(fn);
return () => subscribers.delete(fn); // 2. Возвращаем unsubscribe
},
set(newValue) {
value = newValue;
subscribers.forEach(fn => fn(value)); // 3. Уведомляем всех
},
};
}
const myStore = createMinimalStore(42);

Самый мощный паттерн — обернуть writable и добавить свою бизнес-логику:

stores.js
import { writable } from 'svelte/store';
function createCounter(initial = 0) {
const { subscribe, set, update } = writable(initial);
return {
subscribe, // Обязательно экспортируем!
increment: () => update(n => n + 1),
decrement: () => update(n => n - 1),
reset: () => set(initial),
setTo: (n) => set(n),
};
}
export const counter = createCounter(0);
<script>
import { counter } from './stores.js';
</script>
<p>Счётчик: {$counter}</p>
<button on:click={counter.increment}>+1</button>
<button on:click={counter.decrement}>−1</button>
<button on:click={counter.reset}>Сброс</button>
<button on:click={() => counter.setTo(100)}>= 100</button>
cartStore.js
import { writable } from 'svelte/store';
function createCart() {
const { subscribe, update, set } = writable([]);
return {
subscribe,
addItem(item) {
update(items => {
const existing = items.find(i => i.id === item.id);
if (existing) {
return items.map(i =>
i.id === item.id ? { ...i, qty: i.qty + 1 } : i
);
}
return [...items, { ...item, qty: 1 }];
});
},
removeItem(id) {
update(items => items.filter(i => i.id !== id));
},
updateQty(id, qty) {
if (qty <= 0) { this.removeItem(id); return; }
update(items => items.map(i => i.id === id ? { ...i, qty } : i));
},
clear: () => set([]),
};
}
export const cart = createCart();

Svelte предоставляет типы из svelte/store:

stores.ts
import { writable, readable } from 'svelte/store';
import type { Writable, Readable } from 'svelte/store';
// Типизированный writable
export const count: Writable<number> = writable(0);
// Типизированный readable
export const time: Readable<Date> = readable(new Date(), (set) => {
const interval = setInterval(() => set(new Date()), 1000);
return () => clearInterval(interval);
});
// Кастомный интерфейс
interface User {
id: number;
name: string;
email: string;
}
export const currentUser: Writable<User | null> = writable(null);
// Тип для кастомного store
interface CartStore extends Readable<CartItem[]> {
addItem: (item: Product) => void;
removeItem: (id: number) => void;
clear: () => void;
}

Store как модуль — разделяем между компонентами 📁

Заголовок раздела «Store как модуль — разделяем между компонентами 📁»

Правильный способ — вынести stores в отдельный файл:

src/
├── stores/
│ ├── auth.ts // authStore, currentUser
│ ├── cart.ts // cartStore
│ ├── ui.ts // themeStore, sidebarOpen
│ └── index.ts // реэкспорт всего
├── components/
│ ├── Header.svelte // использует authStore, cartStore
│ ├── Cart.svelte // использует cartStore
│ └── UserMenu.svelte // использует authStore
stores/auth.ts
import { writable, derived } from 'svelte/store';
export const currentUser = writable<User | null>(null);
// derived store — вычисляется из другого
export const isLoggedIn = derived(currentUser, $user => $user !== null);
export const userName = derived(currentUser, $user => $user?.name ?? 'Гость');

derived(store, fn) создаёт Store, вычисляемый на основе другого. Подробно разберём в следующем уроке, но вот быстрый пример:

import { writable, derived } from 'svelte/store';
const cartItems = writable([
{ name: 'Книга', price: 500, qty: 2 },
{ name: 'Кружка', price: 300, qty: 1 },
]);
// Автоматически пересчитывается при изменении cartItems
const totalPrice = derived(cartItems, $items =>
$items.reduce((sum, item) => sum + item.price * item.qty, 0)
);
const itemCount = derived(cartItems, $items =>
$items.reduce((sum, item) => sum + item.qty, 0)
);
<p>Товаров: {$itemCount}</p>
<p>Итого: {$totalPrice} ₽</p>

Store отлично сочетаются с реактивными блоками $::

<script>
import { cartItems, promoCode } from './stores.js';
// Реактивно пересчитывается при изменении $cartItems или $promoCode
$: subtotal = $cartItems.reduce((sum, i) => sum + i.price * i.qty, 0);
$: discount = $promoCode === 'SVELTE30' ? 0.3 : 0;
$: total = subtotal * (1 - discount);
$: if ($cartItems.length > 10) {
console.log('Богатый покупатель!', $cartItems.length, 'товаров');
}
</script>
<p>Подытог: {subtotal} ₽</p>
<p>Скидка: {discount * 100}%</p>
<p>Итого: {total} ₽</p>

Когда использовать Store, а когда — props/context? 🤔

Заголовок раздела «Когда использовать Store, а когда — props/context? 🤔»
ПодходКогда использовать
PropsДанные от родителя к дочернему компоненту
StoreОбщие данные между несвязанными компонентами
ContextДанные для поддерева компонентов (без глобальности)
URL/paramsДанные, которые должны быть в адресной строке
✅ Store — правильный выбор:
- Авторизованный пользователь (нужен везде)
- Корзина покупок (Header + Cart + OrderPage)
- Тема оформления (весь UI)
- Уведомления (глобальная очередь)
✅ Props — правильный выбор:
- Список товаров передаётся в <ProductGrid>
- Цвет кнопки передаётся в <Button>
- Данные пагинации в <Table>
✅ Context — правильный выбор:
- Конфигурация формы для всех полей внутри <Form>
- Тема для виджета (не всего сайта)

Svelte StoreReact ContextZustandVue PiniaRedux
Размер~0kb (встроен)~0kb (встроен)~1kb~1kb~40kb
Синтаксис$storeuseContextuseStorestore.countuseSelector
Шаблонный кодМинимумНемногоНемногоСреднеМного
DevTools⚠️
TypeScript
AsyncРучнойРучнойВстроенВстроенRedux Thunk

Svelte Store выигрывает по простоте — $count++ vs dispatch(increment()) в Redux! 😄


В Svelte 5 появились runes — более мощная система реактивности. Stores не исчезают, но для локального состояния рекомендуется $state:

<!-- Svelte 5 -->
<script>
// Локальное состояние компонента
let count = $state(0);
// Для глобального состояния — Stores по-прежнему актуальны!
import { cart } from './stores.js';
</script>
// Svelte 5: universal reactivity (без компонентов)
import { writable } from 'svelte/store';
// Stores работают везде — в .svelte, .js, .ts файлах
export const theme = writable('dark');

stores/auth.ts
import { writable } from 'svelte/store';
function createAuthStore() {
// Читаем из localStorage при старте
const saved = localStorage.getItem('user');
const initial = saved ? JSON.parse(saved) : null;
const { subscribe, set, update } = writable(initial);
return {
subscribe,
login(user: User) {
localStorage.setItem('user', JSON.stringify(user));
set(user);
},
logout() {
localStorage.removeItem('user');
set(null);
},
updateProfile(changes: Partial<User>) {
update(user => {
const updated = { ...user!, ...changes };
localStorage.setItem('user', JSON.stringify(updated));
return updated;
});
},
};
}
export const auth = createAuthStore();
stores/notifications.ts
import { writable } from 'svelte/store';
export type NotificationType = 'success' | 'error' | 'info' | 'warning';
export interface Notification {
id: number;
type: NotificationType;
message: string;
}
let nextId = 0;
function createNotificationStore() {
const { subscribe, update } = writable<Notification[]>([]);
return {
subscribe,
add(type: NotificationType, message: string, duration = 4000) {
const id = nextId++;
update(list => [...list, { id, type, message }]);
// Автоудаление через duration мс
setTimeout(() => this.remove(id), duration);
},
remove(id: number) {
update(list => list.filter(n => n.id !== id));
},
success: (msg: string) => this.add('success', msg),
error: (msg: string) => this.add('error', msg),
};
}
export const notifications = createNotificationStore();

КонцепцияСуть
writable(val)Store с set/update/subscribe
readable(val, start)Store только для чтения с producer
$storeАвтоподписка и автоотписка
$store = valueКомпилируется в store.set(value)
Кастомный StoreОбёртка над writable с методами
Контракт StoreЛюбой объект с subscribe(fn)
Derived (preview)Вычисляемый из другого Store

Несколько виртуальных “компонентов” разделяют один Store. Когда ты меняешь значение в любом из них — все остальные обновляются мгновенно. Плюс: живые часы на readable store и корзина на кастомном store!