UTA DevHub

Products Domain

Complete product catalog implementation with data models, services, and state management

Products Domain

Production-ready product catalog patterns with type-safe data models and React Query integration

Overview

This domain demonstrates a complete product catalog system including data models, API services, caching strategies, and UI state management using React Query.

Implementation Files

Core Types

// core/domains/products/types.ts
export interface Product {
  id: string;
  name: string;
  description: string;
  price: number;
  currency: string;
  images: ProductImage[];
  category: ProductCategory;
  brand: string;
  sku: string;
  stock: number;
  isActive: boolean;
  rating: number;
  reviewCount: number;
  tags: string[];
  variants?: ProductVariant[];
  metadata: Record<string, any>;
  createdAt: string;
  updatedAt: string;
}
 
export interface ProductImage {
  id: string;
  url: string;
  alt: string;
  width: number;
  height: number;
  isPrimary: boolean;
}
 
export interface ProductCategory {
  id: string;
  name: string;
  slug: string;
  parentId: string | null;
  level: number;
  imageUrl?: string;
}
 
export interface ProductVariant {
  id: string;
  name: string;
  sku: string;
  price: number;
  stock: number;
  attributes: ProductAttribute[];
}
 
export interface ProductAttribute {
  name: string;
  value: string;
  type: 'color' | 'size' | 'material' | 'style';
}
 
export interface ProductFilters {
  categoryId?: string;
  brandIds?: string[];
  priceRange?: {
    min: number;
    max: number;
  };
  rating?: number;
  tags?: string[];
  inStock?: boolean;
  search?: string;
}
 
export interface ProductSortOptions {
  field: 'name' | 'price' | 'rating' | 'createdAt';
  direction: 'asc' | 'desc';
}
 
export interface ProductListResponse {
  products: Product[];
  pagination: {
    page: number;
    limit: number;
    total: number;
    hasNext: boolean;
    hasPrevious: boolean;
  };
  filters: ProductFilters;
  sort: ProductSortOptions;
}
 
export interface CreateProductData {
  name: string;
  description: string;
  price: number;
  currency: string;
  categoryId: string;
  brand: string;
  sku: string;
  stock: number;
  images: Omit<ProductImage, 'id'>[];
  tags?: string[];
  variants?: Omit<ProductVariant, 'id'>[];
}
 
export interface UpdateProductData extends Partial<CreateProductData> {
  isActive?: boolean;
}

Service Layer

// core/domains/products/services/productService.ts
import { ApiClient } from '@/core/shared/api/ApiClient';
import type {
  Product,
  ProductListResponse,
  ProductFilters,
  ProductSortOptions,
  CreateProductData,
  UpdateProductData,
  ProductCategory,
} from '../types';
 
export class ProductService {
  private apiClient: ApiClient;
 
  constructor() {
    this.apiClient = new ApiClient();
  }
 
  async getProducts(
    filters: ProductFilters = {},
    sort: ProductSortOptions = { field: 'createdAt', direction: 'desc' },
    page: number = 1,
    limit: number = 20
  ): Promise<ProductListResponse> {
    const params = new URLSearchParams({
      page: page.toString(),
      limit: limit.toString(),
      sortField: sort.field,
      sortDirection: sort.direction,
    });
 
    // Add filters to params
    if (filters.categoryId) {
      params.append('categoryId', filters.categoryId);
    }
    if (filters.brandIds?.length) {
      filters.brandIds.forEach(brandId => params.append('brandIds[]', brandId));
    }
    if (filters.priceRange) {
      params.append('minPrice', filters.priceRange.min.toString());
      params.append('maxPrice', filters.priceRange.max.toString());
    }
    if (filters.rating) {
      params.append('minRating', filters.rating.toString());
    }
    if (filters.tags?.length) {
      filters.tags.forEach(tag => params.append('tags[]', tag));
    }
    if (filters.inStock !== undefined) {
      params.append('inStock', filters.inStock.toString());
    }
    if (filters.search) {
      params.append('search', filters.search);
    }
 
    return this.apiClient.get(`/products?${params.toString()}`);
  }
 
