DEV Community

Petr Tcoi
Petr Tcoi

Posted on • Updated on

State management with Astro and @nanostores

A convenient opportunity arose to try creating a small website using the AstroJS framework - the radiator store 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

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
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
│     │
Enter fullscreen mode Exit fullscreen mode

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:

/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
}

Enter fullscreen mode Exit fullscreen mode

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, we display the following table:

Image description

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:

/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 }
Enter fullscreen mode Exit fullscreen mode

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:

/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
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (2)

Collapse
 
euaaaio profile image
Eduard Aksamitov

🫶🏻

Collapse
 
roonready profile image
roonready

Why you didnt use persistentMap for keeping order ?
it looks good for keeping,reading,editing each row separately without encoding/decoding whole order to/from string