Gestión de Estado Reactiva con Svelte Stores: Una Guía Completa
Este tutorial te guiará a través del concepto de Svelte Stores, una herramienta poderosa para la gestión de estado reactiva en tus aplicaciones Svelte. Aprenderás a crear, usar y derivar diferentes tipos de stores para construir aplicaciones más robustas y fáciles de mantener. Cubriremos stores grabables, legibles y derivadas con ejemplos prácticos.
La gestión de estado es uno de los pilares fundamentales en el desarrollo de aplicaciones web modernas. En Svelte, a diferencia de otros frameworks que a menudo requieren librerías externas complejas para este propósito, la gestión de estado se simplifica enormemente gracias a los Svelte Stores. Estas no son más que objetos con una interfaz mínima que permiten suscribirse a los cambios y emitir notificaciones cuando su valor se actualiza, facilitando la reactividad a lo largo de toda tu aplicación.
En este tutorial, profundizaremos en los Svelte Stores, explorando sus diferentes tipos y cómo puedes utilizarlos para crear aplicaciones Svelte más eficientes, mantenibles y, sobre todo, reactivas. Prepárate para dominar esta característica esencial de Svelte.
🚀 ¿Qué son los Svelte Stores?
En su esencia, un Svelte Store es un contenedor para un valor que puede ser accedido y modificado por múltiples componentes, y que notifica a todos los componentes suscritos cuando su valor cambia. Esto permite una gestión de estado centralizada y reactiva, eliminando la necesidad de pasar propiedades manualmente a través de múltiples niveles de componentes (conocido como "prop drilling").
Los Stores siguen un contrato simple, definido por una interfaz con dos métodos principales:
.subscribe(callback): Registra una funcióncallbackque se ejecutará cada vez que el valor del store cambie. Retorna una función para cancelar la suscripción..set(value): (Solo para stores grabables) Actualiza el valor del store, notificando a todos los suscriptores.
Svelte nos proporciona helpers para crear diferentes tipos de stores, que veremos en detalle a continuación.
🛠️ Tipos de Svelte Stores
Svelte ofrece tres tipos principales de stores, cada uno diseñado para escenarios específicos:
- Writable Stores: Los más comunes, permiten leer y escribir valores.
- Readable Stores: Solo permiten leer valores, útiles para datos que no deben ser modificados directamente por los componentes.
- Derived Stores: Permiten crear un store cuyo valor depende de uno o más stores existentes.
Analicemos cada uno con ejemplos prácticos.
1. ✍️ Writable Stores: La Base de la Reactividad
Los writable stores son la piedra angular de la gestión de estado en Svelte. Permiten que cualquier componente los actualice y que otros componentes se suscriban a sus cambios. Son ideales para el estado de la aplicación que necesita ser modificado, como el usuario autenticado, la lista de tareas, el tema actual de la interfaz, etc.
Creando un Writable Store
Para crear un writable store, importamos la función writable desde svelte/store y la invocamos con un valor inicial.
// src/stores.js
import { writable } from 'svelte/store';
export const count = writable(0);
export const user = writable({ name: 'Invitado', isLoggedIn: false });
export const theme = writable('light');
Suscribiéndose a un Writable Store en Componentes
Existen dos maneras principales de suscribirse a un store en un componente Svelte:
a) Suscripción Explícita (Menos Común para Reactividad Directa)
Esta es la forma más 'manual' y útil cuando necesitas controlar el ciclo de vida de la suscripción, por ejemplo, para realizar efectos secundarios o limpiar recursos. Requiere que te suscribas y te desuscribas explícitamente.
<!-- src/lib/ExplicitCounter.svelte -->
<script>
import { onMount, onDestroy } from 'svelte';
import { count } from '../stores.js';
let currentCount;
let unsubscribe;
onMount(() => {
unsubscribe = count.subscribe(value => {
currentCount = value;
});
});
onDestroy(() => {
unsubscribe(); // ¡Importante desuscribirse para evitar fugas de memoria!
});
function increment() {
count.update(n => n + 1);
}
</script>
<p>Conteo explícito: {currentCount}</p>
<button on:click={increment}>Incrementar</button>
b) Suscripción Automática con el Prefijo $ (La Forma Svelte)
Esta es la forma idiomática y recomendada de usar stores en Svelte. Svelte detecta el prefijo $ antes del nombre del store y automáticamente se suscribe y desuscribe por ti, manejando la reactividad de forma mágica. ¡Es mucho más conciso y potente!
<!-- src/lib/AutoCounter.svelte -->
<script>
import { count } from '../stores.js';
function increment() {
$count++; // Svelte desazucarará esto a count.set($count + 1);
}
</script>
<p>Conteo automático: {$count}</p>
<button on:click={increment}>Incrementar</button>
<button on:click={() => $count = 0}>Resetear</button>
Actualizando un Writable Store
Los writable stores tienen tres métodos para modificar su valor:
.set(newValue): Establece un nuevo valor para el store, sobrescribiendo el anterior..update(callback): Toma una funcióncallbackque recibe el valor actual del store y debe devolver el nuevo valor. Esto es útil para actualizaciones basadas en el estado actual.- Asignación con
$(solo en archivos.svelte): Como vimos,$count++es un azúcar sintáctico para$count.update(n => n + 1).$count = newValuees azúcar sintáctico para$count.set(newValue).
// Ejemplo de uso en JS (no en un componente Svelte)
import { count } from './stores.js';
count.set(10); // Establece el conteo a 10
console.log(count); // Esto imprimirá el objeto store, no el valor directamente
// Para obtener el valor fuera de un componente Svelte
let currentValue;
const unsubscribe = count.subscribe(value => {
currentValue = value;
});
console.log(currentValue); // 10
unsubscribe();
count.update(n => n + 5); // Incrementa el conteo en 5 (ahora 15)
// Podemos hacer operaciones complejas
import { user } from './stores.js';
user.update(u => ({
...u,
isLoggedIn: true,
name: 'Juanito'
}));
Ejemplo Completo de Writable Store: Lista de Tareas (Todo List)
Vamos a construir una pequeña aplicación de lista de tareas para ver writable stores en acción.
// src/stores/todoStore.js
import { writable } from 'svelte/store';
const initialTodos = [
{ id: 1, text: 'Aprender Svelte Stores', completed: false },
{ id: 2, text: 'Construir una app con Svelte', completed: false },
{ id: 3, text: 'Dominar la gestión de estado', completed: true },
];
export const todos = writable(initialTodos);
export const addTodo = (text) => {
todos.update(currentTodos => [
...currentTodos,
{ id: Date.now(), text, completed: false },
]);
};
export const toggleTodo = (id) => {
todos.update(currentTodos =>
currentTodos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
export const removeTodo = (id) => {
todos.update(currentTodos => currentTodos.filter(todo => todo.id !== id));
};
export const clearCompleted = () => {
todos.update(currentTodos => currentTodos.filter(todo => !todo.completed));
};
<!-- src/routes/TodoApp.svelte -->
<script>
import { todos, addTodo, toggleTodo, removeTodo, clearCompleted } from '../stores/todoStore.js';
let newTodoText = '';
function handleAddTodo() {
if (newTodoText.trim()) {
addTodo(newTodoText);
newTodoText = '';
}
}
</script>
<div class="todo-container">
<h1>Mi Lista de Tareas Svelte</h1>
<input
type="text"
placeholder="Añadir nueva tarea..."
bind:value={newTodoText}
on:keydown={(e) => { if (e.key === 'Enter') handleAddTodo(); }}
/>
<button on:click={handleAddTodo}>Añadir Tarea</button>
{#if $todos.length === 0}
<p>¡No hay tareas pendientes! 🎉</p>
{:else}
<ul>
{#each $todos as todo (todo.id)}
<li>
<input
type="checkbox"
checked={todo.completed}
on:change={() => toggleTodo(todo.id)}
/>
<span class={todo.completed ? 'completed' : ''}>
{todo.text}
</span>
<button on:click={() => removeTodo(todo.id)}>🗑️</button>
</li>
{/each}
</ul>
{/if}
<button on:click={clearCompleted} disabled={!$todos.some(t => t.completed)}>
Limpiar Tareas Completadas
</button>
</div>
<style>
.todo-container {
max-width: 500px;
margin: 2em auto;
padding: 1.5em;
border: 1px solid #eee;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
ul {
list-style: none;
padding: 0;
}
li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.8em 0;
border-bottom: 1px dotted #eee;
}
li:last-child {
border-bottom: none;
}
li span {
flex-grow: 1;
margin-left: 0.5em;
}
li span.completed {
text-decoration: line-through;
color: #999;
}
input[type="text"] {
width: calc(100% - 100px); /* Ajustar el ancho */
padding: 0.8em;
margin-right: 10px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
padding: 0.8em 1.2em;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover:not(:disabled) {
background-color: #0056b3;
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
button:last-of-type { /* Botón de limpiar */
background-color: #dc3545;
margin-top: 1em;
}
button:last-of-type:hover:not(:disabled) {
background-color: #c82333;
}
</style>
2. 👁️ Readable Stores: Solo para Lectura
Los readable stores son similares a los writable stores, pero no exponen los métodos set o update. Esto los hace perfectos para exponer datos que no deben ser modificados directamente por los componentes de la interfaz de usuario, sino por una lógica centralizada (por ejemplo, servicios de API, datos de configuración inicial, etc.).
Creando un Readable Store
Se crean con la función readable de svelte/store. Acepta un valor inicial y, opcionalmente, una función start que se ejecuta cuando el primer suscriptor se une al store, y una función stop que se ejecuta cuando el último suscriptor se va.
// src/stores/timeStore.js
import { readable } from 'svelte/store';
export const time = readable(new Date(), function start(set) {
const interval = setInterval(() => {
set(new Date());
}, 1000);
// La función stop se llama cuando no hay más suscriptores
return function stop() {
clearInterval(interval);
};
});
// Otro ejemplo: un store de configuración
export const appConfig = readable({
appName: 'Svelte App',
version: '1.0.0',
apiUrl: 'https://api.example.com',
});
Usando un Readable Store
Su uso en componentes es idéntico al de los writable stores, empleando el prefijo $. La diferencia clave es que no puedes intentar modificar su valor directamente ($time = new Date() daría un error).
<!-- src/lib/Clock.svelte -->
<script>
import { time } from '../stores/timeStore.js';
// El valor de $time se actualizará cada segundo automáticamente
// y el componente se re-renderizará.
</script>
<div class="clock">
<p>Hora actual: {$time.toLocaleTimeString()}</p>
<p>Fecha actual: {$time.toLocaleDateString()}</p>
</div>
<style>
.clock {
border: 1px solid #ccc;
padding: 1em;
border-radius: 4px;
background-color: #f9f9f9;
text-align: center;
margin-top: 1em;
}
.clock p {
margin: 0.5em 0;
font-size: 1.2em;
}
</style>
3. 🎣 Derived Stores: La Magia de los Datos Derivados
Los derived stores son increíblemente poderosos. Permiten crear un nuevo store cuyo valor se calcula a partir de uno o más stores existentes. Siempre son readable (no puedes modificarlos directamente) y se actualizan automáticamente cuando cualquiera de sus stores dependientes cambia.
Son perfectos para transformar datos, filtrar listas, o combinar información de múltiples fuentes reactivas.
Creando un Derived Store
Se crean con la función derived de svelte/store. Puede tomar uno o varios stores como primer argumento y una función callback como segundo. La callback recibe los valores de los stores dependientes y debe retornar el valor derivado.
// src/stores/todoStore.js (continuación)
import { writable, derived } from 'svelte/store';
// ... (todos los exports anteriores de todos, addTodo, etc.)
// Store derivado para tareas completadas
export const completedTodos = derived(todos, ($todos) =>
$todos.filter(todo => todo.completed)
);
// Store derivado para tareas pendientes
export const pendingTodos = derived(todos, ($todos) =>
$todos.filter(todo => !todo.completed)
);
// Store derivado para el progreso total
export const todoProgress = derived(todos, ($todos) => {
const total = $todos.length;
if (total === 0) return 0;
const completedCount = $todos.filter(todo => todo.completed).length;
return Math.round((completedCount / total) * 100);
});
Usando un Derived Store
Al igual que con los otros stores, simplemente usa el prefijo $ en tus componentes.
<!-- src/routes/TodoApp.svelte (continuación) -->
<script>
import { todos, addTodo, toggleTodo, removeTodo, clearCompleted,
completedTodos, pendingTodos, todoProgress } from '../stores/todoStore.js';
let newTodoText = '';
function handleAddTodo() {
if (newTodoText.trim()) {
addTodo(newTodoText);
newTodoText = '';
}
}
</script>
<div class="todo-container">
<h1>Mi Lista de Tareas Svelte</h1>
<input
type="text"
placeholder="Añadir nueva tarea..."
bind:value={newTodoText}
on:keydown={(e) => { if (e.key === 'Enter') handleAddTodo(); }}
/>
<button on:click={handleAddTodo}>Añadir Tarea</button>
<div class="summary">
<p>Tareas pendientes: {$pendingTodos.length}</p>
<p>Tareas completadas: {$completedTodos.length}</p>
<p>Progreso total: <span class="badge blue">{$todoProgress}%</span></p>
<div class="progress-bar">
<div class="progress-fill" style="width: {$todoProgress}%; background: #28a745;">{$todoProgress}%</div>
</div>
</div>
{#if $todos.length === 0}
<p>¡No hay tareas pendientes! 🎉</p>
{:else}
<ul>
{#each $todos as todo (todo.id)}
<li>
<input
type="checkbox"
checked={todo.completed}
on:change={() => toggleTodo(todo.id)}
/>
<span class={todo.completed ? 'completed' : ''}>
{todo.text}
</span>
<button on:click={() => removeTodo(todo.id)}>🗑️</button>
</li>
{/each}
</ul>
{/if}
<button on:click={clearCompleted} disabled={!$todos.some(t => t.completed)}>
Limpiar Tareas Completadas
</button>
</div>
<style>
/* Estilos anteriores */
.todo-container {
max-width: 500px;
margin: 2em auto;
padding: 1.5em;
border: 1px solid #eee;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
ul {
list-style: none;
padding: 0;
}
li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.8em 0;
border-bottom: 1px dotted #eee;
}
li:last-child {
border-bottom: none;
}
li span {
flex-grow: 1;
margin-left: 0.5em;
}
li span.completed {
text-decoration: line-through;
color: #999;
}
input[type="text"] {
width: calc(100% - 100px); /* Ajustar el ancho */
padding: 0.8em;
margin-right: 10px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
padding: 0.8em 1.2em;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover:not(:disabled) {
background-color: #0056b3;
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
button:last-of-type { /* Botón de limpiar */
background-color: #dc3545;
margin-top: 1em;
}
button:last-of-type:hover:not(:disabled) {
background-color: #c82333;
}
.summary {
background-color: #eaf6ff;
border-left: 5px solid #007bff;
padding: 1em;
margin: 1.5em 0;
border-radius: 4px;
}
.summary p {
margin: 0.5em 0;
}
</style>
Derived Store con set asíncrono
La función derived también puede aceptar un tercer argumento: una función set para manejar valores asíncronos o con efectos secundarios. Esto es útil para stores que dependen de promesas o de lógica que tarda tiempo en resolverse.
// src/stores/asyncUserStore.js
import { readable, derived } from 'svelte/store';
// Simula una llamada a API
async function fetchUser(userId) {
return new Promise(resolve => {
setTimeout(() => {
resolve({
id: userId,
name: 'Alice',
email: 'alice@example.com',
preferences: { theme: 'dark' }
});
}, 1500);
});
}
export const currentUserId = writable(1);
// Derived store que simula la carga de un usuario por su ID
export const currentUser = derived(
currentUserId,
($userId, set) => {
set(null); // Establece el usuario a null mientras se carga
fetchUser($userId).then(user => {
set(user);
});
return () => {}; // Función de limpieza opcional
},
null // Valor inicial opcional antes de la primera actualización
);
<!-- src/lib/UserDetails.svelte -->
<script>
import { currentUserId, currentUser } from '../stores/asyncUserStore.js';
function changeUser() {
currentUserId.set($currentUserId === 1 ? 2 : 1); // Cambia el ID para cargar otro usuario
}
</script>
<div class="user-details">
<h2>Detalles del Usuario</h2>
{#if $currentUser}
<p>ID: {$currentUser.id}</p>
<p>Nombre: {$currentUser.name}</p>
<p>Email: {$currentUser.email}</p>
<p>Tema Preferido: {$currentUser.preferences.theme}</p>
{:else}
<p>Cargando usuario...</p>
{/if}
<button on:click={changeUser}>Cargar Otro Usuario</button>
</div>
<style>
.user-details {
border: 1px solid #ddd;
padding: 1.5em;
margin: 2em auto;
max-width: 400px;
border-radius: 8px;
background-color: #f0f8ff;
}
.user-details h2 {
color: #2c3e50;
}
.user-details p {
margin: 0.5em 0;
}
.user-details button {
margin-top: 1em;
background-color: #28a745;
}
.user-details button:hover {
background-color: #218838;
}
</style>
Este ejemplo ilustra cómo un derived store puede manejar valores asíncronos, proporcionando un valor intermedio (null en este caso) mientras la operación asíncrona está en curso. La función set se usa para actualizar el valor del store cuando la promesa se resuelve.
🗺️ Organización de Stores en tu Proyecto
Es una buena práctica organizar tus stores en un directorio src/stores (o similar) para mantener tu código limpio y modular. Cada store o grupo de stores relacionados puede tener su propio archivo.
my-svelte-app/
├── src/
│ ├── App.svelte
│ ├── main.js
│ └── stores/
│ ├── index.js // Exporta todos los stores para una importación centralizada
│ ├── countStore.js // Para un simple contador
│ ├── userStore.js // Para el estado del usuario
│ ├── todoStore.js // Para la lógica de la lista de tareas
│ └── themeStore.js // Para el tema de la aplicación
└── package.json
src/stores/index.js (Ejemplo de exportación centralizada):
// src/stores/index.js
export * from './countStore.js';
export * from './userStore.js';
export * from './todoStore.js';
export * from './themeStore.js';
// etc.
Esto permite importar múltiples stores desde un solo lugar:
import { count, user, todos } from '../stores';
🔄 Store Contratos Personalizados
Si bien writable, readable y derived cubren la mayoría de los casos de uso, puedes crear tus propios stores personalizados que cumplan con la interfaz del store. Esto te da flexibilidad total para implementar lógica compleja, como persistencia en localStorage o integración con WebSockets.
Vamos a crear un store personalizado que guarde y cargue su valor desde localStorage.
// src/stores/persistentStore.js
import { writable } from 'svelte/store';
export function persistentWritable(key, initialValue) {
// Intenta cargar el valor de localStorage
const storedValue = localStorage.getItem(key);
const data = storedValue ? JSON.parse(storedValue) : initialValue;
const store = writable(data);
store.subscribe(value => {
// Cuando el store cambia, guarda el nuevo valor en localStorage
localStorage.setItem(key, JSON.stringify(value));
});
return store;
}
// src/stores/index.js (actualizado)
// ...
export * from './persistentStore.js';
<!-- src/routes/Settings.svelte -->
<script>
import { persistentWritable } from '../stores/persistentStore.js';
export const appTheme = persistentWritable('appTheme', 'light');
export const showWelcomeMessage = persistentWritable('showWelcome', true);
</script>
<div class="settings">
<h2>Configuración de la Aplicación</h2>
<label>
Tema de la Interfaz:
<select bind:value={$appTheme}>
<option value="light">Claro</option>
<option value="dark">Oscuro</option>
</select>
</label>
<label>
<input type="checkbox" bind:checked={$showWelcomeMessage} />
Mostrar mensaje de bienvenida
</label>
<p>El tema actual es: {$appTheme}</p>
<p>El mensaje de bienvenida está: {$showWelcomeMessage ? 'activado' : 'desactivado'}</p>
</div>
<style>
.settings {
max-width: 400px;
margin: 2em auto;
padding: 1.5em;
border: 1px solid #e0e0e0;
border-radius: 8px;
background-color: #ffffff;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.settings h2 {
color: #333;
margin-bottom: 1.5em;
text-align: center;
}
.settings label {
display: flex;
align-items: center;
margin-bottom: 1em;
font-size: 1.1em;
color: #555;
}
.settings select, .settings input[type="checkbox"] {
margin-left: 10px;
padding: 0.5em;
border-radius: 4px;
border: 1px solid #ccc;
font-size: 1em;
}
.settings select {
flex-grow: 1;
}
.settings input[type="checkbox"] {
width: 20px;
height: 20px;
}
.settings p {
margin-top: 1.5em;
font-style: italic;
color: #777;
border-top: 1px dashed #eee;
padding-top: 1em;
}
</style>
Este persistentWritable es un writable store normal, pero con la lógica adicional de interactuar con localStorage cada vez que su valor cambia (a través de subscribe). Cuando se inicializa, intenta cargar el valor existente, haciendo que el estado persista entre recargas de página.
🧐 ¿Por qué Svelte Stores son tan eficientes?
Svelte compila tu código a JavaScript Vanilla, y el sistema de reactividad de los stores es muy ligero. Cuando un store se actualiza, Svelte solo re-renderiza los componentes o partes de los componentes que realmente utilizan ese store. No hay Virtual DOM ni comparaciones costosas, lo que resulta en un rendimiento excelente.🎯 Buenas Prácticas y Patrones con Svelte Stores
- Centralización: Mantén tus stores en un directorio específico (
src/stores). - Modularidad: Separa la lógica de cada store en archivos individuales. Por ejemplo,
userStore.js,settingsStore.js,todoStore.js. - Estado Mínimo: No guardes todo en stores. Para el estado local de un componente que no necesita ser compartido, usa
lety$normales dentro del<script>del componente. - Funciones de Ayuda: Exporta funciones que interactúen con tus stores (como
addTodo,toggleTodo, etc.) junto con el store mismo. Esto encapsula la lógica de negocio y evita que los componentes accedan directamente astore.update()ostore.set(), haciéndolos más fáciles de probar y mantener. - Lectura vs. Escritura: Utiliza
readablestores para datos que no deben ser modificados por la UI, ywritablepara datos que sí lo son. Losderivedstores son siemprereadable. - Asincronía: Cuando manejes operaciones asíncronas (como llamadas a API), considera cómo el store representará los estados de carga, error y éxito. Puedes tener un store para los datos, otro para el estado de carga (
isLoading: writable(false)), y otro para errores (error: writable(null)).
⚠️ Consideraciones y Errores Comunes
- Olvidar
$: El error más común. Si intentas usar un store sin el prefijo$, Svelte no lo tratará como un valor reactivo y no se suscribirá automáticamente. Accederás al objeto store en sí, no a su valor. - Fugas de Memoria: Si usas
store.subscribe()directamente enonMounty no limpias la suscripción enonDestroy, tu aplicación podría tener fugas de memoria. Usa siempre el prefijo$para la reactividad en componentes Svelte para que Svelte maneje esto por ti. - Mutar objetos directamente: Si tu store contiene un objeto o array, y lo mutas directamente sin pasar por
setoupdate(o la asignación con$), Svelte no detectará el cambio y no re-renderizará los componentes. Siempre crea una nueva instancia del objeto/array al actualizar. Por ejemplo:todos.update(t => [...t, newTodo])en lugar detodos.update(t => { t.push(newTodo); return t; })(aunque Svelte es lo suficientemente inteligente para detectar esto último, es una buena práctica funcional evitar la mutación directa).
✅ Conclusión
Los Svelte Stores son una característica poderosa y elegante que simplifica enormemente la gestión de estado en tus aplicaciones Svelte. Al comprender y aplicar los conceptos de writable, readable y derived stores, junto con las buenas prácticas, estarás bien equipado para construir aplicaciones reactivas, escalables y fáciles de mantener.
La magia del $, la simplicidad de la interfaz y la eficiencia inherente de Svelte hacen que la gestión de estado sea una alegría, no una carga. ¡Ahora, sal y construye algo asombroso con Svelte Stores!
Comentarios (0)
Aún no hay comentarios. ¡Sé el primero!