  async getProduct(id: string): Promise<Product> {
    return this.apiClient.get(`/products/${id}`);
  }
 
  async createProduct(data: CreateProductData): Promise<Product> {
    return this.apiClient.post('/products', data);
  }
 
  async updateProduct(id: string, data: UpdateProductData): Promise<Product> {
    return this.apiClient.patch(`/products/${id}`, data);
  }
 
  async deleteProduct(id: string): Promise<void> {
    return this.apiClient.delete(`/products/${id}`);
  }
 
  async getCategories(): Promise<ProductCategory[]> {
    return this.apiClient.get('/products/categories');
  }
 
  async getFeaturedProducts(limit: number = 10): Promise<Product[]> {
    return this.apiClient.get(`/products/featured?limit=${limit}`);
  }
 
  async getRecommendedProducts(productId: string, limit: number = 10): Promise<Product[]> {
    return this.apiClient.get(`/products/${productId}/recommended?limit=${limit}`);
  }
 
  async searchProducts(query: string, limit: number = 20): Promise<Product[]> {
    const params = new URLSearchParams({
      q: query,
      limit: limit.toString(),
    });
    
    return this.apiClient.get(`/products/search?${params.toString()}`);
  }
 
  async getProductsByCategory(categoryId: string, limit: number = 20): Promise<Product[]> {
    return this.apiClient.get(`/products/categories/${categoryId}/products?limit=${limit}`);
  }
 
  async uploadProductImage(productId: string, imageFile: File): Promise<ProductImage> {
    const formData = new FormData();
    formData.append('image', imageFile);
    
    return this.apiClient.post(`/products/${productId}/images`, formData, {
      headers: {
        'Content-Type': 'multipart/form-data',
      },
    });
  }
 
  async deleteProductImage(productId: string, imageId: string): Promise<void> {
    return this.apiClient.delete(`/products/${productId}/images/${imageId}`);
  }
}

React Query Hooks

// core/domains/products/hooks/useProducts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ProductService } from '../services/productService';
import type {
  Product,
  ProductFilters,
  ProductSortOptions,
  CreateProductData,
  UpdateProductData,
} from '../types';
 
const productService = new ProductService();
 
// Query Keys
export const productKeys = {
  all: ['products'] as const,
  lists: () => [...productKeys.all, 'list'] as const,
  list: (filters: ProductFilters, sort: ProductSortOptions, page: number) =>
    [...productKeys.lists(), { filters, sort, page }] as const,
  details: () => [...productKeys.all, 'detail'] as const,
  detail: (id: string) => [...productKeys.details(), id] as const,
  categories: () => [...productKeys.all, 'categories'] as const,
  featured: () => [...productKeys.all, 'featured'] as const,
  recommended: (productId: string) => [...productKeys.all, 'recommended', productId] as const,
  search: (query: string) => [...productKeys.all, 'search', query] as const,
};
 
// Hooks
export function useProducts(
  filters: ProductFilters = {},
  sort: ProductSortOptions = { field: 'createdAt', direction: 'desc' },
  page: number = 1,
  options: { enabled?: boolean } = {}
) {
  return useQuery({
    queryKey: productKeys.list(filters, sort, page),
    queryFn: () => productService.getProducts(filters, sort, page),
    enabled: options.enabled,
    staleTime: 5 * 60 * 1000, // 5 minutes
    cacheTime: 10 * 60 * 1000, // 10 minutes
  });
}
 
export function useProduct(id: string, options: { enabled?: boolean } = {}) {
  return useQuery({
    queryKey: productKeys.detail(id),
    queryFn: () => productService.getProduct(id),
    enabled: options.enabled && !!id,
    staleTime: 5 * 60 * 1000,
    cacheTime: 30 * 60 * 1000, // Cache longer for individual products
  });
}
 
export function useProductCategories() {
  return useQuery({
    queryKey: productKeys.categories(),
    queryFn: () => productService.getCategories(),
    staleTime: 30 * 60 * 1000, // Categories change less frequently
    cacheTime: 60 * 60 * 1000,
  });
}
 
