# Data Loading Principles and Patterns ## Contents - [Fundamental Rules](#fundamental-rules) - [Think Before You Code Checklist](#think-before-you-code-checklist) - [Common Mistake vs Correct Pattern](#common-mistake-vs-correct-pattern) - [Working with Tanstack Query](#working-with-tanstack-query) - [Fetching Data with useQuery](#fetching-data-with-usequery) - [Basic Query](#basic-query) - [Paginated Query](#paginated-query) - [Query with Dependencies](#query-with-dependencies) - [Fetching Multiple Items by IDs](#fetching-multiple-items-by-ids) - [Updating Data with useMutation](#updating-data-with-usemutation) - [Basic Mutation](#basic-mutation) - [Mutation with Loading State](#mutation-with-loading-state) - [Create Mutation](#create-mutation) - [Delete Mutation](#delete-mutation) - [Cache Invalidation Guidelines](#cache-invalidation-guidelines) - [Important Notes about Metadata](#important-notes-about-metadata) - [Common Patterns](#common-patterns) - [Pattern: Fetching Data with Pagination](#pattern-fetching-data-with-pagination) - [Pattern: Search with Debounce](#pattern-search-with-debounce) - [Pattern: Updating Metadata with useMutation](#pattern-updating-metadata-with-usemutation) - [Common Issues & Solutions](#common-issues--solutions) - [Complete Example: Widget with Separate Queries](#complete-example-widget-with-separate-queries) ## Fundamental Rules 1. **ALWAYS use the Medusa JS SDK** - NEVER use regular fetch() for API requests (missing headers causes authentication/authorization errors) 2. **Display data must load on mount** - Any data shown in the widget's main UI must be fetched when the component mounts, not conditionally 3. **Separate concerns** - Modal/form data queries should be independent from display data queries 4. **Handle reference data properly** - When storing IDs/references (in metadata or elsewhere), you must fetch the full entities to display them 5. **Always show loading states** - Users should see loading indicators, not empty states, while data is being fetched 6. **Invalidate the right queries** - After mutations, invalidate the queries that provide display data, not just the modal queries ## Think Before You Code Checklist Before implementing any widget that displays data: - [ ] Am I using the Medusa JS SDK for all API requests (not regular fetch)? - [ ] For built-in endpoints, am I using existing SDK methods (not sdk.client.fetch)? - [ ] What data needs to be visible immediately? - [ ] Where is this data stored? (metadata, separate endpoint, related entities) - [ ] If storing IDs, how will I fetch the full entities for display? - [ ] Are my display queries separate from interaction queries? - [ ] Have I added loading states for all data fetches? - [ ] Which queries need invalidation after updates to refresh the display? ## Common Mistake vs Correct Pattern ### ❌ WRONG - Single query for both display and modal: ```tsx // This breaks on page refresh! const { data } = useQuery({ queryFn: () => sdk.admin.product.list(), enabled: modalOpen, // Display won't work on mount! }) // Trying to display filtered data from modal query const displayItems = data?.filter((item) => ids.includes(item.id)) // No data until modal opens ``` **Why this is wrong:** - On page refresh, modal is closed, so query doesn't run - User sees empty state instead of their data - Display depends on modal interaction ### ✅ CORRECT - Separate queries with proper invalidation: ```tsx // Display data - loads immediately const { data: displayData } = useQuery({ queryFn: () => fetchDisplayData(), queryKey: ["display-data", product.id], // No 'enabled' condition - loads on mount }) // Modal data - loads when needed const { data: modalData } = useQuery({ queryFn: () => fetchModalData(), queryKey: ["modal-data"], enabled: modalOpen, // OK for modal-only data }) // Mutation with proper cache invalidation const updateMutation = useMutation({ mutationFn: updateFunction, onSuccess: () => { // Invalidate display data query to refresh UI queryClient.invalidateQueries({ queryKey: ["display-data", product.id] }) // Also invalidate the entity if it caches the data queryClient.invalidateQueries({ queryKey: ["product", product.id] }) }, }) ``` **Why this is correct:** - Display query runs immediately on component mount - Modal query only runs when needed - Proper invalidation ensures UI updates after changes - Each query has a clear, separate responsibility ## Using the Medusa JS SDK **⚠️ CRITICAL: ALWAYS use the Medusa JS SDK for ALL API requests - NEVER use regular fetch()** ### Why the SDK is Required - **Admin routes** require `Authorization` header and session cookie - SDK adds them automatically - **Store routes** require `x-publishable-api-key` header - SDK adds them automatically - **Regular fetch()** doesn't include these headers → authentication/authorization errors - Using existing SDK methods provides better type safety and autocomplete ### When to Use What ```tsx import { sdk } from "../lib/client" // ✅ CORRECT - Built-in endpoint: Use existing SDK method const product = await sdk.admin.product.retrieve(productId, { fields: "+metadata,+variants.*" }) // ✅ CORRECT - Custom endpoint: Use sdk.client.fetch() const reviews = await sdk.client.fetch(`/admin/products/${productId}/reviews`) // ❌ WRONG - Using regular fetch for ANY endpoint const response = await fetch(`http://localhost:9000/admin/products/${productId}`) // ❌ Error: Missing Authorization header! ``` ### SDK Method Selection **For built-in Medusa endpoints:** - Use existing SDK methods: `sdk.admin.product.list()`, `sdk.store.product.list()`, etc. - Provides type safety, autocomplete, and proper header handling - Reference: [Medusa JS SDK Documentation](https://docs.medusajs.com/resources/medusa-js-sdk) **For custom API routes:** - Use `sdk.client.fetch()` for your custom endpoints - SDK still handles all required headers (auth, API keys) - Pass plain objects to body (SDK handles JSON serialization) ## Working with Tanstack Query Admin widgets and routes have Tanstack Query pre-configured. **⚠️ pnpm Users**: You MUST install `@tanstack/react-query` BEFORE using `useQuery` or `useMutation`. Install with exact version from dashboard: ```bash pnpm list @tanstack/react-query --depth=10 | grep @medusajs/dashboard pnpm add @tanstack/react-query@[exact-version] ``` **npm/yarn Users**: DO NOT install `@tanstack/react-query` - it's already available through dashboard dependencies. ## Fetching Data with useQuery ### Basic Query ```tsx import { useQuery } from "@tanstack/react-query" import { sdk } from "../lib/client" const { data, isLoading, error } = useQuery({ queryFn: () => sdk.admin.product.retrieve(productId, { fields: "+metadata,+variants.*", }), queryKey: ["product", productId], }) ``` ### Paginated Query ```tsx const limit = 15 const offset = pagination.pageIndex * limit const { data: products } = useQuery({ queryFn: () => sdk.admin.product.list({ limit, offset, q: searchTerm, // for search }), queryKey: ["products", limit, offset, searchTerm], keepPreviousData: true, // Prevents UI flicker during pagination }) ``` ### Query with Dependencies ```tsx // Only fetch if productId exists const { data } = useQuery({ queryFn: () => sdk.admin.product.retrieve(productId), queryKey: ["product", productId], enabled: !!productId, // Only run when productId is truthy }) ``` ### Fetching Multiple Items by IDs ```tsx // For display - fetch specific items by IDs const { data: displayProducts } = useQuery({ queryFn: async () => { if (selectedIds.length === 0) return { products: [] } const response = await sdk.admin.product.list({ id: selectedIds, // Fetch only the selected products limit: selectedIds.length, }) return response }, queryKey: ["related-products-display", selectedIds], enabled: selectedIds.length > 0, // Only fetch if there are IDs }) ``` ## Updating Data with useMutation ### Basic Mutation ```tsx import { useMutation, useQueryClient } from "@tanstack/react-query" import { toast } from "@medusajs/ui" const queryClient = useQueryClient() const updateProduct = useMutation({ mutationFn: (payload) => sdk.admin.product.update(productId, payload), onSuccess: () => { // Invalidate and refetch queryClient.invalidateQueries({ queryKey: ["product", productId] }) toast.success("Product updated successfully") }, onError: (error) => { toast.error(error.message || "Failed to update product") }, }) // Usage const handleSave = () => { updateProduct.mutate({ metadata: { ...existingMetadata, new_field: "value", }, }) } ``` ### Mutation with Loading State ```tsx ``` ### Create Mutation ```tsx const createProduct = useMutation({ mutationFn: (data) => sdk.admin.product.create(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["products"] }) toast.success("Product created successfully") setOpen(false) }, }) ``` ### Delete Mutation ```tsx const deleteProduct = useMutation({ mutationFn: (id) => sdk.admin.product.delete(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["products"] }) toast.success("Product deleted") }, }) ``` ## Cache Invalidation Guidelines After mutations, invalidate the queries that affect what the user sees: ```tsx onSuccess: () => { // Invalidate the entity itself if it stores the data queryClient.invalidateQueries({ queryKey: ["product", productId] }) // Invalidate display-specific queries queryClient.invalidateQueries({ queryKey: ["related-products", productId] }) // Don't need to invalidate modal selection queries // queryClient.invalidateQueries({ queryKey: ["products-list"] }) // Not needed } ``` **Key Points:** - Use specific query keys with IDs for targeted invalidation - Invalidate both the entity and display data queries when needed - Consider what the user sees and ensure those queries refresh - Modal/selection queries typically don't need invalidation ## Important Notes about Metadata - When updating nested objects in metadata, pass the entire object (Medusa doesn't merge nested objects) - To remove a metadata property, set it to an empty string - Metadata is stored as JSONB in the database **Example: Updating Metadata** ```tsx // ✅ CORRECT - Spread existing metadata updateProduct.mutate({ metadata: { ...product.metadata, new_field: "value", }, }) // ❌ WRONG - Overwrites all metadata updateProduct.mutate({ metadata: { new_field: "value", // All other fields lost! }, }) ``` ## Common Patterns ### Pattern: Fetching Data with Pagination ```tsx const limit = 15 const offset = pagination.pageIndex * limit const { data } = useQuery({ queryFn: () => sdk.admin.product.list({ limit, offset }), queryKey: ["products", limit, offset], keepPreviousData: true, // Prevents UI flicker during pagination }) ``` ### Pattern: Search with Debounce ```tsx import { useDebouncedValue } from "@mantine/hooks" // or implement your own const [search, setSearch] = useState("") const [debouncedSearch] = useDebouncedValue(search, 300) const { data } = useQuery({ queryFn: () => sdk.admin.product.list({ q: debouncedSearch }), queryKey: ["products", debouncedSearch], }) ``` ### Pattern: Updating Metadata with useMutation ```tsx const updateMetadata = useMutation({ mutationFn: (metadata) => sdk.admin.product.update(productId, { metadata }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["product", productId] }) toast.success("Updated successfully") }, }) ``` ## Common Issues & Solutions ### Authentication/Authorization errors when fetching data **Symptoms:** - API returns 401 Unauthorized or 403 Forbidden - "Missing x-publishable-api-key header" error - "Unauthorized" error on admin routes **Cause:** Using regular `fetch()` instead of the Medusa JS SDK **Solution:** ```tsx // ❌ WRONG - Missing required headers const { data } = useQuery({ queryFn: () => fetch('http://localhost:9000/admin/products').then(r => r.json()), queryKey: ["products"] }) // ✅ CORRECT - SDK handles headers automatically const { data } = useQuery({ queryFn: () => sdk.admin.product.list(), queryKey: ["products"] }) // ✅ CORRECT - For custom routes const { data } = useQuery({ queryFn: () => sdk.client.fetch('/admin/custom-route'), queryKey: ["custom-data"] }) ``` ### "No QueryClient set, use QueryClientProvider to set one" - **pnpm users**: You forgot to install `@tanstack/react-query` before implementing. Install it now with the exact version from dashboard - **npm/yarn users**: You incorrectly installed `@tanstack/react-query` - remove it from package.json - Never wrap your component in QueryClientProvider - it's already provided ### Search not filtering results - The search happens server-side via the `q` parameter - Make sure to pass the search value in your queryFn: ```tsx queryFn: () => sdk.admin.product.list({ q: searchValue }) ``` ### Metadata updates not working - Always pass the complete metadata object (partial updates aren't merged) - To remove a field, set it to an empty string, not null or undefined ### Widget not refreshing after mutation - Use queryClient.invalidateQueries() with the correct query key - Ensure your query key includes all dependencies (search, pagination, etc.) ### Data shows empty on page refresh - Your query has `enabled: modalOpen` or similar condition - Display data should NEVER be conditionally enabled based on UI state - Move conditional queries to modals/forms only ## Complete Example: Widget with Separate Queries ```tsx import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { useState, useMemo } from "react" import { Container, Heading, Button, FocusModal, toast } from "@medusajs/ui" import { sdk } from "../lib/client" const RelatedProductsWidget = ({ data: product }) => { const [open, setOpen] = useState(false) const queryClient = useQueryClient() // Parse existing related product IDs from metadata const relatedIds = useMemo(() => { if (product?.metadata?.related_product_ids) { try { const ids = JSON.parse(product.metadata.related_product_ids) return Array.isArray(ids) ? ids : [] } catch { return [] } } return [] }, [product?.metadata?.related_product_ids]) // Query 1: Fetch selected products for display (loads on mount) const { data: displayProducts } = useQuery({ queryFn: async () => { if (relatedIds.length === 0) return { products: [] } const response = await sdk.admin.product.list({ id: relatedIds, limit: relatedIds.length, }) return response }, queryKey: ["related-products-display", relatedIds], enabled: relatedIds.length > 0, }) // Query 2: Fetch products for modal selection (only when modal is open) const { data: modalProducts, isLoading } = useQuery({ queryFn: () => sdk.admin.product.list({ limit: 10, offset: 0 }), queryKey: ["products-selection"], enabled: open, // Only load when modal is open }) // Mutation to update the product metadata const updateProduct = useMutation({ mutationFn: (relatedProductIds) => { return sdk.admin.product.update(product.id, { metadata: { ...product.metadata, related_product_ids: JSON.stringify(relatedProductIds), }, }) }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["product", product.id] }) queryClient.invalidateQueries({ queryKey: ["related-products-display"] }) toast.success("Related products updated") setOpen(false) }, }) return (
Related Products
{/* Display current selection */}
{displayProducts?.products.map((p) => (
{p.title}
))}
{/* Modal for selection */} {/* Modal content with selection UI */}
) } ```