Skip to content

Commit 149f36c

Browse files
authored
Merge pull request #64 from pennlabs/james/refactor-react-query-hydration
Refactor to React Query hydration pattern and fix favorites bug
2 parents 1ab0c7c + 6a878cf commit 149f36c

10 files changed

Lines changed: 107 additions & 54 deletions

File tree

backend/market/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ def retrieve(self, request, *args, **kwargs):
189189
serializer_class = ListingSerializer
190190
else:
191191
serializer_class = ListingSerializerPublic
192-
serializer = serializer_class(instance)
192+
serializer = serializer_class(instance, context={"request": request})
193193
return Response(serializer.data)
194194

195195

frontend/app/items/[id]/page.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1+
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
12
import { ListingDetail } from "@/components/listings/detail/ListingDetail";
23
import { getListingOrNotFound } from "@/lib/actions";
4+
import { queryKeys } from "@/lib/queryKeys";
35

46
export default async function ItemPage({ params }: { params: Promise<{ id: string }> }) {
57
const { id } = await params;
68
const item = await getListingOrNotFound(id);
79

8-
return <ListingDetail listing={item} initialIsFavorited={item.is_favorited ?? false} />;
10+
// seed the cache with the already-fetched listing (no additional fetch).
11+
// We use setQueryData instead of prefetchQuery to preserve the notFound() throw above.
12+
const queryClient = new QueryClient();
13+
queryClient.setQueryData(queryKeys.listing(item.id), item);
14+
15+
return (
16+
<HydrationBoundary state={dehydrate(queryClient)}>
17+
<ListingDetail listingId={item.id} />
18+
</HydrationBoundary>
19+
);
920
}

frontend/app/page.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,32 @@
1+
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
12
import { getCurrentUser, getItems } from "@/lib/actions";
3+
import { queryKeys } from "@/lib/queryKeys";
24
import { PageHeader } from "@/components/common/PageHeader";
35
import { ItemFilters } from "@/components/filters/ItemFilters";
46
import { ListingsGrid } from "@/components/listings/ListingsGrid";
57

68
export default async function ItemsPage() {
7-
const [items, currentUser] = await Promise.all([getItems({ pageParam: 1 }), getCurrentUser()]);
9+
const queryClient = new QueryClient();
10+
11+
const [, currentUser] = await Promise.all([
12+
queryClient.fetchInfiniteQuery({
13+
queryKey: queryKeys.listings("items"),
14+
queryFn: () => getItems({ pageParam: 1 }),
15+
initialPageParam: 1,
16+
getNextPageParam: (lastPage, allPages) =>
17+
lastPage.results.length > 0 ? allPages.length + 1 : undefined,
18+
pages: 1,
19+
}),
20+
getCurrentUser(),
21+
]);
822

923
return (
1024
<div className="container mx-auto w-full max-w-[96rem] space-y-6 px-12 pt-6">
1125
<PageHeader title="Browse Items" description="Discover the latest items on sale at Penn" />
1226
<ItemFilters />
13-
<ListingsGrid type="items" listings={items} currentUser={currentUser} />
27+
<HydrationBoundary state={dehydrate(queryClient)}>
28+
<ListingsGrid type="items" currentUser={currentUser} />
29+
</HydrationBoundary>
1430
</div>
1531
);
1632
}

frontend/app/sublets/[id]/page.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1+
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
12
import { ListingDetail } from "@/components/listings/detail/ListingDetail";
23
import { getListingOrNotFound } from "@/lib/actions";
4+
import { queryKeys } from "@/lib/queryKeys";
35

46
export default async function SubletPage({ params }: { params: Promise<{ id: string }> }) {
57
const { id } = await params;
68
const sublet = await getListingOrNotFound(id);
79

8-
return <ListingDetail listing={sublet} initialIsFavorited={sublet.is_favorited ?? false} />;
10+
// seed the cache with the already-fetched listing (no additional fetch).
11+
// We use setQueryData instead of prefetchQuery to preserve the notFound() throw above.
12+
const queryClient = new QueryClient();
13+
queryClient.setQueryData(queryKeys.listing(sublet.id), sublet);
14+
15+
return (
16+
<HydrationBoundary state={dehydrate(queryClient)}>
17+
<ListingDetail listingId={sublet.id} />
18+
</HydrationBoundary>
19+
);
920
}

frontend/app/sublets/page.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,32 @@
1+
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
12
import { getCurrentUser, getSublets } from "@/lib/actions";
3+
import { queryKeys } from "@/lib/queryKeys";
24
import { PageHeader } from "@/components/common/PageHeader";
35
import { SubletFilters } from "@/components/filters/SubletFilters";
46
import { ListingsGrid } from "@/components/listings/ListingsGrid";
57