export function useFeaturedProducts() {
  return useQuery({
    queryKey: productKeys.featured(),
    queryFn: () => productService.getFeaturedProducts(),
    staleTime: 10 * 60 * 1000,
    cacheTime: 30 * 60 * 1000,
  });
}
 
export function useRecommendedProducts(productId: string) {
  return useQuery({
    queryKey: productKeys.recommended(productId),
    queryFn: () => productService.getRecommendedProducts(productId),
    enabled: !!productId,
    staleTime: 15 * 60 * 1000,
    cacheTime: 30 * 60 * 1000,
  });
}
 
export function useProductSearch(query: string, options: { enabled?: boolean } = {}) {
  return useQuery({
    queryKey: productKeys.search(query),
    queryFn: () => productService.searchProducts(query),
    enabled: options.enabled && query.length > 2,
    staleTime: 2 * 60 * 1000, // Search results change more frequently
    cacheTime: 5 * 60 * 1000,
  });
}
 
// Mutations
export function useCreateProduct() {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: (data: CreateProductData) => productService.createProduct(data),
    onSuccess: () => {
      // Invalidate all product lists
      queryClient.invalidateQueries({ queryKey: productKeys.lists() });
      queryClient.invalidateQueries({ queryKey: productKeys.categories() });
    },
  });
}
 
export function useUpdateProduct() {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: ({ id, data }: { id: string; data: UpdateProductData }) =>
      productService.updateProduct(id, data),
    onSuccess: (updatedProduct) => {
      // Update the specific product in cache
      queryClient.setQueryData(
        productKeys.detail(updatedProduct.id),
        updatedProduct
      );
      
      // Invalidate product lists to reflect changes
      queryClient.invalidateQueries({ queryKey: productKeys.lists() });
    },
  });
}
 
export function useDeleteProduct() {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: (id: string) => productService.deleteProduct(id),
    onSuccess: (_, deletedId) => {
      // Remove from cache
      queryClient.removeQueries({ queryKey: productKeys.detail(deletedId) });
      
      // Invalidate lists
      queryClient.invalidateQueries({ queryKey: productKeys.lists() });
    },
  });
}

Custom Hooks for Common Patterns

// core/domains/products/hooks/useProductFilters.ts
import { useState, useCallback, useMemo } from 'react';
import type { ProductFilters, ProductSortOptions } from '../types';
 
export function useProductFilters(initialFilters: ProductFilters = {}) {
  const [filters, setFilters] = useState<ProductFilters>(initialFilters);
  const [sort, setSort] = useState<ProductSortOptions>({
    field: 'createdAt',
    direction: 'desc',
  });
 
  const updateFilter = useCallback(<K extends keyof ProductFilters>(
    key: K,
    value: ProductFilters[K]
  ) => {
    setFilters(prev => ({
      ...prev,
      [key]: value,
    }));
  }, []);
 
  const removeFilter = useCallback((key: keyof ProductFilters) => {
    setFilters(prev => {
      const newFilters = { ...prev };
      delete newFilters[key];
      return newFilters;
    });
  }, []);
 
  const clearFilters = useCallback(() => {
    setFilters({});
  }, []);
 
  const updateSort = useCallback((newSort: ProductSortOptions) => {
    setSort(newSort);
  }, []);
 
  const activeFilterCount = useMemo(() => {
    return Object.keys(filters).length;
  }, [filters]);
 
  const hasActiveFilters = useMemo(() => {
    return activeFilterCount > 0;
  }, [activeFilterCount]);
 
  return {
    filters,
    sort,
    updateFilter,
    removeFilter,
    clearFilters,
    updateSort,
    activeFilterCount,
    hasActiveFilters,
  };
}
// core/domains/products/hooks/useProductCart.ts
import { useState, useCallback } from 'react';
import type { Product, ProductVariant } from '../types';
 
export interface CartItem {
  product: Product;
  variant?: ProductVariant;
  quantity: number;
  addedAt: string;
}
 
