Compartir estado entre islands
Al construir un sitio web con Astro usando arquitectura de islands / hidratación parcial, puede que te hayas encontrado con este problema: Quiero compartir estado entre mis componentes.
UI frameworks como React o Vue pueden fomentar providers de “context” para que otros componentes los consuman. Pero al hidratar parcialmente componentes dentro de Astro o Markdown, no puedes usar estos wrappers de context.
Astro recomienda una solución diferente para almacenamiento compartido del lado del cliente: Nano Stores.
¿Por qué Nano Stores?
Sección titulada “¿Por qué Nano Stores?”La librería Nano Stores te permite crear stores con los que cualquier componente puede interactuar. Recomendamos Nano Stores porque:
- Son ligeros. Nano Stores envían el mínimo JS que necesitarás (menos de 1 KB) con cero dependencias.
- Son agnósticos al framework. ¡Esto significa que compartir estado entre frameworks será fluido! Astro está construido sobre la flexibilidad, así que nos encantan las soluciones que ofrecen una experiencia de desarrollador similar sin importar tu preferencia.
Aún así, hay una serie de alternativas que puedes explorar. Estas incluyen:
- Stores integrados de Svelte
- Solid signals fuera de un contexto de componente
- API de reactividad de Vue
- Enviar eventos personalizados del navegador entre componentes
🙋 ¿Puedo usar Nano Stores en archivos .astro u otros componentes del lado del servidor?
Nano Stores puede usarse en etiquetas <script> para compartir estado entre componentes .astro. Sin embargo, usar Nano Stores en el frontmatter de componentes del lado del servidor no es recomendado por las siguientes restricciones:
- Escribir en un store desde un archivo
.astroo un componente no hidratado no afectará el valor recibido por los componentes del lado del cliente. - No puedes pasar un Nano Store como “prop” a componentes del lado del cliente.
- No puedes suscribirte a cambios del store desde un archivo
.astro, ya que los componentes de Astro no se re-renderizan.
Si entiendes estas restricciones y aun así encuentras un caso de uso, ¡puedes probar Nano Stores! Solo recuerda que Nano Stores están construidos para reactividad a cambios en el cliente específicamente.
🙋 ¿Cómo se comparan los stores de Svelte con Nano Stores?
¡Nano Stores y los stores de Svelte son muy similares! De hecho, nanostores te permite usar el mismo atajo $ para suscripciones que podrías usar con los stores de Svelte.
Si quieres evitar librerías de terceros, los stores de Svelte son una gran herramienta de comunicación cross-island por sí solos. Aún así, podrías preferir Nano Stores si a) te gustan sus add-ons para “objects” y async state, o b) quieres comunicarte entre Svelte y otros UI frameworks como Preact o Vue.
🙋 ¿Cómo se comparan los signals de Solid con Nano Stores?
Si has usado Solid por un tiempo, puede que hayas intentado mover signals o stores fuera de tus componentes. ¡Esta es una excelente forma de compartir estado entre islands de Solid! Prueba exportando signals desde un archivo compartido:
import { createSignal } from 'solid-js';
export const sharedCount = createSignal(0);…y todos los componentes que importen sharedCount compartirán el mismo estado. Aunque esto funciona bien, podrías preferir Nano Stores si a) te gustan sus add-ons para “objects” y async state, o b) quieres comunicarte entre Solid y otros UI frameworks como Preact o Vue.
Instalando Nano Stores
Sección titulada “Instalando Nano Stores”Para empezar, instala Nano Stores junto con su paquete helper para tu UI framework favorito:
npm install nanostores @nanostores/preactnpm install nanostores @nanostores/reactnpm install nanostores @nanostores/solidnpm install nanostores¡No hay paquete helper aquí! Nano Stores puede usarse como stores estándar de Svelte.
npm install nanostores @nanostores/vuePuedes saltar a la guía de uso de Nano Stores desde aquí, o seguir nuestro ejemplo a continuación.
Ejemplo de uso - flyout de carrito de ecommerce
Sección titulada “Ejemplo de uso - flyout de carrito de ecommerce”Digamos que estamos construyendo una interfaz de ecommerce simple con tres elementos interactivos:
- Un formulario de envío de “añadir al carrito”
- Un flyout de carrito para mostrar los items añadidos
- Un toggle para el flyout del carrito
Prueba el ejemplo completo on your machine or online via StackBlitz.
Tu archivo base de Astro podría verse así:
---import CartFlyoutToggle from '../components/CartFlyoutToggle';import CartFlyout from '../components/CartFlyout';import AddToCartForm from '../components/AddToCartForm';---
<!DOCTYPE html><html lang="en"><head>...</head><body> <header> <nav> <a href="/">Astro storefront</a> <CartFlyoutToggle client:load /> </nav> </header> <main> <AddToCartForm client:load> <!-- ... --> </AddToCartForm> </main> <CartFlyout client:load /></body></html>Usando “atoms”
Sección titulada “Usando “atoms””Empecemos abriendo nuestro CartFlyout cada vez que se haga clic en CartFlyoutToggle.
Primero, crea un nuevo archivo JS o TS para contener nuestro store. Usaremos un “atom” para esto:
import { atom } from 'nanostores';
export const isCartOpen = atom(false);Ahora, podemos importar este store en cualquier archivo que necesite leer o escribir. Empezaremos conectando nuestro CartFlyoutToggle:
import { useStore } from '@nanostores/preact';import { isCartOpen } from '../cartStore';
export default function CartButton() { // read the store value with the `useStore` hook const $isCartOpen = useStore(isCartOpen); // write to the imported store using `.set` return ( <button onClick={() => isCartOpen.set(!$isCartOpen)}>Cart</button> )}import { useStore } from '@nanostores/react';import { isCartOpen } from '../cartStore';
export default function CartButton() { // read the store value with the `useStore` hook const $isCartOpen = useStore(isCartOpen); // write to the imported store using `.set` return ( <button onClick={() => isCartOpen.set(!$isCartOpen)}>Cart</button> )}import { useStore } from '@nanostores/solid';import { isCartOpen } from '../cartStore';
export default function CartButton() { // read the store value with the `useStore` hook const $isCartOpen = useStore(isCartOpen); // write to the imported store using `.set` return ( <button onClick={() => isCartOpen.set(!$isCartOpen())}>Cart</button> )}<script> import { isCartOpen } from '../cartStore';</script>
<!--use "$" to read the store value--><button on:click={() => isCartOpen.set(!$isCartOpen)}>Cart</button><template> <!--write to the imported store using `.set`--> <button @click="isCartOpen.set(!$isCartOpen)">Cart</button></template>
<script setup> import { isCartOpen } from '../cartStore'; import { useStore } from '@nanostores/vue';
// read the store value with the `useStore` hook const $isCartOpen = useStore(isCartOpen);</script>Luego, podemos leer isCartOpen desde nuestro componente CartFlyout:
import { useStore } from '@nanostores/preact';import { isCartOpen } from '../cartStore';
export default function CartFlyout() { const $isCartOpen = useStore(isCartOpen);
return $isCartOpen ? <aside>...</aside> : null;}import { useStore } from '@nanostores/react';import { isCartOpen } from '../cartStore';
export default function CartFlyout() { const $isCartOpen = useStore(isCartOpen);
return $isCartOpen ? <aside>...</aside> : null;}import { useStore } from '@nanostores/solid';import { isCartOpen } from '../cartStore';
export default function CartFlyout() { const $isCartOpen = useStore(isCartOpen);
return $isCartOpen() ? <aside>...</aside> : null;}<script> import { isCartOpen } from '../cartStore';</script>
{#if $isCartOpen}<aside>...</aside>{/if}<template> <aside v-if="$isCartOpen">...</aside></template>
<script setup> import { isCartOpen } from '../cartStore'; import { useStore } from '@nanostores/vue';
const $isCartOpen = useStore(isCartOpen);</script>Usando “maps”
Sección titulada “Usando “maps””Maps son una excelente opción para objetos en los que escribes regularmente. Además de los helpers estándar get() y set() que un atom proporciona, también tendrás una función .setKey() para actualizar eficientemente claves individuales del objeto.
Ahora, vamos a rastrear los items dentro de tu carrito. Para evitar duplicados y rastrear la “cantidad,” podemos almacenar tu carrito como un objeto con el ID del item como clave. Usaremos un Map para esto.
Añadamos un store cartItem a nuestro cartStore.js anterior. También puedes cambiar a un archivo TypeScript para definir la estructura si lo deseas.
import { atom, map } from 'nanostores';
export const isCartOpen = atom(false);
/** * @typedef {Object} CartItem * @property {string} id * @property {string} name * @property {string} imageSrc * @property {number} quantity */
/** @type {import('nanostores').MapStore<Record<string, CartItem>>} */export const cartItems = map({});import { atom, map } from 'nanostores';
export const isCartOpen = atom(false);
export type CartItem = { id: string; name: string; imageSrc: string; quantity: number;}
export const cartItems = map<Record<string, CartItem>>({});Ahora, exportemos un helper addCartItem para que lo usen nuestros componentes.
- Si ese item no existe en tu carrito, añade el item con una cantidad inicial de 1.
- Si ese item sí existe ya, incrementa la cantidad en 1.
...export function addCartItem({ id, name, imageSrc }) { const existingEntry = cartItems.get()[id]; if (existingEntry) { cartItems.setKey(id, { ...existingEntry, quantity: existingEntry.quantity + 1, }) } else { cartItems.setKey( id, { id, name, imageSrc, quantity: 1 } ); }}...type ItemDisplayInfo = Pick<CartItem, 'id' | 'name' | 'imageSrc'>;export function addCartItem({ id, name, imageSrc }: ItemDisplayInfo) { const existingEntry = cartItems.get()[id]; if (existingEntry) { cartItems.setKey(id, { ...existingEntry, quantity: existingEntry.quantity + 1, }); } else { cartItems.setKey( id, { id, name, imageSrc, quantity: 1 } ); }}🙋 ¿Por qué usar .get() aquí en lugar de un helper useStore?
Puede que hayas notadado que estamos llamando cartItems.get() aquí, en lugar de tomar ese helper useStore de nuestros ejemplos de React / Preact / Solid / Vue. Esto es porque useStore está diseñado para disparar re-renderizados de componentes. En otras palabras, useStore debería usarse siempre que el valor del store se esté renderizando en la UI. Como estamos leyendo el valor cuando un evento se dispara (addToCart en este caso), y no estamos intentando renderizar ese valor, no necesitamos useStore aquí.
Con nuestro store en su lugar, podemos llamar esta función dentro de nuestro AddToCartForm cada vez que ese formulario se envíe. También abriremos el flyout del carrito para que puedas ver un resumen completo del carrito.
import { addCartItem, isCartOpen } from '../cartStore';
export default function AddToCartForm({ children }) { // we'll hardcode the item info for simplicity! const hardcodedItemInfo = { id: 'astronaut-figurine', name: 'Astronaut Figurine', imageSrc: '/images/astronaut-figurine.png', }
function addToCart(e) { e.preventDefault(); isCartOpen.set(true); addCartItem(hardcodedItemInfo); }
return ( <form onSubmit={addToCart}> {children} </form> )}import { addCartItem, isCartOpen } from '../cartStore';
export default function AddToCartForm({ children }) { // we'll hardcode the item info for simplicity! const hardcodedItemInfo = { id: 'astronaut-figurine', name: 'Astronaut Figurine', imageSrc: '/images/astronaut-figurine.png', }
function addToCart(e) { e.preventDefault(); isCartOpen.set(true); addCartItem(hardcodedItemInfo); }
return ( <form onSubmit={addToCart}> {children} </form> )}import { addCartItem, isCartOpen } from '../cartStore';
export default function AddToCartForm({ children }) { // we'll hardcode the item info for simplicity! const hardcodedItemInfo = { id: 'astronaut-figurine', name: 'Astronaut Figurine', imageSrc: '/images/astronaut-figurine.png', }
function addToCart(e) { e.preventDefault(); isCartOpen.set(true); addCartItem(hardcodedItemInfo); }
return ( <form onSubmit={addToCart}> {children} </form> )}<form on:submit|preventDefault={addToCart}> <slot></slot></form>
<script> import { addCartItem, isCartOpen } from '../cartStore';
// we'll hardcode the item info for simplicity! const hardcodedItemInfo = { id: 'astronaut-figurine', name: 'Astronaut Figurine', imageSrc: '/images/astronaut-figurine.png', }
function addToCart() { isCartOpen.set(true); addCartItem(hardcodedItemInfo); }</script><template> <form @submit="addToCart"> <slot></slot> </form></template>
<script setup> import { addCartItem, isCartOpen } from '../cartStore';
// we'll hardcode the item info for simplicity! const hardcodedItemInfo = { id: 'astronaut-figurine', name: 'Astronaut Figurine', imageSrc: '/images/astronaut-figurine.png', }
function addToCart(e) { e.preventDefault(); isCartOpen.set(true); addCartItem(hardcodedItemInfo); }</script>Finalmente, renderizaremos esos items del carrito dentro de nuestro CartFlyout:
import { useStore } from '@nanostores/preact';import { isCartOpen, cartItems } from '../cartStore';
export default function CartFlyout() { const $isCartOpen = useStore(isCartOpen); const $cartItems = useStore(cartItems);
return $isCartOpen ? ( <aside> {Object.values($cartItems).length ? ( <ul> {Object.values($cartItems).map(cartItem => ( <li> <img src={cartItem.imageSrc} alt={cartItem.name} /> <h3>{cartItem.name}</h3> <p>Quantity: {cartItem.quantity}</p> </li> ))} </ul> ) : <p>Your cart is empty!</p>} </aside> ) : null;}import { useStore } from '@nanostores/react';import { isCartOpen, cartItems } from '../cartStore';
export default function CartFlyout() { const $isCartOpen = useStore(isCartOpen); const $cartItems = useStore(cartItems);
return $isCartOpen ? ( <aside> {Object.values($cartItems).length ? ( <ul> {Object.values($cartItems).map(cartItem => ( <li> <img src={cartItem.imageSrc} alt={cartItem.name} /> <h3>{cartItem.name}</h3> <p>Quantity: {cartItem.quantity}</p> </li> ))} </ul> ) : <p>Your cart is empty!</p>} </aside> ) : null;}import { useStore } from '@nanostores/solid';import { isCartOpen, cartItems } from '../cartStore';
export default function CartFlyout() { const $isCartOpen = useStore(isCartOpen); const $cartItems = useStore(cartItems);
return $isCartOpen() ? ( <aside> {Object.values($cartItems()).length ? ( <ul> {Object.values($cartItems()).map(cartItem => ( <li> <img src={cartItem.imageSrc} alt={cartItem.name} /> <h3>{cartItem.name}</h3> <p>Quantity: {cartItem.quantity}</p> </li> ))} </ul> ) : <p>Your cart is empty!</p>} </aside> ) : null;}<script> import { isCartOpen, cartItems } from '../cartStore';</script>
{#if $isCartOpen} {#if Object.values($cartItems).length} <aside> {#each Object.values($cartItems) as cartItem} <li> <img src={cartItem.imageSrc} alt={cartItem.name} /> <h3>{cartItem.name}</h3> <p>Quantity: {cartItem.quantity}</p> </li> {/each} </aside> {:else} <p>Your cart is empty!</p> {/if}{/if}<template> <aside v-if="$isCartOpen"> <ul v-if="Object.values($cartItems).length"> <li v-for="cartItem in Object.values($cartItems)" v-bind:key="cartItem.name"> <img :src=cartItem.imageSrc :alt=cartItem.name /> <h3>{{cartItem.name}}</h3> <p>Quantity: {{cartItem.quantity}}</p> </li> </ul> <p v-else>Your cart is empty!</p> </aside></template>
<script setup> import { cartItems, isCartOpen } from '../cartStore'; import { useStore } from '@nanostores/vue';
const $isCartOpen = useStore(isCartOpen); const $cartItems = useStore(cartItems);</script>Ahora, deberías tener un ejemplo de ecommerce totalmente interactivo con el bundle de JS más pequeño de la galaxia 🚀
Prueba el ejemplo completo en tu máquina o en línea vía StackBlitz!
Recetas