State management with Astro and @nanostores
Tags: astro, preact
Published: 2023-05-12 05:25:24

A convenient opportunity arose to try creating a small website using the `AstroJS` framework - the radiator store [velarshop.ru](https://velarshop.ru) (more like an online catalog).

`Astro's` work is based on an "islands architecture" approach: each page of the website is considered not as a whole but as a set of `islands` responsible for displaying their respective parts of the page. Most of these islands consist of statically generated HTML that is sent to the client immediately. Islands that require JavaScript functionality load it after rendering, adding the necessary interactivity. This approach allows for achieving astonishing speed in application performance.

For example, the product page has such PageSpeed scores.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4zrg3rxmu3ectwqs5e4r.jpg)

An additional advantage is that both `Vanilla JavaScript` components and components written with `React`, `Vue`, `Solid`, `Preact`, or `Svelte` can be used as interactive components.

For my stack, I chose `Preact` because it closely resembles `React`, which I am familiar with, and it has minimal size.

It's desirable to keep such components small and place them as far as possible in the application's structure. This way, we achieve atomic "islands" that communicate with each other solely through shared state and, when it comes to page transitions, through `localStorage`.

## Application Structure

The application is quite simple: it consists of a set of pages generated based on the price list and technical catalog of the manufacturer. These pages provide descriptions of products with the ability to add selected items to the shopping cart.

The state is only needed here to store information about the products added to the cart and the options chosen by the visitor (such as radiator color, casing material, etc.).

Since `Astro` is a set of separate pages, I used the `@nanostores/persistent` library to persist the state between page transitions. This library synchronizes the data with `localStorage` and retrieves the latest data from it upon each page reload.

Below is a diagram of a product page, indicating the main blocks that require interactivity, modification, or consume data from the state.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qn26236cks7utiv21j4y.jpg)
The blocks that modify the state are marked in red:
- Product options: color, connection type, etc.
- Adding/removing products from the shopping cart.

The blocks that consume the state are highlighted in blue:
- Product names and prices, which change depending on the selected filters.
- The shopping cart, displays the quantity and total amount of the added products.

## Item Options Selection
The fundamental element of the state is the product options. They determine both the price and the item titles.

For each option, we create a separate folder with the following structure:

```
│
├── features
│     ├── options
│     │     ├── SelectConnection
│     │     ├── SelectGrill
│     │     ├── SelectColor
│     │     │     ├── store
│     │     │     │     ├── color.ts
│     │     │     ├── SelectColor.tsx
│     │     │     ├── index.ts
│     │
```
We end up with isolated folders, each containing everything necessary: a JSX component that can be placed anywhere on the website, and a piece of state in the store folder. In the case of color, the store looks like this:

``` typescript
/src/features/options/SelectColor/store/store.ts

import { сolors } from "@entities/Сolor"
import { persistentAtom } from "@nanostores/persistent"
import { computed } from 'nanostores'

const version = await import.meta.env.PUBLIC_LOCAL_STORAGE_VERSION

const colorId = persistentAtom<string>(`velarshop_color_active/${ version }`, "")

const color = computed(colorId, (colorId) => {
  return colors.find((color) => color.id === colorId)
})

const colorPostfix = computed(color, (color) => {
  if (!color) return ''
  return `, ${ color.name }`
})

const colorPricePerSection = computed(color, (color) => {
  if (!color) return 0
  return parseInt(color.price_section)
})

export {
  colorId,
  colorPostfix,
  colorPricePerSection
}

```
Here, I used a version of the store called "version" in case there are updates to the price list or color palette, to ensure that old data from `localStorage` doesn't mix with the updated data.

There is only one variable, `colorId`, which represents the user's selection. The other variables are derived from it. Their values are automatically recalculated whenever the user changes the color.

`Color` contains all the data about the color. It is selected from an array based on `colorId`. This variable is not exported and is used to derive the following two variables.
`colorPrefix` is computed based on the `color` and is used in the application to display the item title (e.g., _VelarP30H white_ or _VelarP30H black_).

