Skip to main content

Overview

DPM Delivery Mobile uses TanStack Query (React Query) for server state management. This provides automatic caching, background refetching, and optimistic updates.

Setup

The QueryClient is configured in the Providers component:
// src/components/providers/index.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 2,
    },
  },
});

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <GestureHandlerRootView style={{ flex: 1 }}>
        <KeyboardProvider>
          <HeroUINativeProvider config={config}>
            {children}
          </HeroUINativeProvider>
        </KeyboardProvider>
      </GestureHandlerRootView>
    </QueryClientProvider>
  );
}
Default Configuration:
  • retry: 2 attempts on failure
  • Queries are cached and refetched in background
  • Stale time and cache time use defaults

Query Keys

Centralized query keys ensure consistency and avoid cache collisions:
// src/lib/tanstack-query/query-keys.ts
export const queryKeys = {
  users: {
    wallet: () => ["users", "wallet"],
    riders: {
      stats: (riderId: string) => ["users", "riders", riderId, "stats"],
    },
  },
  shipments: {
    getRiderLatestOrders: (riderId: string) => [
      "shipments",
      "getRiderLatestOrders",
      riderId,
    ],
    getByReference: (reference: string) => [
      "shipments",
      "getByReference",
      reference,
    ],
  },
};
Key Structure:
  • Hierarchical organization by domain
  • Parameters included for cache invalidation
  • Consistent naming convention

Query Options

Query options are defined in separate files for reusability:

Shipment Queries

// src/lib/tanstack-query/query-options/shipment.ts
import { api } from "@/services/api";
import { queryKeys } from "../query-keys";

export const getShipmentsQueryOptions = (riderId: string) => ({
  queryKey: queryKeys.shipments.getRiderLatestOrders(riderId),
  queryFn: () => api.shipments.getRiderLatestOrders(riderId),
});

export const getShipmentByReferenceQueryOptions = (reference: string) => ({
  queryKey: queryKeys.shipments.getByReference(reference),
  queryFn: () => api.shipments.getByReference(reference),
});

User Queries

// src/lib/tanstack-query/query-options/users.ts
import { api } from "@/services/api";
import { queryKeys } from "../query-keys";

export const getUserWalletQueryOptions = () => ({
  queryKey: queryKeys.users.wallet(),
  queryFn: api.users.getWallet,
});

export const getRiderAccountStatQueryOptions = (riderId: string) => ({
  queryKey: queryKeys.users.riders.stats(riderId),
  queryFn: () => api.users.riders.stats(riderId),
});

Using Queries in Components

Basic Query

import { useQuery } from "@tanstack/react-query";
import { getUserWalletQueryOptions } from "@/lib/tanstack-query/query-options/users";

export function WalletComponent() {
  const { data, isLoading, error } = useQuery(getUserWalletQueryOptions());

  if (isLoading) return <Text>Loading...</Text>;
  if (error) return <Text>Error: {error.message}</Text>;

  return <Text>Balance: {data.balance}</Text>;
}

Query with Parameters

import { useQuery } from "@tanstack/react-query";
import { getShipmentByReferenceQueryOptions } from "@/lib/tanstack-query/query-options/shipment";
import { useLocalSearchParams } from "expo-router";

export default function ShipmentDetailsPage() {
  const { reference } = useLocalSearchParams<{ reference: string }>();

  const { data: shipment, isLoading, error } = useQuery(
    getShipmentByReferenceQueryOptions(reference)
  );

  if (isLoading) return <ActivityIndicator />;
  if (error) return <ErrorView message={error.message} />;

  return <ShipmentDetails shipment={shipment} />;
}

Dependent Queries

const { data: user } = useQuery(getUserQueryOptions());

const { data: wallet } = useQuery({
  ...getUserWalletQueryOptions(),
  enabled: !!user, // Only run when user is available
});

Mutations

Mutations handle data updates with automatic cache invalidation:
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/services/api";
import { queryKeys } from "@/lib/tanstack-query/query-keys";

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

  return useMutation({
    mutationFn: (data: { reference: string; status: string }) =>
      api.shipments.updateStatus(data.reference, data.status),
    onSuccess: (_, variables) => {
      // Invalidate related queries
      queryClient.invalidateQueries({
        queryKey: queryKeys.shipments.getByReference(variables.reference),
      });
      queryClient.invalidateQueries({
        queryKey: queryKeys.shipments.getRiderLatestOrders,
      });
    },
  });
}

Using Mutations in Components

import { useUpdateShipmentStatus } from "@/hooks/api/use-update-shipment-status";

