Files
2026-03-07 11:07:45 -03:00

16 KiB

Data Loading Principles and Patterns

Contents

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:

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

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

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

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:

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

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

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

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

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

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

<Button
  onClick={handleSave}
  isLoading={updateProduct.isPending}
>
  Save
</Button>

Create Mutation

const createProduct = useMutation({
  mutationFn: (data) => sdk.admin.product.create(data),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ["products"] })
    toast.success("Product created successfully")
    setOpen(false)
  },
})

Delete Mutation

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:

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

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

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

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

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:

// ❌ 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:
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

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 (
    <Container>
      <div className="flex items-center justify-between">
        <Heading>Related Products</Heading>
        <Button onClick={() => setOpen(true)}>Edit</Button>
      </div>

      {/* Display current selection */}
      <div>
        {displayProducts?.products.map((p) => (
          <div key={p.id}>{p.title}</div>
        ))}
      </div>

      {/* Modal for selection */}
      <FocusModal open={open} onOpenChange={setOpen}>
        {/* Modal content with selection UI */}
      </FocusModal>
    </Container>
  )
}