Data Fetching

Learn how to fetch data from APIs using the custom hooks built into the boilerplate.

Overview

The boilerplate provides two custom hooks that wrap React Query and integrate with the HTTP client:

  • useApiQuery - For GET requests (fetching data)
  • useApiMutation - For POST, PUT, PATCH, DELETE requests (modifying data)
These hooks automatically use the configured httpClient with authentication, error handling, and interceptors built-in.

useApiQuery

Custom hook that combines React Query with the httpClient from the boilerplate. Perfect for fetching data from APIs.

Basic Usage

typescript
import { useApiQuery } from '@/hooks/useApi';

export function useUserListViewModel() {
  const { data, isLoading, error, refetch } = useApiQuery<User[]>(
    ['users'],           // Query key for caching
    '/api/users'         // Endpoint
  );

  return {
    users: data?.data ?? [],  // Access response data
    isLoading,
    error,
    refetch,
  };
}

With React Query Options

typescript
const { data, isLoading } = useApiQuery<Post[]>(
  ['posts', 'featured'],
  '/api/posts/featured',
  {
    staleTime: 5 * 60 * 1000,  // 5 minutes
    gcTime: 10 * 60 * 1000,    // 10 minutes
    enabled: true,              // Run immediately
    refetchOnWindowFocus: false,
  }
);

Response Structure

typescript
const { data } = useApiQuery<User[]>(['users'], '/api/users');

// data contains:
{
  data: User[],           // Response body
  status: 200,            // HTTP status code
  headers: { ... }        // Response headers
}

useApiMutation

Custom hook for mutations (POST, PUT, PATCH, DELETE) that modify data on the server.

POST Request

typescript
import { useApiMutation } from '@/hooks/useApi';
import { useQueryClient } from '@tanstack/react-query';

export function useCreateUserViewModel() {
  const queryClient = useQueryClient();
  
  const createMutation = useApiMutation<User, CreateUserInput>(
    'post',
    '/api/users',
    {
      onSuccess: () => {
        // Invalidate and refetch users list
        queryClient.invalidateQueries({ queryKey: ['users'] });
      },
      onError: (error) => {
        console.error('Failed to create user:', error.message);
      },
    }
  );

  const handleCreateUser = (userData: CreateUserInput) => {
    createMutation.mutate(userData);
  };

  return {
    handleCreateUser,
    isCreating: createMutation.isPending,
    error: createMutation.error,
  };
}

PUT Request with Dynamic URL

typescript
const updateMutation = useApiMutation<User, UpdateUserInput>(
  'put',
  (variables) => `/api/users/${variables.id}`,  // Dynamic URL
  {
    onSuccess: (response) => {
      console.log('User updated:', response.data);
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  }
);

// Usage
updateMutation.mutate({ 
  id: '123', 
  name: 'John Updated',
  email: 'john@example.com'
});

DELETE Request

typescript
const deleteMutation = useApiMutation<void, string>(
  'delete',
  (id) => `/api/users/${id}`,
  {
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  }
);

// Usage
const handleDelete = (userId: string) => {
  deleteMutation.mutate(userId);
};

PATCH Request

typescript
const patchMutation = useApiMutation<User, Partial<User>>(
  'patch',
  (variables) => `/api/users/${variables.id}`,
  {
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  }
);

// Usage - update only specific fields
patchMutation.mutate({ 
  id: '123', 
  status: 'active' 
});

Complete Example

Here's a complete ViewModel with both query and mutation:

src/presentation/viewmodels/useTaskViewModel.ts
import { useApiQuery, useApiMutation } from '@/hooks/useApi';
import { useQueryClient } from '@tanstack/react-query';
import type { Task, CreateTaskInput, UpdateTaskInput } from '@/domain/entities/task';

export function useTaskViewModel() {
  const queryClient = useQueryClient();

  // Fetch tasks
  const { data, isLoading, error } = useApiQuery<Task[]>(
    ['tasks'],
    '/api/tasks'
  );

  // Create task
  const createMutation = useApiMutation<Task, CreateTaskInput>(
    'post',
    '/api/tasks',
    {
      onSuccess: () => {
        queryClient.invalidateQueries({ queryKey: ['tasks'] });
      },
    }
  );

  // Update task
  const updateMutation = useApiMutation<Task, UpdateTaskInput>(
    'put',
    (variables) => `/api/tasks/${variables.id}`,
    {
      onSuccess: () => {
        queryClient.invalidateQueries({ queryKey: ['tasks'] });
      },
    }
  );

  // Delete task
  const deleteMutation = useApiMutation<void, string>(
    'delete',
    (id) => `/api/tasks/${id}`,
    {
      onSuccess: () => {
        queryClient.invalidateQueries({ queryKey: ['tasks'] });
      },
    }
  );

  return {
    // Data
    tasks: data?.data ?? [],
    isLoading,
    error,

    // Actions
    createTask: (task: CreateTaskInput) => createMutation.mutate(task),
    updateTask: (task: UpdateTaskInput) => updateMutation.mutate(task),
    deleteTask: (id: string) => deleteMutation.mutate(id),

    // Status
    isCreating: createMutation.isPending,
    isUpdating: updateMutation.isPending,
    isDeleting: deleteMutation.isPending,
  };
}

Using in a View

src/presentation/screens/TaskScreen.tsx
import { View, Text, FlatList, Pressable, ActivityIndicator } from 'react-native';
import { useTaskViewModel } from '../viewmodels/useTaskViewModel';

export default function TaskScreen() {
  const { 
    tasks, 
    isLoading, 
    error,
    deleteTask,
    isDeleting 
  } = useTaskViewModel();

  if (isLoading) {
    return (
      <View className="flex-1 justify-center items-center">
        <ActivityIndicator size="large" />
      </View>
    );
  }

  if (error) {
    return (
      <View className="flex-1 justify-center items-center">
        <Text className="text-red-500">Error: {error.message}</Text>
      </View>
    );
  }

  return (
    <FlatList
      data={tasks}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => (
        <View className="bg-white dark:bg-slate-800 p-4 mb-2 rounded-lg">
          <Text className="text-lg font-semibold">{item.title}</Text>
          <Text className="text-slate-600 dark:text-slate-400">
            {item.description}
          </Text>
          <Pressable
            onPress={() => deleteTask(item.id)}
            disabled={isDeleting}
            className="mt-2"
          >
            <Text className="text-red-500">
              {isDeleting ? 'Deleting...' : 'Delete'}
            </Text>
          </Pressable>
        </View>
      )}
    />
  );
}

Type Safety

Both hooks are fully typed with TypeScript generics:

typescript
// useApiQuery<ResponseType>
const { data } = useApiQuery<User[]>(
  ['users'], 
  '/api/users'
);
// data.data is typed as User[]

// useApiMutation<ResponseType, VariablesType>
const mutation = useApiMutation<User, CreateUserInput>(
  'post',
  '/api/users'
);
// mutation.mutate expects CreateUserInput
// response.data is typed as User

Error Handling

typescript
const { error } = useApiQuery<User[]>(['users'], '/api/users');

if (error) {
  // Error is normalized by httpClient
  const err = error as Error & { status?: number };
  
  if (err.status === 404) {
    return <Text>Users not found</Text>;
  }
  
  if (err.status === 401) {
    // Redirect to login
    router.push('/login');
  }
  
  return <Text>Error: {err.message}</Text>;
}
The httpClient automatically handles authentication tokens, retries, and error normalization!

Next Steps