Data Fetching
The @/services directory implements a service layer pattern that encapsulates all API calls and external integrations.
This architecture provides a clean separation between business logic and UI components, ensuring type safety, automatic caching,
and consistent error handling.
Core Principles
- Separation of Concerns: All API calls are isolated from components
- Type Safety: Zod schemas validate data at runtime and provide TypeScript types
- Automatic Caching: TanStack Query handles caching, refetching, and synchronization
- Consistent Error Handling: Standardized error messages via toast notifications
- Domain Organization: Services are organized by domain (e.g.,
user,product, etc.)
Architecture Pattern
services/
├── index.ts # Main export point
└── {domain}/ # Service domain (e.g., user, product)
├── index.ts # Domain exports
├── use-{action}.ts # Query hooks (e.g., use-user.ts)
├── use-{action}.ts # Mutation hooks (e.g., use-update.ts)
└── {domain}.schema.ts # Zod schemas and TypeScript typesTechnology Stack
- TanStack Query (React Query): Server state management, caching, and synchronization
- Supabase: Backend client for database operations
- Zod: Runtime validation and TypeScript type inference
- Toast Notifications: User feedback for success/error states
Usage Examples
Fetching Data
import { useUser } from '@/services';
import { useAuth } from '@/hooks';
function ProfileScreen() {
const { user } = useAuth();
const { useFetchOneQuery } = useUser();
const { data: userProfile, isLoading, error } = useFetchOneQuery(user?.id);
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return <ProfileView user={userProfile} />;
}Updating Data
import { useUpdateUser } from '@/services';
import { useAuth } from '@/hooks';
function SettingsScreen() {
const { user } = useAuth();
const { updateUser, isLoading } = useUpdateUser();
const handleSave = (settings: User['settings']) => {
updateUser({
userId: user?.id,
settings,
});
};
return <SettingsForm onSave={handleSave} isLoading={isLoading} />;
}Async Updates
import { useUpdateUser } from '@/services';
function AdvancedSettingsScreen() {
const { updateUserAsync, isLoading } = useUpdateUser();
const handleSave = async (settings: User['settings']) => {
try {
const updatedUser = await updateUserAsync({
userId: user?.id,
settings,
});
// Handle success with updated data
console.log('Updated:', updatedUser);
} catch (error) {
// Error toast is already shown by the hook
console.error('Update failed:', error);
}
};
return <SettingsForm onSave={handleSave} isLoading={isLoading} />;
}When to Use Each
- Use updateUser when: you just need to trigger the update and don’t need the result.
- Use updateUserAsync when: you need the returned data, want to chain operations, or need custom error handling beyond the toast.
Best Practices
1. Query Key Management
Always use enums for query keys to ensure consistency:
const enum UserQueryKeys {
fetchOne = 'fetchOneUser',
fetchAll = 'fetchAllUsers',
search = 'searchUsers',
}2. Conditional Fetching
Use the enabled option to prevent unnecessary requests:
useQuery({
queryKey: [UserQueryKeys.fetchOne, userId],
queryFn: () => fetchUser(userId),
enabled: !!userId, // Only fetch if userId exists
});3. Error Handling
Let TanStack Query handle errors, but provide user-friendly messages:
onError: (error) => {
showErrorToast({
title: 'Operation failed',
subtitle: error.message || 'An unexpected error occurred',
});
};4. Cache Invalidation
Invalidate related queries after mutations:
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
queryKey: [UserQueryKeys.fetchOne, variables.userId],
});
};5. Type Safety
Always define Zod schemas and use inferred types:
export const userSchema = z.object({
id: z.string(),
// ... other fields
});
export type User = z.infer<typeof userSchema>;Creating a New Service Domain
To add a new service domain (e.g., product):
-
Create the domain directory:
services/product/ -
Define the schema (
product.schema.ts):import { z } from 'zod'; export const productSchema = z.object({ id: z.string(), name: z.string(), price: z.number(), }); export type Product = z.infer<typeof productSchema>; -
Create query hooks (
use-product.ts):import { useQuery } from '@tanstack/react-query'; import { Product } from './product.schema'; import { supabase } from '@/lib/supabase'; const enum ProductQueryKeys { fetchOne = 'fetchOneProduct', } export const useProduct = () => { const useFetchOneQuery = (productId: string) => useQuery({ queryKey: [ProductQueryKeys.fetchOne, productId], queryFn: async () => { const { data, error } = await supabase .from('Product') .select('*') .eq('id', productId) .single(); if (error) throw error; return data; }, }); return { useFetchOneQuery }; }; -
Create mutation hooks (
use-create-product.tsoruse-update-product.ts):import { useMutation, useQueryClient } from '@tanstack/react-query'; import { showSuccessToast, showErrorToast } from '@/lib/toast'; export const useCreateProduct = () => { const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: async (productData: CreateProductInput) => { // Implementation }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: [ProductQueryKeys.fetchAll], }); showSuccessToast({ title: 'Product created' }); }, onError: (error) => { showErrorToast({ title: 'Creation failed', subtitle: error.message }); }, }); return { createProduct: mutation.mutate, createProductAsync: mutation.mutateAsync, isLoading: mutation.isPending, }; }; -
Export from domain index (
product/index.ts):export * from './use-product'; export * from './use-create-product'; export * from './product.schema'; -
Export from main index (
services/index.ts):export * from './user'; export * from './product'; // Add new domain
Integration with Other Layers
Components
Components should never make direct API calls. Always use services:
// ❌ Bad: Direct API call
const data = await supabase.from('UserProfile').select('*');
// ✅ Good: Use service hook
const { useFetchOneQuery } = useUser();
const { data } = useFetchOneQuery(userId);Hooks
Custom hooks can compose service hooks:
import { useUser } from '@/services';
import { useAuth } from '@/hooks';
export function useCurrentUserProfile() {
const { user } = useAuth();
const { useFetchOneQuery } = useUser();
return useFetchOneQuery(user?.id);
}State Management
Services work alongside Zustand for global state:
- Services: Server state (data from API)
- Zustand: Client state (UI state, preferences)
Error Handling Strategy
- Service Layer: Throws errors for TanStack Query to handle
- Mutation Hooks: Show user-friendly toast messages
- Components: Can access error state from query/mutation hooks for custom handling
const { data, error, isLoading } = useFetchOneQuery(userId);
if (error) {
// Custom error handling in component
return <ErrorBoundary error={error} />;
}Performance Considerations
- Automatic Caching: TanStack Query caches responses automatically
- Background Refetching: Data is refetched when window regains focus
- Stale Time: Configure
staleTimefor data that doesn’t change frequently - Query Deduplication: Multiple components using the same query share the same request