Skip to content

Commit

Permalink
Util function for cleaner usage of TanStack useQuery (#9395)
Browse files Browse the repository at this point in the history
----

Co-authored-by: rithviknishad <[email protected]>
  • Loading branch information
amjithtitus09 and rithviknishad authored Dec 13, 2024
1 parent 2611a32 commit 05e8f4c
Show file tree
Hide file tree
Showing 7 changed files with 251 additions and 42 deletions.
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
137 changes: 98 additions & 39 deletions src/Utils/request/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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: query(routes.patient.get, {
pathParams: { id }
})
});

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

// With query parameters
function SearchMedicines() {
const { data } = useQuery({
queryKey: ['medicines', 'paracetamol'],
queryFn: 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: query(routes.getFacility, {
pathParams: { id },
silent: true
})
});

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

### 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<string, string>; // URL parameters
queryParams?: Record<string, string>; // 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

Expand All @@ -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
});
Expand Down Expand Up @@ -100,7 +164,7 @@ useTanStackQueryInstead(route, { prefetch: shouldFetch })
// Modern
useQuery({
queryKey: [route.path],
queryFn: async () => (await request(route)).data,
queryFn: query(route),
enabled: shouldFetch
})
```
Expand All @@ -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 }
})
})
```

Expand All @@ -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
})
```

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

0 comments on commit 05e8f4c

Please sign in to comment.