export function UpdateStatusForm({ reference }: { reference: string }) {
  const updateStatus = useUpdateShipmentStatus();

  const handleSubmit = (status: string) => {
    updateStatus.mutate(
      { reference, status },
      {
        onSuccess: () => {
          Toast.show({ type: "success", text1: "Status updated" });
        },
        onError: (error) => {
          Toast.show({ type: "error", text1: error.message });
        },
      }
    );
  };

  return (
    <Button
      onPress={() => handleSubmit("delivered")}
      isLoading={updateStatus.isPending}
    >
      Mark as Delivered
    </Button>
  );
}

Custom API Hooks

Complex API interactions can be wrapped in custom hooks:
// src/hooks/api/use-verify-phone-number.ts
import { api } from "@/services/api";
import { useCallback, useState } from "react";

export function useVerifyPhoneNumber() {
  const [isVerifying, setIsVerifying] = useState(false);
  const [verificationError, setVerificationError] = useState<string | null>(
    null
  );
  const [verifiedAccountName, setVerifiedAccountName] = useState<string | null>(
    null
  );

  const handleVerifyMobileNumber = useCallback(
    async (provider: string, number: string) => {
      if (!provider || !number) {
        setVerificationError("Please provide both provider and phone number");
        return;
      }

      setIsVerifying(true);
      setVerificationError(null);
      setVerifiedAccountName(null);
      try {
        const result = await api.payment.verifyMobileMoneyNumber(
          number,
          provider
        );

        if (result.data?.accountName) {
          setVerifiedAccountName(result.data.accountName);
          setVerificationError(null);
        }
      } catch (err) {
        setVerificationError(
          err instanceof Error ? err.message : "Failed to verify account"
        );
        setVerifiedAccountName(null);
      } finally {
        setIsVerifying(false);
      }
    },
    []
  );

  return {
    isVerifying,
    verificationError,
    verifiedAccountName,
    handleVerifyMobileNumber,
  };
}

Caching Strategies

Automatic Background Refetch

Queries automatically refetch when:
  • Component mounts (if stale)
  • Window regains focus
  • Network reconnects

Manual Invalidation

const queryClient = useQueryClient();

// Invalidate specific query
queryClient.invalidateQueries({
  queryKey: queryKeys.shipments.getByReference(reference),
});

// Invalidate all shipment queries
queryClient.invalidateQueries({
  queryKey: queryKeys.shipments,
});

Optimistic Updates

const updateShipment = useMutation({
  mutationFn: api.shipments.update,
  onMutate: async (newData) => {
    // Cancel outgoing refetches
    await queryClient.cancelQueries({
      queryKey: queryKeys.shipments.getByReference(newData.reference),
    });

    // Snapshot previous value
    const previousShipment = queryClient.getQueryData(
      queryKeys.shipments.getByReference(newData.reference)
    );

    // Optimistically update cache
    queryClient.setQueryData(
      queryKeys.shipments.getByReference(newData.reference),
      newData
    );

    return { previousShipment };
  },
  onError: (err, newData, context) => {
    // Rollback on error
    if (context?.previousShipment) {
      queryClient.setQueryData(
        queryKeys.shipments.getByReference(newData.reference),
        context.previousShipment
      );
    }
  },
  onSettled: (data, error, variables) => {
    // Always refetch after error or success
    queryClient.invalidateQueries({
      queryKey: queryKeys.shipments.getByReference(variables.reference),
    });
  },
});

Prefetching

const queryClient = useQueryClient();

// Prefetch before navigation
const handleNavigateToDetails = (reference: string) => {
  queryClient.prefetchQuery(getShipmentByReferenceQueryOptions(reference));
  router.push(`/shipments/${reference}`);
};

Query Configuration Options

useQuery({
  ...getShipmentsQueryOptions(riderId),
  staleTime: 5 * 60 * 1000,      // 5 minutes
  cacheTime: 10 * 60 * 1000,     // 10 minutes
  refetchOnMount: true,
  refetchOnWindowFocus: true,
  refetchOnReconnect: true,
  retry: 2,
  retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
  enabled: !!riderId,             // Conditional fetching
});

Best Practices

  1. Centralize query keys in query-keys.ts to avoid typos and maintain consistency
  2. Create query options for reusable queries
  3. Use mutations for data updates with automatic invalidation
  4. Implement optimistic updates for better UX on slow connections
  5. Handle loading and error states explicitly in components
  6. Prefetch data before navigation for instant loading
  7. Use enabled option for dependent queries
  8. Invalidate related queries after mutations

Build docs developers (and LLMs) love