68
export default async function SubletsPage() {
7-
const [sublets, currentUser] = await Promise.all([
8-
getSublets({ pageParam: 1 }),
9+
const queryClient = new QueryClient();
10+
11+
const [, currentUser] = await Promise.all([
12+
queryClient.fetchInfiniteQuery({
13+
queryKey: queryKeys.listings("sublets"),
14+
queryFn: () => getSublets({ pageParam: 1 }),
15+
initialPageParam: 1,
16+
getNextPageParam: (lastPage, allPages) =>
17+
lastPage.results.length > 0 ? allPages.length + 1 : undefined,
18+
pages: 1,
19+
}),
920
getCurrentUser(),
1021
]);
1122

1223
return (
1324
<div className="container mx-auto w-full max-w-[96rem] space-y-6 px-12 pt-6">
1425
<PageHeader title="Browse Sublets" description="Find your perfect housing solution at Penn" />
1526
<SubletFilters />
16-
<ListingsGrid type="sublets" listings={sublets} currentUser={currentUser} />
27+
<HydrationBoundary state={dehydrate(queryClient)}>
28+
<ListingsGrid type="sublets" currentUser={currentUser} />
29+
</HydrationBoundary>
1730
</div>
1831
);
1932
}

frontend/components/listings/ListingsGrid.tsx

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,15 @@ import { Spinner } from "@/components/ui/spinner";
55
import { ListingsCard } from "@/components/listings/ListingsCard";
66
import { NoListingsFound } from "@/components/listings/NoListingsFound";
77
import { useListings } from "@/hooks/useListings";
8-
import { Item, PaginatedResponse, Sublet, User } from "@/lib/types";
8+
import { ListingTypes, User } from "@/lib/types";
99

10-
type Props =
11-
| {
12-
type: "items";
13-
listings: PaginatedResponse<Item>;
14-
currentUser: User;
15-
}
16-
| {
17-
type: "sublets";
18-
listings: PaginatedResponse<Sublet>;
19-
currentUser: User;
20-
};
10+
type Props = {
11+
type: ListingTypes;
12+
currentUser: User;
13+
};
2114

22-
export const ListingsGrid = ({ type, listings, currentUser }: Props) => {
23-
const { data, error, isFetchingNextPage, hasNextPage, ref } = useListings({ type, listings });
15+
export const ListingsGrid = ({ type, currentUser }: Props) => {
16+
const { data, error, isFetchingNextPage, hasNextPage, ref } = useListings({ type });
2417

2518
const totalResults = data?.pages.reduce((acc, page) => acc + page.results.length, 0) || 0;
2619
const isEmpty = totalResults === 0;

frontend/components/listings/detail/ListingDetail.tsx

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"use client";
22

33
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4-
import { addToUsersFavorites, deleteFromUsersFavorites } from "@/lib/actions";
4+
import { addToUsersFavorites, deleteFromUsersFavorites, getListing } from "@/lib/actions";
5+
import { queryKeys } from "@/lib/queryKeys";
56
import { Heart, Share } from "lucide-react";
67
import { Item, Sublet } from "@/lib/types";
78
import { ListingActions } from "@/components/listings/detail/ListingActions";
@@ -12,42 +13,40 @@ import { BackButton } from "@/components/listings/detail/BackButton";
1213
import { SubletMap } from "@/components/listings/detail/SubletMap";
1314

1415
interface Props {
15-
listing: Item | Sublet;
16-
initialIsFavorited: boolean;
16+
listingId: number;
1717
}
1818

19-
export const ListingDetail = ({ listing, initialIsFavorited }: Props) => {
20-
const listingType = listing.listing_type;
21-
const priceLabel = listingType === "sublet" ? "/mo" : undefined;
22-
const listingOwnerLabel = listingType === "item" ? "Seller" : "Owner";
19+
export const ListingDetail = ({ listingId }: Props) => {
2320
const queryClient = useQueryClient();
24-
const favoritesQuery = useQuery({
25-
queryKey: ["favorite", listing.id],
26-
queryFn: async () => initialIsFavorited,
27-
initialData: initialIsFavorited,
28-
staleTime: Infinity,
21+
const queryKey = queryKeys.listing(listingId);
22+
23+
const { data: listing } = useQuery({
24+
queryKey,
25+
queryFn: () => getListing(String(listingId)),
2926
});
3027

31-
const isFavorited = favoritesQuery.data ?? false;
28+
const isFavorited = listing?.is_favorited ?? false;
3229

3330
const toggleFavoriteMutation = useMutation({
3431
meta: { suppressErrorToast: true }, // since it's noisy to show error toast on top of optimistic update
3532
mutationFn: async (shouldFavorite: boolean) => {
3633
if (shouldFavorite) {
37-
await addToUsersFavorites(listing.id);
34+
await addToUsersFavorites(listingId);
3835
} else {
39-
await deleteFromUsersFavorites(listing.id);
36+
await deleteFromUsersFavorites(listingId);
4037
}
4138
},
4239
onMutate: async (shouldFavorite: boolean) => {
43-
await queryClient.cancelQueries({ queryKey: ["favorite", listing.id] });
44-
const previous = queryClient.getQueryData<boolean>(["favorite", listing.id]);
45-
queryClient.setQueryData(["favorite", listing.id], shouldFavorite);
40+
await queryClient.cancelQueries({ queryKey });
41+
const previous = queryClient.getQueryData<Item | Sublet>(queryKey);
42+
if (previous) {
43+
queryClient.setQueryData(queryKey, { ...previous, is_favorited: shouldFavorite });
44+
}
4645
return { previous };
4746
},
4847
onError: (_error, _shouldFavorite, context) => {
49-
if (context?.previous !== undefined) {
50-
queryClient.setQueryData(["favorite", listing.id], context.previous);
48+
if (context?.previous) {
49+
queryClient.setQueryData(queryKey, context.previous);
5150
}
5251
},
5352
});
@@ -56,6 +55,12 @@ export const ListingDetail = ({ listing, initialIsFavorited }: Props) => {
5655
toggleFavoriteMutation.mutate(!isFavorited);
5756
};
5857

58+
if (!listing) return null;
59+
60+
const listingType = listing.listing_type;
61+
const priceLabel = listingType === "sublet" ? "/mo" : undefined;
62+
const listingOwnerLabel = listingType === "item" ? "Seller" : "Owner";
63+
5964
const subletCoords = listingType === "sublet" ? listing.additional_data : null;
6065
const hasLocation = subletCoords?.latitude != null && subletCoords?.longitude != null;
6166

frontend/hooks/useListings.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { useEffect } from "react";
22
import { useInView } from "react-intersection-observer";
33
import { useInfiniteQuery } from "@tanstack/react-query";
44
import { getItems, getSublets } from "@/lib/actions";
5-
import { ListingTypes, ListingDataMap, PaginatedResponse, Listing } from "@/lib/types";
5+
import { queryKeys } from "@/lib/queryKeys";
6+
import { ListingTypes, PaginatedResponse, Listing } from "@/lib/types";
67
import { useFilters } from "@/providers/FiltersProvider";
78

89
const LISTING_FETCHERS = {
@@ -12,30 +13,22 @@ const LISTING_FETCHERS = {
1213

1314
export type UseListingsParams<T extends ListingTypes> = {
1415
type: T;
15-
listings: PaginatedResponse<ListingDataMap[T]>;
1616
};
1717

18-
export function useListings<T extends ListingTypes>({ type, listings }: UseListingsParams<T>) {
18+
export function useListings<T extends ListingTypes>({ type }: UseListingsParams<T>) {
1919
const { ref, inView } = useInView();
2020
const { debouncedFilters } = useFilters();
2121

2222
const filters = debouncedFilters[type];
23-
const queryKey = [type, "list", filters];
2423

2524
const queryFn = ({ pageParam = 1 }: { pageParam: unknown }) =>
2625
LISTING_FETCHERS[type]({ pageParam, ...filters });
2726

28-
const hasActiveFilters = Object.values(filters).some((value) => {
29-
if (typeof value === "string") return value.trim() !== "";
30-
return value !== undefined;
31-
});
32-
3327
const { data, error, fetchNextPage, isFetchingNextPage, hasNextPage } = useInfiniteQuery<
3428
PaginatedResponse<Listing>
3529
>({
36-
queryKey,
30+
queryKey: queryKeys.listings(type, filters),
3731
queryFn,
38-
initialData: hasActiveFilters ? undefined : { pages: [listings], pageParams: [1] },
3932
placeholderData: (previousData) => previousData,
4033
initialPageParam: 1,
4134
getNextPageParam(lastPage, allPages) {

frontend/lib/actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export async function getSublets({
182182
// ------------------------------------------------------------
183183
// single listing (items or sublets)
184184
// ------------------------------------------------------------
185-
async function getListing(id: string) {
185+
export async function getListing(id: string) {
186186
return await serverFetch<Item | Sublet>(`/market/listings/${id}/`);
187187
}
188188

frontend/lib/queryKeys.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { DEFAULT_FILTERS } from "@/lib/constants";
2+
import { ListingFiltersMap, ListingTypes } from "@/lib/types";
3+
4+
export const queryKeys = {
5+
listings: <T extends ListingTypes>(type: T, filters?: ListingFiltersMap[T]) => [
6+
type,
7+
"list",
8+
filters ?? DEFAULT_FILTERS[type],
9+
],
10+
listing: (id: number | string) => ["listing", String(id)],
11+
};

0 commit comments

Comments
 (0)