Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/app/default/api/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { data } from "@/app/default/data";

export async function GET() {
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 100));

return NextResponse.json({
data,
total: data.length,
});
}
30 changes: 30 additions & 0 deletions src/app/default/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { columns } from "./columns";
import { filterFields } from "./constants";
import { DataTable } from "./data-table";
import { dataOptions } from "./query-options";
import { useQueryStates } from "nuqs";
import { searchParamsParser } from "./search-params";

export function Client() {
const [search] = useQueryStates(searchParamsParser);
const { data } = useQuery(dataOptions(search));

if (!data) return null;

return (
<DataTable
columns={columns}
data={data.data}
filterFields={filterFields}
defaultColumnFilters={Object.entries(search)
.map(([key, value]) => ({
id: key,
value,
}))
.filter(({ value }) => value ?? undefined)}
/>
);
}
5 changes: 5 additions & 0 deletions src/app/default/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Skeleton } from "./skeleton";

export default function Loading() {
return <Skeleton />;
}
30 changes: 11 additions & 19 deletions src/app/default/page.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,23 @@
import * as React from "react";
import { columns } from "./columns";
import { filterFields } from "./constants";
import { data } from "./data";
import { DataTable } from "./data-table";
import { getQueryClient } from "@/providers/get-query-client";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { searchParamsCache } from "./search-params";
import { Skeleton } from "./skeleton";
import { dataOptions } from "./query-options";
import { Client } from "./client";

