One codebase, three shop types, and a Zustand subscription loop crash.
Hubal POS split into three adaptive variants — pharmacy, cosmetics, supermarket — from a single codebase. A Zustand subscription pattern crashed the app at the end of a long session.
003 documented the scaffold — offline-first sync, the atomic sale function, the immutable ledger. That was January. Today Hubal POS became a different thing.
The constraint
A pharmacy cashier works on a desktop and needs calculator speed. A cosmetics shop runs on a phone and sends receipts over WhatsApp. A supermarket uses the original POS unchanged.
Three different interfaces. The alternative was three separate repositories — which meant tripling the offline sync logic, the IndexedDB handlers, and every bug fix applied to any of them. I built one codebase instead.
The proof
The routing switch
On login, the app reads user.shopType and lazy-loads the correct layout:
// App.tsx
const shopType = useAuth((s) => s.user?.shopType);
if (shopType === "pharmacy") return <PharmacyLayout />;
if (shopType === "cosmetics") return <CosmeticsLayout />;
return <AppGeneral />;
AppGeneral is the original POS extracted into its own file and left untouched. All three layouts share the same Zustand stores, the same IndexedDB sync engine, and the same PinLockScreen.
Pharmacy — desktop, three columns
PharmacyPOSPage is a three-column screen built for scanner keyboards:
- Left: auto-focus search input optimised for barcode scanners
- Middle: full product detail on focus — cost price, retail price, profit margin, expiry status, monthly sales count
- Right: cart with EVC+, e-Dahab, and cash checkout. No print dialogue. Speed over paper.
PharmacyDashboardPage shows today’s revenue, margin percentage, and net profit. A near-expiry widget flags products expiring in under 3 months (amber) or under 1 month (red). A fast movers widget lists the top 8 products sold in the last 30 days from local sales invoices.
The Product type now carries an optional expiryDate field. ProductForm got a date picker. useProducts writes it through the IndexedDB pipeline.
// types/index.ts
interface Product {
// ...existing fields
expiryDate?: string; // YYYY-MM-DD
}
Cosmetics — mobile-first
CosmeticsHomePage is a touch grid with product images and horizontal category chips — Lipstick, Skincare, Haircare, Nailcare, Perfume. Tap adds to cart with a badge count.
CosmeticsCartPage has swipe-to-delete and a WhatsApp receipt compiler — it formats an invoice in Arabic and Somali-friendly text and shares it directly to a customer number.
Shared additions
PinLockScreen triggers after 5 minutes of inactivity. Cashiers enter a PIN on a touchpad to resume.
BarcodeStickerModal lets cashiers select a product, enter a sticker count, and print EAN/CODE128 barcodes to a thermal label printer via jsbarcode.
useDrafts is a new Zustand store that saves the current cart, lets the cashier serve a new customer, and resumes the saved cart later.
The crash
Hours into the session the app stopped loading and threw this:

Fig 1: Maximum update depth exceeded. React killed the call stack.
Maximum update depth exceeded. This can happen when a component
repeatedly calls setState inside componentWillUpdate or
componentDidUpdate. React limits the number of nested updates to
prevent infinite loops.
I thought it was the barcode scanner. PharmacyPOSPage uses a useBarcodeScanner hook that takes an opts object — maxKeystrokeGap, minLength, and similar settings. My first guess was that this object was being recreated on every render, constantly retriggering the scanner’s useEffect and spinning the cycle. I went looking there first. That was not it.
The actual cause was simpler and I had already fixed the same mistake earlier in the same session.
The root cause
PharmacyLayout and SyncIndicator were both subscribing to entire Zustand stores:
// What was written — subscribes to the whole store
const { user, isLoading } = useAuth();
In PharmacyLayout, every sync progress update — the engine ticking through its queue — triggered a full layout re-render. That recreated internal context states, which triggered more updates, which triggered more re-renders.
SyncIndicator was the actual loop. It subscribed to the entire sync store. Every progress tick re-rendered the component, which caused its useEffect to unmount and remount, which restarted the sync listener, which triggered another progress tick. React hit its depth limit and stopped.
The fix
Targeted selectors on every store subscription:
// PharmacyLayout.tsx
// Before
const { user, isLoading } = useAuth();
// After
const user = useAuth((s) => s.user);
const isLoading = useAuth((s) => s.isLoading);
// SyncIndicator.tsx — listener now mounts exactly once
const subscribedRef = useRef(false);
useEffect(() => {
if (subscribedRef.current) return;
subscribedRef.current = true;
// bind sync event listener
}, []);
pnpm typecheck — 0 errors. The layouts loaded clean.
Where this sits
| Item | Status |
|---|---|
| Single codebase adaptive architecture | Done |
| PharmacyLayout — three-column desktop POS | Done |
| PharmacyDashboardPage — expiry alerts and fast movers | Done |
| CosmeticsLayout — mobile-first tab navigation | Done |
| CosmeticsHomePage — touch grid with categories | Done |
| CosmeticsCartPage — WhatsApp receipt compiler | Done |
| PinLockScreen — inactivity timeout | Done |
| BarcodeStickerModal — thermal label printing | Done |
| useDrafts — suspend and resume carts | Done |
| Infinite re-render crash | Fixed |
| Field testing with pharmacy in Mogadishu | Not started |
The fix was a selector function and a ref. I had already written the same fix earlier in the same session for a different component and still missed it the second time. That is what tired looks like.