Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Util function for cleaner usage of TanStack useQuery #9395

Merged
merged 11 commits into from
Dec 13, 2024
Merged
12 changes: 10 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 = () => {
Expand Down
135 changes: 96 additions & 39 deletions src/Utils/request/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,101 @@ 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 `api.query`:

```tsx
import { useQuery } from "@tanstack/react-query";
import request from "@/Utils/request/request";
import FooRoutes from "@foo/routes";
import api from "@/Utils/request/api-request";

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: api.query(routes.users.current)
});

if (isLoading) return <Loading />;
if (error) return <Error error={error} />;
return <div>{data?.name}</div>;
}

return (
<div>
<span>{data.id}</span>
<span>{data.name}</span>
</div>
);
// With path parameters
function PatientDetails({ id }: { id: string }) {
const { data } = useQuery({
queryKey: ['patient', id],
queryFn: api.query(routes.patient.get, {
pathParams: { id }
})
});

return <div>{data?.name}</div>;
}

// With query parameters
function SearchMedicines() {
const { data } = useQuery({
queryKey: ['medicines', 'paracetamol'],
queryFn: api.query(routes.medicine.search, {
queryParams: { search: 'paracetamol' }
})
});

return <MedicinesList medicines={data?.results} />;
}

// When you need response status/error handling
function FacilityDetails({ id }: { id: string }) {
const { data, isLoading } = useQuery({
queryKey: ["facility", id],
queryFn: api.query(routes.getFacility, {
pathParams: { id },
silent: true
})
});

if (isLoading) return <Loading />;
return <div>{data?.name}</div>;
}

### api.query

`api.query` is our wrapper around fetch that works seamlessly with TanStack Query. It:
- Handles response parsing (JSON, text, blobs)
- Constructs proper error objects
- Integrates with our global error handling

```typescript
interface QueryOptions {
pathParams?: Record<string, string>; // URL parameters
queryParams?: Record<string, string>; // Query string parameters
silent?: boolean; // Suppress error notifications
}

// Basic usage
useQuery({
queryKey: ["users"],
queryFn: api.query(routes.users.list)
});

// With parameters
useQuery({
queryKey: ["user", id],
queryFn: api.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:
- 404 responses -> Redirects to /not-found
- 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
Expand All @@ -60,12 +125,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: api.query(UserRoutes.getUser, {
pathParams: { id }
}),
enabled: true,
refetchOnWindowFocus: false
});
Expand Down Expand Up @@ -100,7 +162,7 @@ useTanStackQueryInstead(route, { prefetch: shouldFetch })
// Modern
useQuery({
queryKey: [route.path],
queryFn: async () => (await request(route)).data,
queryFn: api.query(route),
enabled: shouldFetch
})
```
Expand All @@ -116,13 +178,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: api.query(route, {
pathParams: { id },
queryParams: { filter }
})
})
```

Expand All @@ -135,12 +194,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: api.query(route, {
silent: true // Optional: suppress error notifications
})
// Error handling is now done globally
})
```

Expand Down
54 changes: 54 additions & 0 deletions src/Utils/request/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -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;
}
56 changes: 56 additions & 0 deletions src/Utils/request/query.ts
Original file line number Diff line number Diff line change
@@ -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<TData, TBody>(
{ path, method, noAuth }: Route<TData, TBody>,
options?: QueryOptions<TBody>,
): Promise<TData> {
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<TData>(res);

if (!res.ok) {
throw new QueryError({
message: "Request Failed",
status: res.status,
silent: options?.silent ?? false,
cause: data as unknown as Record<string, unknown>,
});
}

return data;
}

/**
* Creates a TanStack Query compatible request function
*/
export default function query<TData, TBody>(
route: Route<TData, TBody>,
options?: QueryOptions<TBody>,
) {
return ({ signal }: { signal: AbortSignal }) => {
return queryRequest(route, { ...options, signal });
};
}
24 changes: 24 additions & 0 deletions src/Utils/request/queryError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
type QueryErrorCause = Record<string, unknown> | 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<string, unknown>;
}) {
super(message, { cause });
this.status = status;
this.silent = silent;
this.cause = cause;
}
}
2 changes: 1 addition & 1 deletion src/Utils/request/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export default async function request<TData, TBody>(
return result;
}

async function getResponseBody<TData>(res: Response): Promise<TData> {
export async function getResponseBody<TData>(res: Response): Promise<TData> {
if (!(res.headers.get("content-length") !== "0")) {
return null as TData;
}
Expand Down
8 changes: 8 additions & 0 deletions src/Utils/request/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ export interface RequestOptions<TData = unknown, TBody = unknown> {
silent?: boolean;
}

export interface QueryOptions<TBody = unknown> {
pathParams?: Record<string, string>;
queryParams?: Record<string, string>;
body?: TBody;
silent?: boolean;
signal?: AbortSignal;
}

export interface PaginatedResponse<TItem> {
count: number;
next: string | null;
Expand Down
Loading