export default async function Page({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const search = searchParamsCache.parse(await searchParams);
const queryClient = getQueryClient();
await queryClient.prefetchQuery(dataOptions(search));

const dehydratedState = dehydrate(queryClient);

return (
<React.Suspense fallback={<Skeleton />}>
<DataTable
columns={columns}
data={data}
filterFields={filterFields}
defaultColumnFilters={Object.entries(search)
.map(([key, value]) => ({
id: key,
value,
}))
.filter(({ value }) => value ?? undefined)}
/>
</React.Suspense>
<HydrationBoundary state={dehydratedState}>
<Client />
</HydrationBoundary>
);
}
26 changes: 26 additions & 0 deletions src/app/default/query-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { queryOptions } from "@tanstack/react-query";
import type { ColumnSchema } from "./types";

interface ApiResponse {
data: ColumnSchema[];
total: number;
}

export const dataOptions = (search: Record<string, any>) =>
queryOptions({
queryKey: ["default-data", search],
queryFn: async () => {
// Use absolute URL for server-side fetching
const baseUrl = typeof window === 'undefined'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: could you import the data directly here when in server-side (à la RSC), rather than going through HTTP server -> server? Or does it pose bundling separation issues?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree with you @franky47 - if it's a production app, I'd do a db query straight to get the data. Here, to keep the data consistent and change it only in a single place, I'd suggest to leave the http call.

@gruckion I think we would need to use the https://${VERCEL_URL} on non dev environments to make it work.

we should probably do the same for the /infinite/query-options to make it fetch from the server.

? `http://localhost:${process.env.PORT || 3001}`
: '';

const response = await fetch(`${baseUrl}/default/api`);
if (!response.ok) {
throw new Error("Failed to fetch data");
}
const result: ApiResponse = await response.json();
return result;
},
staleTime: 1000 * 60 * 5, // 5 minutes
});
23 changes: 13 additions & 10 deletions src/app/infinite/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,17 @@ import { getLevelRowClassName } from "@/lib/request/level";
import { cn } from "@/lib/utils";
import { useInfiniteQuery } from "@tanstack/react-query";
import type { Table as TTable } from "@tanstack/react-table";
import { useQueryState, useQueryStates } from "nuqs";
import { useQueryState } from "nuqs";
import * as React from "react";
import { LiveRow } from "./_components/live-row";
import { columns } from "./columns";
import { filterFields as defaultFilterFields, sheetFields } from "./constants";
import { DataTableInfinite } from "./data-table-infinite";
import { dataOptions } from "./query-options";
import type { FacetMetadataSchema } from "./schema";
import { searchParamsParser } from "./search-params";
import { searchParamsParser, type SearchParamsType } from "./search-params";

export function Client() {
const [search] = useQueryStates(searchParamsParser);
export function Client({ search }: { search: SearchParamsType }) {
const {
data,
isFetching,
Expand Down Expand Up @@ -76,19 +75,23 @@ export function Client() {
});
}, [facets]);

const defaultColumnFilters = React.useMemo(() => {
return Object.entries(filter)
.map(([key, value]) => ({
id: key,
value,
}))
.filter(({ value }) => value ?? undefined);
}, [filter]);

return (
<DataTableInfinite
columns={columns}
data={flatData}
totalRows={totalDBRowCount}
filterRows={filterDBRowCount}
totalRowsFetched={totalFetched}
defaultColumnFilters={Object.entries(filter)
.map(([key, value]) => ({
id: key,
value,
}))
.filter(({ value }) => value ?? undefined)}
defaultColumnFilters={defaultColumnFilters}
defaultColumnSorting={sort ? [sort] : undefined}
defaultRowSelection={search.uuid ? { [search.uuid]: true } : undefined}
// FIXME: make it configurable - TODO: use `columnHidden: boolean` in `filterFields`
Expand Down
5 changes: 5 additions & 0 deletions src/app/infinite/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Skeleton } from "./skeleton";

export default function Loading() {
return <Skeleton />;
}
12 changes: 9 additions & 3 deletions src/app/infinite/page.tsx
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the HydrationBoundary doesn't seem to work on the infinite page for me.

when adding the absolute url to the query-options - the isLoading property from useInfiniteQuery always starts to be true - which shouldn't be the case.

I'm trying to debug it currently the dataOptions(search) returns two different values based on server and client.

Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import * as React from "react";
import { searchParamsCache } from "./search-params";
import { getQueryClient } from "@/providers/get-query-client";
import { dataOptions } from "./query-options";
import { Client } from "./client";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";

export default async function Page({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const search = searchParamsCache.parse(await searchParams);
const search = await searchParamsCache.parse(searchParams);
const queryClient = getQueryClient();
await queryClient.prefetchInfiniteQuery(dataOptions(search));
Copy link
Member

@mxkaske mxkaske Sep 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when logging on server component (/infinite/page.tsx)

  const queryOptions = dataOptions(search);
  console.log('server key', queryOptions); // [ 'data-table', [ 'data-table', '' ] ]
  await queryClient.prefetchInfiniteQuery(queryOptions);

while in client component (/infinite/client.tsx)

const queryOptions = dataOptions(search);
console.log('client key', queryOptions); // [ 'data-table', '?cursor=1758483849134' ]
const { .. } = useInfiniteQuery(queryOptions);

while search is exactly same!

To be exact, the searchParamsSerializer = createSerializer(searchParamsParser) returns two different values based on server and client.

@franky47 is that known?

Copy link

@franky47 franky47 Sep 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah the problem is in the definition of the default value for cursor in the search params definitions object: https://github.com/gruckion/data-table-filters/blob/3b564725b96f53d093567466eab64530f367f732/src/app/infinite/search-params.ts#L58

If you log the timestamp for this new Date(), you'll see it differs between the client and the server, which makes sense because the code is evaluated at different times.

So on the server, the serializer sees the default value as equal (comparison by timestamp), but on the client it doesn't, and therefore renders it to the URL, giving you different React Query keys.


return <Client />;
const dehydratedState = dehydrate(queryClient);

return (
<HydrationBoundary state={dehydratedState}>
<Client search={search} />
</HydrationBoundary>
);
}
5 changes: 3 additions & 2 deletions src/app/infinite/query-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const dataOptions = (search: SearchParamsType) => {
json,
);
},
initialPageParam: { cursor: new Date().getTime(), direction: "next" },
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch!

initialPageParam: { cursor: search.cursor.getTime(), direction: "next" },
getPreviousPageParam: (firstPage, _pages) => {
if (!firstPage.prevCursor) return null;
return { cursor: firstPage.prevCursor, direction: "prev" };
Expand All @@ -60,5 +60,6 @@ export const dataOptions = (search: SearchParamsType) => {
},
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
});
staleTime: 1000 * 60 * 5, // 5 minutes
})
};
Loading