export function useProductCart() {
  const [cartItems, setCartItems] = useState<CartItem[]>([]);
 
  const addToCart = useCallback((
    product: Product,
    quantity: number = 1,
    variant?: ProductVariant
  ) => {
    setCartItems(prev => {
      const existingIndex = prev.findIndex(item =>
        item.product.id === product.id &&
        item.variant?.id === variant?.id
      );
 
      if (existingIndex >= 0) {
        // Update existing item
        const newItems = [...prev];
        newItems[existingIndex] = {
          ...newItems[existingIndex],
          quantity: newItems[existingIndex].quantity + quantity,
        };
        return newItems;
      } else {
        // Add new item
        return [...prev, {
          product,
          variant,
          quantity,
          addedAt: new Date().toISOString(),
        }];
      }
    });
  }, []);
 
  const removeFromCart = useCallback((productId: string, variantId?: string) => {
    setCartItems(prev =>
      prev.filter(item =>
        !(item.product.id === productId && item.variant?.id === variantId)
      )
    );
  }, []);
 
  const updateQuantity = useCallback((
    productId: string,
    quantity: number,
    variantId?: string
  ) => {
    if (quantity <= 0) {
      removeFromCart(productId, variantId);
      return;
    }
 
    setCartItems(prev =>
      prev.map(item =>
        item.product.id === productId && item.variant?.id === variantId
          ? { ...item, quantity }
          : item
      )
    );
  }, [removeFromCart]);
 
  const clearCart = useCallback(() => {
    setCartItems([]);
  }, []);
 
  const getCartTotal = useCallback(() => {
    return cartItems.reduce((total, item) => {
      const price = item.variant?.price || item.product.price;
      return total + (price * item.quantity);
    }, 0);
  }, [cartItems]);
 
  const getCartItemCount = useCallback(() => {
    return cartItems.reduce((count, item) => count + item.quantity, 0);
  }, [cartItems]);
 
  return {
    cartItems,
    addToCart,
    removeFromCart,
    updateQuantity,
    clearCart,
    getCartTotal,
    getCartItemCount,
  };
}

Usage Examples

Product List Component

// features/product-catalog/components/ProductList.tsx
import React from 'react';
import { FlatList, View, Text } from 'react-native';
import { useProducts } from '@/core/domains/products/hooks/useProducts';
import { ProductCard } from './ProductCard';
import { LoadingSpinner } from '@/ui/foundation/LoadingSpinner';
import type { Product, ProductFilters, ProductSortOptions } from '@/core/domains/products/types';
 
interface ProductListProps {
  filters?: ProductFilters;
  sort?: ProductSortOptions;
  onProductPress?: (product: Product) => void;
}
 
export function ProductList({ 
  filters = {}, 
  sort = { field: 'createdAt', direction: 'desc' },
  onProductPress 
}: ProductListProps) {
  const { data, isLoading, error, fetchNextPage, hasNextPage } = useProducts(filters, sort);
 
  if (isLoading && !data) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <LoadingSpinner />
        <Text style={{ marginTop: 16 }}>Loading products...</Text>
      </View>
    );
  }
 
  if (error) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <Text>Error loading products: {error.message}</Text>
      </View>
    );
  }
 
  const products = data?.products || [];
 
  return (
    <FlatList
      data={products}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => (
        <ProductCard
          product={item}
          onPress={() => onProductPress?.(item)}
        />
      )}
      onEndReached={() => {
        if (hasNextPage) {
          fetchNextPage();
        }
      }}
      onEndReachedThreshold={0.1}
      ListEmptyComponent={
        <View style={{ padding: 20, alignItems: 'center' }}>
          <Text>No products found</Text>
        </View>
      }
      contentContainerStyle={{ padding: 16 }}
    />
  );
}

Product Detail Component

// features/product-catalog/components/ProductDetail.tsx
import React from 'react';
import { ScrollView, View, Text, Image } from 'react-native';
import { useProduct } from '@/core/domains/products/hooks/useProducts';
import { useProductCart } from '@/core/domains/products/hooks/useProductCart';
import { Button } from '@/ui/foundation/Button';
import { LoadingSpinner } from '@/ui/foundation/LoadingSpinner';
 
interface ProductDetailProps {
  productId: string;
}
 
