Sharing State
When building an Astro website with islands architecture / partial hydration, you may have run into this problem: I want to share state between my components.
UI frameworks like React or Vue may encourage “context” providers for other components to consume. But when partially hydrating components within Astro or Markdown, you can’t use these context wrappers.
Astro recommends a different solution for shared client-side storage: Nano Stores.
Why Nano Stores?
Section titled Why Nano Stores?The Nano Stores library allows you to author stores that any component can interact with. We recommend Nano Stores because:
- They’re lightweight. Nano Stores ship the bare minimum JS you’ll need (less than 1 KB) with zero dependencies.
- They’re framework-agnostic. This means sharing state between frameworks will be seamless! Astro is built on flexibility, so we love solutions that offer a similar developer experience no matter your preference.
Still, there are a number of alternatives you can explore. These include:
- Svelte’s built-in stores
- Solid signals outside of a component context
- Sending custom browser events between components
Installing Nano Stores
Section titled Installing Nano StoresTo get started, install Nano Stores alongside their helper package for your favorite UI framework:
npm install nanostores @nanostores/preact
npm install nanostores @nanostores/react
npm install nanostores @nanostores/solid
npm install nanostores @nanostores/vue
npm install nanostores
You can jump into the Nano Stores usage guide from here, or follow along with our example below!
Usage example - ecommerce cart flyout
Section titled Usage example - ecommerce cart flyoutLet’s say we’re building a simple ecommerce interface with three interactive elements:
- An “add to cart” submission form
- A cart flyout to display those added items
- A cart flyout toggle
Try the completed example on your machine or online via Stackblitz.
Your base Astro file may look like this:
---
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>
Using “atoms”
Section titled Using “atoms”Let’s start by opening our CartFlyout
whenever CartFlyoutToggle
is clicked.
First, create a new JS or TS file to contain our store. We’ll use an “atom” for this:
import { atom } from 'nanostores';
export const isCartOpen = atom(false);
Now, we can import this store into any file that needs to read or write. We’ll start by wiring up our 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>
Then, we can read isCartOpen
from our CartFlyout
component:
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>
Using “maps”
Section titled Using “maps”Now, let’s keep track of the items inside your cart. To avoid duplicates and keep track of “quantity,” we can store your cart as an object with the item’s ID as a key. We’ll use a Map for this.
Let’s add a cartItem
store to our cartStore.js
from earlier. You can also switch to a TypeScript file to define the shape if you’re so inclined.
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>>({});
Now, let’s export an addCartItem
helper for our components to use.
- If that item doesn’t exist in your cart, add the item with a starting quantity of 1.
- If that item does already exist, bump the quantity by 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 }
);
}
}
With our store in place, we can call this function inside our AddToCartForm
whenever that form is submitted. We’ll also open the cart flyout so you can see a full cart summary.
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>
Finally, we’ll render those cart items inside our 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>
Now, you should have a fully interactive ecommerce example with the smallest JS bundle in the galaxy 🚀
Try the completed example on your machine or online via Stackblitz!