`colorPricePerSection` represents the price of painting one radiator section. It is used in calculating the final cost of the radiator.

Thus, we have a set of isolated components that can be placed in a convenient location for the user to choose suitable options.

- `colorPrefix` is simply added at the end of the displayed item titles.
- `colorPricePerSection` is used in a more complex manner and is involved in calculating the final cost of each item.

## Calculating the cost of the radiator
We can't simply add the cost of painting one radiator section to the final cost because we also need to know the number of sections.

For example, for [cast iron radiators](https://velarshop.ru/model/nostalgia500/), we display the following table:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/umr43t4td2m65goln7z2.png)

In each row, a different number of sections is displayed, ranging from 3 to 15. Consequently, the total cost of painting the radiator varies. Therefore, we cannot simply pass the painting price value from the store. Instead, we will pass a function that calculates the radiator cost based on the radiator data, including the number of sections.

To achieve this, we create a separate store responsible for calculating the product price based on the available parameters:

``` typescript
/src/features/item/ItemTotalCost/store/store.ts

import { computed } from 'nanostores'
import { colorPricePerSection } from '@features/options/SelectColor'

.... 
// другие опции
...

const getColorCost = computed(colorPricePerSection, (colorPricePerSection) =>
  (model: ModelJson, radiator: RadiatorJson) =>
     colorPricePerSection * (parseInt(radiator?.sections || "0"))
  ))

...

const getItemTotalCost = computed(
  [ getColorCost, getConnectionCost, getSomeOtherCost ],
      ( getColorCost, getConnectionCost, getSomeOtherCost ) => 
           (model: ModelJson, radiator: RadiatorJson) => (
                 getColorCost(model,radiator) + getConnectionCost(model,radiator) + ...
           )
)

export { getItemTotalCost }
```
Now we have a function that we can pass to the product table and obtain the total cost for each variant. The cost will automatically update every time the user changes the selected options.

## Shopping cart

The shopping cart is also placed in a separate folder, which contains the `<BuyButton />` component responsible for adding items to the cart, as well as the store responsible for calculating the total purchase amount.

The store looks like this:

``` typescript
/src/features/order/ShoppingCart/store/store.ts


import { persistentAtom } from "@nanostores/persistent"
import { computed } from 'nanostores'
import type { ShoppingCart } from "@entities/shopping-cart"

const version = await import.meta.env.PUBLIC_LOCAL_STORAGE_VERSION

const storeShoppingCart = persistentAtom<ShoppingCart>(`velarshop_shopping_cart/${ version }`, { items: [] }, {
  encode: JSON.stringify,
  decode: JSON.parse,
})

const storeCartTotalPrice = computed(storeShoppingCart, (shoppingCart) => {
  return shoppingCart.items.reduce((total, item) => total + item.price * item.qnty, 0)
})

const storeCartTotalQnty = computed(storeShoppingCart, (shoppingCart) => {
  return shoppingCart.items.reduce((total, item) => total + item.qnty, 0)
})

const storeUniqueItemsQnty = computed(storeShoppingCart, (shoppingCart) => {
  return shoppingCart.items.length
})

export {
  storeShoppingCart,
  storeCartTotalPrice,
  storeCartTotalQnty,
  storeUniqueItemsQnty
}
```
Here, the logic is similar: there is the main variable, `storeShoppingCart`, where the added items are stored, and there are derived variables used to display data in the application.

The only difference is the addition of the `encode`/`decode` properties when creating `storeShoppingCart`. Since it is not a primitive but an array of objects, specifying how to transform the data before saving and retrieving it from local storage is necessary.

## Summary

Working with **AstroJS** has proven to be quite simple and enjoyable. The need to embed JSX components as isolated blocks help maintain the overall architecture of the application.

When compared to **NextJS**, at least for small and simple websites, Astro is much easier and more pleasant to work with. If we add the impressive PageSpeed scores to the equation, the choice in favor of this framework becomes even more evident.

P.S. I haven't had the opportunity to work with the new features of Next13 (with the app folder) yet. Therefore, the comparison with Astro may not be entirely fair.