From 7d06216f78be5277297e6a92aeb7d4fc8ebd37d9 Mon Sep 17 00:00:00 2001 From: Oleksandr Shevtsov Date: Tue, 22 Oct 2024 15:02:33 +0300 Subject: [PATCH] feat: tanstack query for client cache and state management (#30) Co-authored-by: Oleksandr Shevtsov --- app/components/delete-report-button.tsx | 18 +++-- app/components/delete-results-button.tsx | 20 ++++-- app/components/fs-stat-tabs.tsx | 2 +- app/components/generate-report-button.tsx | 22 ++++-- app/components/report-trends.tsx | 26 ++++--- app/components/reports-table.tsx | 40 ++++++----- app/components/results-table.tsx | 42 ++++++------ app/hooks/useMutation.ts | 70 ++++++++++--------- app/hooks/useQuery.ts | 82 ++++++++++------------- app/lib/query-cache.ts | 18 +++++ app/page.tsx | 4 +- app/providers/index.tsx | 22 +++++- package-lock.json | 51 ++++++++++++++ package.json | 2 + 14 files changed, 264 insertions(+), 155 deletions(-) create mode 100644 app/lib/query-cache.ts diff --git a/app/components/delete-report-button.tsx b/app/components/delete-report-button.tsx index ef5567e..a0b7493 100644 --- a/app/components/delete-report-button.tsx +++ b/app/components/delete-report-button.tsx @@ -12,10 +12,12 @@ import { Button, } from '@nextui-org/react'; import { useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import useMutation from '@/app/hooks/useMutation'; import ErrorMessage from '@/app/components/error-message'; import { DeleteIcon } from '@/app/components/icons'; +import { invalidateCache } from '@/app/lib/query-cache'; interface DeleteProjectButtonProps { reportId: string; @@ -23,7 +25,15 @@ interface DeleteProjectButtonProps { } export default function DeleteReportButton({ reportId, onDeleted }: DeleteProjectButtonProps) { - const { mutate: deleteReport, isLoading, error } = useMutation('/api/report/delete', { method: 'DELETE' }); + const queryClient = useQueryClient(); + const { + mutate: deleteReport, + isPending, + error, + } = useMutation('/api/report/delete', { + method: 'DELETE', + onSuccess: () => invalidateCache(queryClient, { queryKeys: ['/api/info'], predicate: '/api/report' }), + }); const [confirm, setConfirm] = useState(''); const { isOpen, onOpen, onOpenChange } = useDisclosure(); @@ -33,7 +43,7 @@ export default function DeleteReportButton({ reportId, onDeleted }: DeleteProjec return; } - await deleteReport({ reportsIds: [reportId] }); + deleteReport({ body: { reportsIds: [reportId] } }); onDeleted?.(); }; @@ -42,7 +52,7 @@ export default function DeleteReportButton({ reportId, onDeleted }: DeleteProjec !!reportId && ( <> - @@ -68,7 +78,7 @@ export default function DeleteReportButton({ reportId, onDeleted }: DeleteProjec @@ -74,7 +84,7 @@ export default function DeleteResultsButton({ resultIds, onDeletedResult }: Dele @@ -65,7 +75,7 @@ export default function GenerateReportButton({ ({ label: project, value: project }))} label="Project name" placeholder="leave empty if not required" @@ -76,10 +86,10 @@ export default function GenerateReportButton({ - - diff --git a/app/components/report-trends.tsx b/app/components/report-trends.tsx index 695745e..867bffc 100644 --- a/app/components/report-trends.tsx +++ b/app/components/report-trends.tsx @@ -1,7 +1,7 @@ 'use client'; import { Spinner } from '@nextui-org/react'; -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { defaultProjectName } from '../lib/constants'; @@ -12,24 +12,32 @@ import { title } from '@/app/components/primitives'; import useQuery from '@/app/hooks/useQuery'; import ErrorMessage from '@/app/components/error-message'; import { type ReportHistory } from '@/app/lib/storage'; +import { withQueryParams } from '@/app/lib/network'; export default function ReportTrends() { - const getProjectQueryParam = (project: string) => - project === defaultProjectName ? '' : `?project=${encodeURIComponent(project)}`; - - const getUrl = (project: string) => `/api/report/trend${getProjectQueryParam(project)}`; - - const { data: reports, error, isLoading, refetch } = useQuery(getUrl(defaultProjectName)); + const [project, setProject] = useState(defaultProjectName); + + const { + data: reports, + error, + isFetching, + isPending, + } = useQuery( + withQueryParams('/api/report/trend', { + project, + }), + { dependencies: [project] }, + ); const onProjectChange = useCallback((project: string) => { - refetch({ path: getUrl(project) }); + setProject(project); }, []); return ( <>

Trends

- {isLoading && } + {(isFetching || isPending) && }
diff --git a/app/components/reports-table.tsx b/app/components/reports-table.tsx index 4919776..07c0b33 100644 --- a/app/components/reports-table.tsx +++ b/app/components/reports-table.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { Table, TableHeader, @@ -15,12 +15,13 @@ import { LinkIcon, } from '@nextui-org/react'; import Link from 'next/link'; +import { keepPreviousData } from '@tanstack/react-query'; import TablePaginationOptions from './table-pagination-options'; import { withQueryParams } from '@/app/lib/network'; import { defaultProjectName } from '@/app/lib/constants'; -import useMutation from '@/app/hooks/useMutation'; +import useQuery from '@/app/hooks/useQuery'; import ErrorMessage from '@/app/components/error-message'; import DeleteReportButton from '@/app/components/delete-report-button'; import FormattedDate from '@/app/components/date-format'; @@ -50,30 +51,22 @@ export default function ReportsTable({ onChange }: ReportsTableProps) { project, }); - const { isLoading, error, mutate } = useMutation(withQueryParams(reportListEndpoint, getQueryParams()), { - method: 'GET', + const { + data: reportResponse, + isFetching, + isPending, + error, + refetch, + } = useQuery(withQueryParams(reportListEndpoint, getQueryParams()), { + dependencies: [project, rowsPerPage, page], + placeholderData: keepPreviousData, }); - const fetchReports = async () => { - mutate(null, { - path: withQueryParams(reportListEndpoint, getQueryParams()), - }).then((res) => setReportResponse(res)); - }; - - const [reportResponse, setReportResponse] = useState({ reports: [], total: 0 }); - - useEffect(() => { - if (isLoading) { - return; - } - fetchReports(); - }, [rowsPerPage, project, page]); - const { reports, total } = reportResponse ?? {}; const onDeleted = () => { onChange?.(); - fetchReports(); + refetch(); }; const onPageChange = useCallback( @@ -132,7 +125,12 @@ export default function ReportsTable({ onChange }: ReportsTableProps) { )} - }> + } + > {(item) => ( diff --git a/app/components/results-table.tsx b/app/components/results-table.tsx index 533bdb8..fca87bb 100644 --- a/app/components/results-table.tsx +++ b/app/components/results-table.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { Table, TableHeader, @@ -13,11 +13,12 @@ import { Spinner, Pagination, } from '@nextui-org/react'; +import { keepPreviousData } from '@tanstack/react-query'; import { withQueryParams } from '@/app/lib/network'; import { defaultProjectName } from '@/app/lib/constants'; import TablePaginationOptions from '@/app/components/table-pagination-options'; -import useMutation from '@/app/hooks/useMutation'; +import useQuery from '@/app/hooks/useQuery'; import ErrorMessage from '@/app/components/error-message'; import FormattedDate from '@/app/components/date-format'; import { ReadResultsOutput, type Result } from '@/app/lib/storage'; @@ -53,30 +54,22 @@ export default function ResultsTable({ onSelect, onDeleted, selected }: ResultsT project, }); - const { isLoading, error, mutate } = useMutation(withQueryParams(resultListEndpoint, getQueryParams()), { - method: 'GET', + const { + data: resultsResponse, + isFetching, + isPending, + error, + refetch, + } = useQuery(withQueryParams(resultListEndpoint, getQueryParams()), { + dependencies: [project, rowsPerPage, page], + placeholderData: keepPreviousData, }); - const fetchResults = async () => { - mutate(null, { - path: withQueryParams(resultListEndpoint, getQueryParams()), - }).then((res) => setResultsResponse(res)); - }; - - const [resultsResponse, setResultsResponse] = useState({ results: [], total: 0 }); - - useEffect(() => { - if (isLoading) { - return; - } - fetchResults(); - }, [rowsPerPage, project, page]); - const { results, total } = resultsResponse ?? {}; const shouldRefetch = () => { onDeleted?.(); - fetchResults(); + refetch(); }; const onPageChange = useCallback( @@ -121,7 +114,7 @@ export default function ResultsTable({ onSelect, onDeleted, selected }: ResultsT <> )} - }> + } + > {(item) => ( {item.resultID} diff --git a/app/hooks/useMutation.ts b/app/hooks/useMutation.ts index dca3f8e..4efddce 100644 --- a/app/hooks/useMutation.ts +++ b/app/hooks/useMutation.ts @@ -1,48 +1,46 @@ 'use client'; -import { useState, useCallback } from 'react'; +import { useMutation as useTanStackMutation, UseMutationOptions } from '@tanstack/react-query'; import { useSession } from 'next-auth/react'; -const useMutation = (url: string, options: RequestInit) => { - const [isLoading, setLoading] = useState(false); - const [error, setError] = useState(null); - const session = useSession(); +type MutationFnParams = { + body?: TVariables; + path?: string; +}; +const useMutation = ( + url: string, + options?: Omit>, 'mutationFn'> & { + method?: string; + }, +) => { + const session = useSession(); const apiToken = session?.data?.user?.apiToken; - const mutate = useCallback( - async (body?: unknown, opts?: { path: string }) => { - setLoading(true); - setError(null); - - try { - const headers = !!apiToken - ? { - Authorization: apiToken, - } - : undefined; - - const response = await fetch(opts?.path ?? url, { - headers, - body: body ? JSON.stringify(body) : undefined, - method: options?.method ?? 'GET', - }); - - if (!response.ok) { - throw new Error(`Network response was not ok: ${await response.text()}`); - } - - return await response.json(); - } catch (err) { - setError(err as Error); - } finally { - setLoading(false); + return useTanStackMutation>({ + mutationFn: async ({ body, path }: MutationFnParams) => { + const headers: HeadersInit = { + 'Content-Type': 'application/json', + }; + + if (apiToken) { + headers.Authorization = apiToken; } - }, - [url, options], - ); - return { isLoading, error, mutate: mutate }; + const response = await fetch(path ?? url, { + headers, + body: body ? JSON.stringify(body) : undefined, + method: options?.method ?? 'POST', + }); + + if (!response.ok) { + throw new Error(`Network response was not ok: ${await response.text()}`); + } + + return response.json(); + }, + ...options, + }); }; export default useMutation; diff --git a/app/hooks/useQuery.ts b/app/hooks/useQuery.ts index 74ee962..0db8286 100644 --- a/app/hooks/useQuery.ts +++ b/app/hooks/useQuery.ts @@ -1,60 +1,27 @@ 'use client'; -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useQuery as useTanStackQuery, UseQueryOptions } from '@tanstack/react-query'; import { useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; + +import { withQueryParams } from '../lib/network'; const useQuery = ( path: string, - options?: RequestInit & { dependencies?: unknown[]; callback?: string }, + options?: Omit, 'queryKey' | 'queryFn'> & { + dependencies?: unknown[]; + callback?: string; + method?: string; + body?: BodyInit | null; + }, ) => { - const [data, setData] = useState(null); - const [isLoading, setLoading] = useState(false); - const [error, setError] = useState(null); const session = useSession(); const router = useRouter(); - const apiToken = useMemo(() => session?.data?.user?.apiToken, [session]); - - const fetchData = useCallback( - async (opts?: { path: string }) => { - setLoading(true); - setError(null); - try { - const headers = apiToken - ? { - Authorization: apiToken, - } - : undefined; - - const response = await fetch(opts?.path ?? path, { - headers, - body: options?.body ? JSON.stringify(options.body) : undefined, - method: options?.method ?? 'GET', - }); - - if (!response.ok) { - throw new Error(`Network response was not ok: ${await response.text()}`); - } - const jsonData = await response.json(); - - setData(jsonData); - } catch (err) { - if (err instanceof Error) { - setError(err); - } - } finally { - setLoading(false); - } - }, - [path, options, apiToken, ...(options?.dependencies ?? [])], - ); - useEffect(() => { if (session.status === 'unauthenticated') { - const redirectParam = options?.callback ? `?callbackUrl=${encodeURI(options.callback)}` : ''; - - router.replace(`/login${redirectParam}`); + router.push(withQueryParams('/login', options?.callback ? { callbackUrl: encodeURI(options.callback) } : {})); return; } @@ -62,11 +29,32 @@ const useQuery = ( if (session.status === 'loading') { return; } - - fetchData(); }, [session.status]); - return { data, isLoading, error, refetch: fetchData }; + return useTanStackQuery({ + queryKey: [path, ...(options?.dependencies ?? [])], + queryFn: async () => { + const headers: HeadersInit = {}; + + if (session.data?.user?.apiToken) { + headers.Authorization = session.data.user.apiToken; + } + + const response = await fetch(path, { + headers, + body: options?.body ? JSON.stringify(options.body) : undefined, + method: options?.method ?? 'GET', + }); + + if (!response.ok) { + throw new Error(`Network response was not ok: ${await response.text()}`); + } + + return response.json(); + }, + enabled: session.status === 'authenticated', + ...options, + }); }; export default useQuery; diff --git a/app/lib/query-cache.ts b/app/lib/query-cache.ts new file mode 100644 index 0000000..ecd92b0 --- /dev/null +++ b/app/lib/query-cache.ts @@ -0,0 +1,18 @@ +import { type QueryClient } from '@tanstack/react-query'; + +interface InvalidateCacheOptions { + queryKeys?: string[]; + predicate?: string; +} + +export const invalidateCache = (client: QueryClient, options: InvalidateCacheOptions) => { + if (options?.queryKeys) { + client.invalidateQueries({ queryKey: options.queryKeys }); + } + + if (options?.predicate) { + client.invalidateQueries({ + predicate: (q) => q.queryKey.some((key) => typeof key === 'string' && key.startsWith(options.predicate!)), + }); + } +}; diff --git a/app/page.tsx b/app/page.tsx index 7e87a8a..44d9be6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -23,7 +23,7 @@ export default function DashboardPage() { const { status } = useSession(); const authIsLoading = status === 'loading'; - const { data: info, error, refetch } = useQuery('/api/info'); + const { data: info, error, refetch, isLoading: isInfoLoading } = useQuery('/api/info'); const [selectedTab, setSelectedTab] = useState(getPersistedSelectedTab() ?? ''); const [refreshId, setRefreshId] = useState(uuidv4()); @@ -34,7 +34,7 @@ export default function DashboardPage() { refetch(); }, [refreshId]); - if (authIsLoading) { + if (authIsLoading || isInfoLoading) { return ; } diff --git a/app/providers/index.tsx b/app/providers/index.tsx index 9c62c43..481363a 100644 --- a/app/providers/index.tsx +++ b/app/providers/index.tsx @@ -1,14 +1,32 @@ -import React from 'react'; +'use client'; + +import React, { useState } from 'react'; import { NextUIProvider } from '@nextui-org/system'; import { ThemeProvider as NextThemesProvider } from 'next-themes'; import { ThemeProviderProps } from 'next-themes/dist/types'; import { SessionProvider } from 'next-auth/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; export const Providers: React.FC = ({ children, ...themeProps }) => { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, // 5 minutes + }, + }, + }), + ); + return ( - {children} + + {children} + + ); diff --git a/package-lock.json b/package-lock.json index a0cb16c..b458a82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@nextui-org/react": "^2.4.8", "@playwright/test": "1.45.1", + "@tanstack/react-query": "^5.59.15", + "@tanstack/react-query-devtools": "^5.59.15", "envalid": "^8.0.0", "framer-motion": "^11.11.8", "get-folder-size": "5.0.0", @@ -4786,6 +4788,55 @@ "tslib": "^2.4.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.59.13", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.59.13.tgz", + "integrity": "sha512-Oou0bBu/P8+oYjXsJQ11j+gcpLAMpqW42UlokQYEz4dE7+hOtVO9rVuolJKgEccqzvyFzqX4/zZWY+R/v1wVsQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.58.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.58.0.tgz", + "integrity": "sha512-iFdQEFXaYYxqgrv63ots+65FGI+tNp5ZS5PdMU1DWisxk3fez5HG3FyVlbUva+RdYS5hSLbxZ9aw3yEs97GNTw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.59.15", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.59.15.tgz", + "integrity": "sha512-QbVlAkTI78wB4Mqgf2RDmgC0AOiJqer2c5k9STOOSXGv1S6ZkY37r/6UpE8DbQ2Du0ohsdoXgFNEyv+4eDoPEw==", + "dependencies": { + "@tanstack/query-core": "5.59.13" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.59.15", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.59.15.tgz", + "integrity": "sha512-rX28KTivkA2XEn3Fj9ckDtnTPY8giWYgssySSAperpVol4+th+NCij/MhLylfB+Mfg2JfCxOcwnM/fwzS8iSog==", + "dependencies": { + "@tanstack/query-devtools": "5.58.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.59.15", + "react": "^18 || ^19" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "devOptional": true, diff --git a/package.json b/package.json index 8d1a4db..eeba4dd 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "dependencies": { "@nextui-org/react": "^2.4.8", "@playwright/test": "1.45.1", + "@tanstack/react-query": "^5.59.15", + "@tanstack/react-query-devtools": "^5.59.15", "envalid": "^8.0.0", "framer-motion": "^11.11.8", "get-folder-size": "5.0.0",