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.
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.
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:
/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, we display the following table:
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 }
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
}
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)
🫶🏻
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