diff --git a/src/App.tsx b/src/App.tsx
index c91849de559..b4d1a1570a9 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,4 +1,8 @@
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ QueryCache,
+ QueryClient,
+ QueryClientProvider,
+} from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { Suspense } from "react";
@@ -12,17 +16,21 @@ import AuthUserProvider from "@/Providers/AuthUserProvider";
import HistoryAPIProvider from "@/Providers/HistoryAPIProvider";
import Routers from "@/Routers";
import { FeatureFlagsProvider } from "@/Utils/featureFlags";
+import { handleQueryError } from "@/Utils/request/errorHandler";
import { PubSubProvider } from "./Utils/pubsubContext";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
- retry: 3,
+ retry: 2,
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000, // 5 minutes
},
},
+ queryCache: new QueryCache({
+ onError: handleQueryError,
+ }),
});
const App = () => {
diff --git a/src/Utils/request/README.md b/src/Utils/request/README.md
index 0780d3149f6..3c1279e1554 100644
--- a/src/Utils/request/README.md
+++ b/src/Utils/request/README.md
@@ -4,41 +4,108 @@ CARE now uses TanStack Query (formerly React Query) as its data fetching solutio
## Using TanStack Query (Recommended for new code)
-For new API integrations, we recommend using TanStack Query directly:
+For new API integrations, we recommend using TanStack Query with `query` utility function. This is a wrapper around `fetch` that works seamlessly with TanStack Query. It handles response parsing, error handling, setting headers, and more.
```tsx
import { useQuery } from "@tanstack/react-query";
-import request from "@/Utils/request/request";
-import FooRoutes from "@foo/routes";
+import query from "@/Utils/request/query";
-export default function FooDetails({ id }) {
- const { data, isLoading, error } = useQuery({
- queryKey: [FooRoutes.getFoo.path, id],
- queryFn: async () => {
- const response = await request(FooRoutes.getFoo, {
- pathParams: { id }
- });
- return response.data;
- }
+export default function UserProfile() {
+ const { data, isLoading } = useQuery({
+ queryKey: [routes.users.current.path],
+ queryFn: query(routes.users.current)
});
if (isLoading) return ;
- if (error) return ;
+ return
{data?.name}
;
+}
- return (
-
- {data.id}
- {data.name}
-
- );
+// With path parameters
+function PatientDetails({ id }: { id: string }) {
+ const { data } = useQuery({
+ queryKey: ['patient', id],
+ queryFn: query(routes.patient.get, {
+ pathParams: { id }
+ })
+ });
+
+ return {data?.name}
;
+}
+
+// With query parameters
+function SearchMedicines() {
+ const { data } = useQuery({
+ queryKey: ['medicines', 'paracetamol'],
+ queryFn: query(routes.medicine.search, {
+ queryParams: { search: 'paracetamol' }
+ })
+ });
+
+ return ;
+}
+
+// When you need response status/error handling
+function FacilityDetails({ id }: { id: string }) {
+ const { data, isLoading } = useQuery({
+ queryKey: ["facility", id],
+ queryFn: query(routes.getFacility, {
+ pathParams: { id },
+ silent: true
+ })
+ });
+
+ if (isLoading) return ;
+ return {data?.name}
;
+}
+
+### query
+
+`query` is our wrapper around fetch that works seamlessly with TanStack Query. It:
+- Handles response parsing (JSON, text, blobs).
+- Constructs proper error objects.
+- Sets the headers appropriately.
+- Integrates with our global error handling.
+
+```typescript
+interface QueryOptions {
+ pathParams?: Record; // URL parameters
+ queryParams?: Record; // Query string parameters
+ silent?: boolean; // Suppress error notifications
}
+
+// Basic usage
+useQuery({
+ queryKey: ["users"],
+ queryFn: query(routes.users.list)
+});
+
+// With parameters
+useQuery({
+ queryKey: ["user", id],
+ queryFn: query(routes.users.get, {
+ pathParams: { id },
+ queryParams: { include: "details" },
+ silent: true // Optional: suppress error notifications
+ })
+});
```
+### Error Handling
+
+All API errors are now handled globally. Common scenarios like:
+
+- Session expiry -> Redirects to /session-expired
+- Bad requests (400/406) -> Shows error notification
+are automatically handled.
+
+Use the `silent: true` option to suppress error notifications for specific queries.
+
## Migration Guide & Reference
### Understanding the Transition
Our codebase contains two patterns for data fetching:
+
1. Legacy pattern using `useTanStackQueryInstead` (wrapper around TanStack Query)
2. Modern pattern using TanStack Query directly
@@ -60,12 +127,9 @@ function LegacyComponent({ id }) {
function ModernComponent({ id }) {
const { data, isLoading, error, refetch } = useQuery({
queryKey: [UserRoutes.getUser.path, id],
- queryFn: async () => {
- const response = await request(UserRoutes.getUser, {
- pathParams: { id }
- });
- return response.data;
- },
+ queryFn: query(UserRoutes.getUser, {
+ pathParams: { id }
+ }),
enabled: true,
refetchOnWindowFocus: false
});
@@ -100,7 +164,7 @@ useTanStackQueryInstead(route, { prefetch: shouldFetch })
// Modern
useQuery({
queryKey: [route.path],
- queryFn: async () => (await request(route)).data,
+ queryFn: query(route),
enabled: shouldFetch
})
```
@@ -116,13 +180,10 @@ useTanStackQueryInstead(route, {
// Modern
useQuery({
queryKey: [route.path, id, filter],
- queryFn: async () => {
- const response = await request(route, {
- pathParams: { id },
- query: { filter }
- });
- return response.data;
- }
+ queryFn: query(route, {
+ pathParams: { id },
+ queryParams: { filter }
+ })
})
```
@@ -135,12 +196,10 @@ if (res?.status === 403) handleForbidden();
// Modern
useQuery({
queryKey: [route.path],
- queryFn: async () => {
- const response = await request(route);
- if (response.res.status === 403) handleForbidden();
- return response.data;
- },
- onError: (error) => handleError(error)
+ queryFn: query(route, {
+ silent: true // Optional: suppress error notifications
+ })
+ // Error handling is now done globally
})
```
diff --git a/src/Utils/request/errorHandler.ts b/src/Utils/request/errorHandler.ts
new file mode 100644
index 00000000000..68d7e4600bb
--- /dev/null
+++ b/src/Utils/request/errorHandler.ts
@@ -0,0 +1,54 @@
+import { navigate } from "raviger";
+
+import * as Notifications from "@/Utils/Notifications";
+import { QueryError } from "@/Utils/request/queryError";
+
+export function handleQueryError(error: Error) {
+ if (error.name === "AbortError") {
+ return;
+ }
+
+ if (!(error instanceof QueryError)) {
+ Notifications.Error({ msg: error.message || "Something went wrong!" });
+ return;
+ }
+
+ if (error.silent) {
+ return;
+ }
+
+ const cause = error.cause;
+
+ if (isSessionExpired(cause)) {
+ handleSessionExpired();
+ return;
+ }
+
+ if (isBadRequest(error)) {
+ Notifications.BadRequest({ errs: cause });
+ return;
+ }
+
+ Notifications.Error({
+ msg: cause?.detail || "Something went wrong...!",
+ });
+}
+
+function isSessionExpired(error: QueryError["cause"]) {
+ return (
+ // If Authorization header is not valid
+ error?.code === "token_not_valid" ||
+ // If Authorization header is not provided
+ error?.detail === "Authentication credentials were not provided."
+ );
+}
+
+function handleSessionExpired() {
+ if (!location.pathname.startsWith("/session-expired")) {
+ navigate(`/session-expired?redirect=${window.location.href}`);
+ }
+}
+
+function isBadRequest(error: QueryError) {
+ return error.status === 400 || error.status === 406;
+}
diff --git a/src/Utils/request/query.ts b/src/Utils/request/query.ts
new file mode 100644
index 00000000000..3431f625728
--- /dev/null
+++ b/src/Utils/request/query.ts
@@ -0,0 +1,56 @@
+import careConfig from "@careConfig";
+
+import { QueryError } from "@/Utils/request/queryError";
+import { getResponseBody } from "@/Utils/request/request";
+import { QueryOptions, Route } from "@/Utils/request/types";
+import { makeHeaders, makeUrl } from "@/Utils/request/utils";
+
+async function queryRequest(
+ { path, method, noAuth }: Route,
+ options?: QueryOptions,
+): Promise {
+ const url = `${careConfig.apiUrl}${makeUrl(path, options?.queryParams, options?.pathParams)}`;
+
+ const fetchOptions: RequestInit = {
+ method,
+ headers: makeHeaders(noAuth ?? false),
+ signal: options?.signal,
+ };
+
+ if (options?.body) {
+ fetchOptions.body = JSON.stringify(options.body);
+ }
+
+ let res: Response;
+
+ try {
+ res = await fetch(url, fetchOptions);
+ } catch {
+ throw new Error("Network Error");
+ }
+
+ const data = await getResponseBody(res);
+
+ if (!res.ok) {
+ throw new QueryError({
+ message: "Request Failed",
+ status: res.status,
+ silent: options?.silent ?? false,
+ cause: data as unknown as Record,
+ });
+ }
+
+ return data;
+}
+
+/**
+ * Creates a TanStack Query compatible request function
+ */
+export default function query(
+ route: Route,
+ options?: QueryOptions,
+) {
+ return ({ signal }: { signal: AbortSignal }) => {
+ return queryRequest(route, { ...options, signal });
+ };
+}
diff --git a/src/Utils/request/queryError.ts b/src/Utils/request/queryError.ts
new file mode 100644
index 00000000000..cdfad312ef4
--- /dev/null
+++ b/src/Utils/request/queryError.ts
@@ -0,0 +1,24 @@
+type QueryErrorCause = Record | undefined;
+
+export class QueryError extends Error {
+ status: number;
+ silent: boolean;
+ cause?: QueryErrorCause;
+
+ constructor({
+ message,
+ status,
+ silent,
+ cause,
+ }: {
+ message: string;
+ status: number;
+ silent: boolean;
+ cause?: Record;
+ }) {
+ super(message, { cause });
+ this.status = status;
+ this.silent = silent;
+ this.cause = cause;
+ }
+}
diff --git a/src/Utils/request/request.ts b/src/Utils/request/request.ts
index 73bc9763f50..bd1cabc523c 100644
--- a/src/Utils/request/request.ts
+++ b/src/Utils/request/request.ts
@@ -61,7 +61,7 @@ export default async function request(
return result;
}
-async function getResponseBody(res: Response): Promise {
+export async function getResponseBody(res: Response): Promise {
if (!(res.headers.get("content-length") !== "0")) {
return null as TData;
}
diff --git a/src/Utils/request/types.ts b/src/Utils/request/types.ts
index 668f937dbf0..20f095ae6fd 100644
--- a/src/Utils/request/types.ts
+++ b/src/Utils/request/types.ts
@@ -35,6 +35,14 @@ export interface RequestOptions {
silent?: boolean;
}
+export interface QueryOptions {
+ pathParams?: Record;
+ queryParams?: Record;
+ body?: TBody;
+ silent?: boolean;
+ signal?: AbortSignal;
+}
+
export interface PaginatedResponse {
count: number;
next: string | null;