Skip to content

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.

bash
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)

typescript
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.
  • 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 call useStore(...) to read state
  • cartTotal 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:

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

typescript
// 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)

typescript
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

typescript
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

typescript
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

typescript
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

bash
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(...) or useStore.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!