export function ProductDetail({ productId }: ProductDetailProps) {
  const { data: product, isLoading, error } = useProduct(productId);
  const { addToCart } = useProductCart();
 
  if (isLoading) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <LoadingSpinner />
      </View>
    );
  }
 
  if (error || !product) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <Text>Product not found</Text>
      </View>
    );
  }
 
  const primaryImage = product.images.find(img => img.isPrimary) || product.images[0];
 
  return (
    <ScrollView style={{ flex: 1 }}>
      {primaryImage && (
        <Image
          source={{ uri: primaryImage.url }}
          style={{ width: '100%', height: 300 }}
          resizeMode="cover"
        />
      )}
      
      <View style={{ padding: 16 }}>
        <Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 8 }}>
          {product.name}
        </Text>
        
        <Text style={{ fontSize: 20, color: '#007AFF', marginBottom: 16 }}>
          {product.currency} {product.price.toFixed(2)}
        </Text>
        
        <Text style={{ fontSize: 16, lineHeight: 24, marginBottom: 16 }}>
          {product.description}
        </Text>
        
        <View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 16 }}>
          <Text>Rating: {product.rating}/5</Text>
          <Text style={{ marginLeft: 16 }}>
            ({product.reviewCount} reviews)
          </Text>
        </View>
        
        <Text style={{ marginBottom: 16 }}>
          Stock: {product.stock > 0 ? `${product.stock} available` : 'Out of stock'}
        </Text>
        
        <Button
          onPress={() => addToCart(product)}
          disabled={product.stock === 0}
          style={{ marginTop: 16 }}
        >
          {product.stock > 0 ? 'Add to Cart' : 'Out of Stock'}
        </Button>
      </View>
    </ScrollView>
  );
}

Testing Patterns

// core/domains/products/__tests__/useProducts.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useProducts } from '../hooks/useProducts';
import { ProductService } from '../services/productService';
 
// Mock the service
jest.mock('../services/productService');
const mockProductService = ProductService as jest.MockedClass<typeof ProductService>;
 
const createWrapper = () => {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  });
  
  return ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
};
 
describe('useProducts', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });
 
  it('fetches products successfully', async () => {
    const mockResponse = {
      products: [
        { id: '1', name: 'Test Product', price: 29.99 },
      ],
      pagination: { page: 1, limit: 20, total: 1, hasNext: false, hasPrevious: false },
    };
    
    mockProductService.prototype.getProducts.mockResolvedValue(mockResponse);
 
    const { result } = renderHook(() => useProducts(), {
      wrapper: createWrapper(),
    });
 
    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });
 
    expect(result.current.data).toEqual(mockResponse);
    expect(mockProductService.prototype.getProducts).toHaveBeenCalledWith(
      {},
      { field: 'createdAt', direction: 'desc' },
      1
    );
  });
 
  it('applies filters correctly', async () => {
    const filters = { categoryId: 'cat-1', search: 'test' };
    const sort = { field: 'price', direction: 'asc' } as const;
    
    mockProductService.prototype.getProducts.mockResolvedValue({
      products: [],
      pagination: { page: 1, limit: 20, total: 0, hasNext: false, hasPrevious: false },
    });
 
    renderHook(() => useProducts(filters, sort), {
      wrapper: createWrapper(),
    });
 
    await waitFor(() => {
      expect(mockProductService.prototype.getProducts).toHaveBeenCalledWith(
        filters,
        sort,
        1
      );
    });
  });
});

Best Practices

Performance

  • ✅ Use React Query for caching and background updates
  • ✅ Implement pagination for large product lists
  • ✅ Cache product images and optimize sizes
  • ✅ Use proper stale times based on data volatility

Type Safety

  • ✅ Define comprehensive TypeScript interfaces
  • ✅ Use discriminated unions for variants
  • ✅ Type-safe filter and sort operations
  • ✅ Proper error type definitions

Data Management

  • ✅ Normalize product data structure
  • ✅ Implement optimistic updates for mutations
  • ✅ Handle offline scenarios gracefully
  • ✅ Cache frequently accessed data

Ready to use? Copy these patterns into your src/core/domains/products/ directory and customize the data models and API endpoints for your specific product catalog needs.

On this page