deco-variant-selection-perf
Product Variant Selection Performance
Patterns for making variant selection instant in Deco storefronts on TanStack Start. Discovered while optimizing espacosmart-storefront where clicking a variant triggered 2 full loadCmsPage server calls (1300ms+ each).
When to Use This Skill
- Variant changes on PDP are slow (>500ms)
- HAR analysis shows duplicate
loadCmsPagecalls with/without?skuId preload="intent"on variant<Link>causes double-fetch- Need to add loading feedback for cross-product variant navigation
- Implementing a new variant selector component
Key Insight: Two Types of "Variant" Navigation
| Type | Example | Data needed | Approach |
|---|---|---|---|
| Same product, different SKU | Size 90x0.8 → 90x0.95 | Already loaded in isVariantOf.hasVariant |
replaceState — zero fetch |
| Different product | Product A → Product B (visual variation) | New product data | navigate() — single fetch |
The CMS block "PDP Loader" does NOT pass skuId to the server loader. All SKU data comes in the first load via isVariantOf.hasVariant. The ?skuId in the URL is purely for bookmarking/sharing.
Problem: Double-Fetch on Variant Click
Root Cause
When using <Link to="/slug/p?skuId=160" preload="intent">:
- Hover → TanStack Router fires a prefetch. The
loaderDepsfiltersskuId, so it sendsloadCmsPage({ data: "/slug/p" }). - Click → Router fires the real navigation. Depending on timing and staleTime, it may fire
loadCmsPage({ data: "/slug/p?skuId=160" }).
Result: 2 server calls for one variant click, each taking 1-2 seconds (full CMS resolution + section loaders + VTEX API calls).
How to Diagnose
Export a HAR from Chrome DevTools (Network tab → Export HAR). Analyze:
# Find all loadCmsPage calls
import json, urllib.parse, base64
with open('localhost.har') as f:
har = json.load(f)
for e in har['log']['entries']:
url = e['request']['url']
if '/_serverFn/' not in url:
continue
qs = url.split('?')[1] if '?' in url else ''
params = urllib.parse.parse_qs(qs)
if 'payload' in params:
payload = json.loads(urllib.parse.unquote(params['payload'][0]))
# Extract the "data" (path) from TanStack's serialized payload
print(f"{e.get('time',0):.0f}ms skuId={'skuId' in str(payload)}")
If you see two calls for the same slug (one with skuId, one without), this is the double-fetch.
Fix 1: Same-Product Variants — replaceState (Zero Fetch)
Replace <Link> with <a> + window.history.replaceState. This changes the URL for bookmarking without triggering any TanStack Router navigation or loader.
Before (slow)
import { Link } from "@tanstack/react-router";
function VariantSelector({ product }: { product: Product }) {
const possibilities = useVariantPossibilities(hasVariant, product);
return (
// ...
<Link to={relativeLink} preload="intent">
<Avatar variant={relativeLink === relative(url) ? "active" : "default"} />
</Link>
);
}
After (instant)
import { useState, useCallback } from "react";
function VariantSelector({ product }: { product: Product }) {
const possibilities = useVariantPossibilities(hasVariant, product);
const [currentUrl, setCurrentUrl] = useState(() => relative(product.url));
const handleVariantClick = useCallback(
(e: React.MouseEvent<HTMLAnchorElement>, link: string) => {
e.preventDefault();
setCurrentUrl(link);
window.history.replaceState(null, "", link);
},
[],
);
return (
// ...
<a
href={relativeLink ?? "#"}
onClick={(e) => relativeLink && handleVariantClick(e, relativeLink)}
>
<Avatar
variant={relativeLink === currentUrl ? "active" : relativeLink ? "default" : "disabled"}
/>
</a>
);
}
Why This Works
replaceStatechanges browser URL without notifying TanStack Router → zero loader executionuseState(currentUrl)tracks the active variant for UI highlighting → instant re-render<a href>preserves accessibility (right-click, ctrl+click open in new tab)- The CMS PDP Loader does NOT use
skuIdfrom the URL — all variant data is inisVariantOf.hasVariant
When NOT to Use replaceState
- The variant changes the product (different
productGroupID) — usenavigate()instead - The server loader actually reads
skuIdfrom the request URL to fetch different data - SEO requires each variant to be a separate indexable page with unique server-rendered content
Fix 2: Cross-Product Variants — navigate() with Loading
For SkuVariation (different products shown as visual variations), use navigate() but WITHOUT preload="intent" and WITH a loading state.
import { useNavigate } from "@tanstack/react-router";
import { useState, useCallback, useEffect, useRef } from "react";
export default function SkuVariation({ products }: { products: Product[] | null }) {
const [loadingIdx, setLoadingIdx] = useState<number | null>(null);
const navigate = useNavigate();
const prevProducts = useRef(products);
// Reset loading when products change (navigation completed, component reused)
useEffect(() => {
if (prevProducts.current !== products) {
setLoadingIdx(null);
prevProducts.current = products;
}
}, [products]);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLAnchorElement>, link: string, idx: number) => {
e.preventDefault();
setLoadingIdx(idx);
navigate({ to: link });
},
[navigate],
);
if (!products?.length) return null;
return (
<ul>
{products.map((product, index) => {
const link = relative(product.url) ?? "#";
const isLoading = loadingIdx === index;
return (
<li key={index}>
<a href={link} onClick={(e) => handleClick(e, link, index)} className="relative">
<div className={`transition-opacity ${isLoading ? "opacity-30" : ""}`}>
<img src={product.image?.[0]?.url} width={35} height={35} />
</div>
{isLoading && (
<span className="loading loading-spinner loading-xs absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
)}
</a>
</li>
);
})}
</ul>
);
}
Key Details
| Aspect | Why |
|---|---|
preload={false} / no preload |
Prevents duplicate fetch (hover + click) |
useRef(prevProducts) + useEffect |
Resets loading when component reuses with new props |
No await navigate() |
navigate resolves when route renders — component may already be unmounted |
<a href> not <Link> |
Avoids TanStack Router's built-in prefetch behavior |
Fix 3: Remove preload="intent" from All Variant Links
Any <Link> with preload="intent" in variant selectors causes prefetch on hover, leading to:
- Extra server calls
- Race conditions with the click navigation
- Wasted bandwidth
Replace with preload={false} or use <a> elements:
# Find all variant links with preload="intent"
rg 'preload="intent"' src/components/product/ --glob '*.tsx' -l
Files to check:
ProductVariantSelector.tsxSkuVariation.tsxProductCardCategory.tsx(variant selector in PLP cards)
Verification
After applying fixes, export a new HAR and verify:
- Same-product variant click: Zero
loadCmsPagecalls (only image requests) - Cross-product variant click: Exactly 1
loadCmsPagecall per product - No duplicate calls: Each unique slug appears at most once
# Quick HAR verification
server_calls = [e for e in har['log']['entries'] if '/_serverFn/' in e['request']['url']]
print(f"Server calls: {len(server_calls)}")
# Should be 0 for same-product variants, N for N different products
Related Configuration
ignoreSearchParams in Route Config
The CMS catch-all route should filter skuId from loaderDeps:
const config = cmsRouteConfig({
siteName: "My Store",
defaultTitle: "My Store",
ignoreSearchParams: ["skuId"],
});
This prevents skuId changes from being treated as dependency changes by TanStack Router.
staleTime in Dev Mode
With staleTime: 0 (default in dev), even identical loaderDeps trigger re-fetch. Set a minimum staleTime in dev:
// In routeCacheDefaults()
if (isDev) return { staleTime: 5_000, gcTime: 30_000 };
Related Skills
| Skill | Purpose |
|---|---|
deco-cms-layout-caching |
Cache layout sections (Header/Footer) to avoid redundant API calls |
deco-api-call-dedup |
In-flight deduplication for VTEX API calls |
deco-cms-route-config |
CMS route configuration in @decocms/start |
deco-tanstack-storefront-patterns |
General patterns for deco-start storefronts |