Building an E-commerce App with Neutrix (Hook-Only)
This tutorial will walk you through creating a simple e-commerce application using Neutrix for state management without any providers. We'll rely on the hook-only usage of createNeutrixStore (i.e., no <Provider>
). So follow along with the steps below.
Project Setup
We do have a full working example inside /examples so you can clone it into your repo and try it for yourself. Alternatively if you want to follow along, you can proceed on below.
npm create vite@latest shop-app -- --template react-ts
cd shop-app
npm install neutrix react-router-dom
Remove or rename boilerplate files if you want.
Create your own store.ts, ShopContent.tsx, CartDrawer.tsx, etc.
Structure
Create a folder called src
. The structure should be as such
.
├── App.css
├── App.tsx
├── CartDrawer.tsx
├── Header.tsx
├── ShopContent.tsx
├── WishList.tsx
├── assets
│ └── react.svg
├── index.css
├── main.tsx
├── store.ts
├── styles.css
└── vite-env.d.ts
CodeSandBox
You can see the result in this CodeSandbox demo. Note that your environment may differ slightly from the tutorial below.
Introduction
Neutrix provides a straightforward way to manage your React state. This will show you how to:
- Organize a store for products and cart items.
- Use a hook-only store (no provider).
- Define action-like helper functions (
addToCart
,removeFromCart
). - Create a computed value (
cartTotal
) that updates automatically on state changes.
Note: Apologies that the ui is not amazing. We're just running through how to use Neutrix
Tutorial
1. Store Setup (Hook-only)
import { createNeutrixStore } from 'neutrix';
export interface Product {
id: number;
name: string;
price: number;
}
export interface ShopState {
products: Product[];
cart: Record<number, number>;
cartOpen: boolean;
}
const initialState: ShopState = {
products: [
{ id: 1, name: 'Mechanical Keyboard', price: 149.99 },
{ id: 2, name: 'Gaming Mouse', price: 59.99 },
{ id: 3, name: 'Monitor', price: 299.99 },
],
cart: {},
cartOpen: false
};
export const { useStore, store } = createNeutrixStore<ShopState>(initialState);
export function addToCart(id: number) {
const { cart } = store.getState();
store.set('cart', {
...cart,
[id]: (cart[id] || 0) + 1
});
}
export function removeFromCart(id: number) {
const { cart } = store.getState();
store.set('cart', {
...cart,
[id]: Math.max((cart[id] || 0) - 1, 0)
});
}
export function toggleCart() {
const { cartOpen } = store.getState();
store.set('cartOpen', !cartOpen);
}
export const cartTotal = store.computed('cartTotal', (state) =>
Object.entries(state.cart).reduce((total, [id, qty]) => {
const product = state.products.find((p) => p.id === +id);
return total + (product?.price || 0) * qty;
}, 0)
);
createNeutrixStore<ShopState>(initialState)
returns:- A React hook:
useStore(selector)
. - A low-level store: store with
get
,set
,computed
, etc.
- A React hook:
No
<Provider>
is used here. This is purely a hook-based global store (Zustand-like).cartTotal
is a computed function we can call in our components.
Key Points
useStore
is our main hook. Any React component can calluseStore(...)
to read statecartTotal
is a computed value that will update automatically whenever products or cart changes
2. Displaying Products & Cart: ShopContent.tsx
Adding items to cart is super simple. Just one function:
// ShopContent.tsx
import { useStore } from './store';
import { addToCart, removeFromCart, cartTotal, Product } from './store';
export function ShopContent() {
// 1) Read from store
const products = useStore((state) => state.products);
const cart = useStore((state) => state.cart);
// 2) Use computed cartTotal
const total = useStore(() => cartTotal());
return (
<>
<div className="products">
<h2>Products</h2>
{products.map((product: Product) => (
<div key={product.id} className="product">
<h3>{product.name}</h3>
<p>${product.price.toFixed(2)}</p>
<button onClick={() => addToCart(product.id)}>Add to Cart</button>
</div>
))}
</div>
<div className="cart">
<h2>Cart</h2>
{products.map((product: Product) => {
const quantity = cart[product.id] || 0;
if (quantity === 0) return null;
return (
<div key={product.id} className="cart-item">
<h3>{product.name}</h3>
<p>Quantity: {quantity}</p>
<button onClick={() => removeFromCart(product.id)}>Remove</button>
</div>
);
})}
<div className="total">
<h3>Total: ${total.toFixed(2)}</h3>
</div>
</div>
</>
);
}
Key points:
useStore((state) => state.products)
auto-subscribes the component to products.useStore(() => cartTotal())
pulls the computed total.
3. Cart Drawer: CartDrawer.tsx
// CartDrawer.tsx
import { useStore } from './store';
import { cartTotal, toggleCart, removeFromCart } from './store';
import type { Product, ShopState } from './store';
export function CartDrawer() {
const isOpen = useStore((state: ShopState) => state.cartOpen);
const cart = useStore((state: ShopState) => state.cart);
const products = useStore((state: ShopState) => state.products);
const total = useStore(() => cartTotal());
if (!isOpen) return null; // hidden if cartOpen = false
return (
<div style={{
position: 'fixed',
right: 0, top: 0, bottom: 0,
width: '300px', backgroundColor: 'white',
boxShadow: '-2px 0 5px rgba(0,0,0,0.1)',
padding: '1rem', zIndex: 1000
}}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<h2>Cart</h2>
<button onClick={toggleCart}>✕</button>
</div>
{Object.entries(cart).map(([productId, quantity]) => {
const product = products.find((p: Product) => p.id === Number(productId));
if (!product || quantity === 0) return null;
return (
<div key={productId} style={{ marginTop: '1rem' }}>
<div>{product.name}</div>
<div>Quantity: {quantity}</div>
<div>${(product.price * quantity).toFixed(2)}</div>
<button onClick={() => removeFromCart(Number(productId))}>Remove</button>
</div>
);
})}
<div style={{ marginTop: '2rem', borderTop: '1px solid #eee', paddingTop: '1rem' }}>
<strong>Total: ${total.toFixed(2)}</strong>
</div>
</div>
);
}
4. WishList.tsx
(Optional)
import { useStore } from './store';
import type { Product } from './store';
export function Wishlist() {
const products = useStore((state) => state.products);
const cart = useStore((state) => state.cart);
return (
<div>
<h2>Wishlist</h2>
<div>
<p>Items currently in your cart:</p>
{products.map((product: Product) => {
const quantity = cart[product.id] || 0;
if (quantity === 0) return null;
return (
<div key={product.id}>
{product.name} - Quantity: {quantity}
</div>
);
})}
</div>
</div>
);
}
5. Putting it together: App.tsx
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'
import { Header } from './Header'
import { CartDrawer } from './CartDrawer'
import { ShopContent } from './ShopContent'
import { Wishlist } from './WishList'
function App() {
return (
<BrowserRouter>
<div className="app">
<Header />
<CartDrawer />
<nav style={{
padding: '1rem',
borderBottom: '1px solid #eee'
}}>
<Link to="/" style={{
marginRight: '1rem',
textDecoration: 'none',
color: '#333'
}}>
Shop
</Link>
<Link to="/wishlist" style={{
textDecoration: 'none',
color: '#333'
}}>
Wishlist
</Link>
</nav>
<Routes>
<Route path="/" element={<ShopContent />} />
<Route path="/wishlist" element={<Wishlist />} />
</Routes>
</div>
</BrowserRouter>
)
}
export default App
In main.tsx
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import App from "./App"
import "./styles.css"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
)
6. Header.tsx
import { useStore, cartTotal, toggleCart } from './store'
export function Header() {
const total = useStore(() => cartTotal())
return (
<div style={{
padding: '1rem',
borderBottom: '1px solid #eee',
display: 'flex',
justifyContent: 'space-between'
}}>
<h1>Shop</h1>
<div>
<span>Total: ${total.toFixed(2)} </span>
<button onClick={toggleCart}>🛒</button>
</div>
</div>
)
}
7. To run
npm run dev
Conclusion & Key Takeaways
- No Provider: We used createNeutrixStore without { provider: true }. This yields a hook-only global store.
- No Boilerplate Nonsense: You can just call
useStore.store.set(...)
oruseStore.store.get(...)
. - Computed Values:
useStore.store.computed(...)
automatically updates derived data like cartTotal. - One Source of Truth: The entire app shares the same store.
- This is more “Zustand-like”. If you ever need multiple stores or SSR, you can switch to
createNeutrixStore
with a{ provider: true }
option.
Happy coding with Neutrix!