From a81b7052603b47ac27db7995dbe2db362f9161b7 Mon Sep 17 00:00:00 2001 From: James Doh Date: Fri, 3 Apr 2026 11:43:52 -0400 Subject: [PATCH] Refactor to React Query hydration pattern and fix favorites bug The favorite toggle on listing detail pages was broken because the backend retrieve view did not pass request context to the serializer, so is_favorited always returned false. Additionally, the frontend stored favorite state in a separate query with staleTime: Infinity that could never self-correct. This adopts React Query's hydration pattern (prefetchQuery + dehydrate + HydrationBoundary) for grid and detail pages, replacing the initialData-from-props approach. The favorite toggle now uses optimistic updates on the listing cache entry itself. Co-Authored-By: Claude Opus 4.6 --- backend/market/views.py | 2 +- frontend/app/items/[id]/page.tsx | 13 +++- frontend/app/page.tsx | 20 +++++- frontend/app/sublets/[id]/page.tsx | 13 +++- frontend/app/sublets/page.tsx | 19 +++++- frontend/components/listings/ListingsGrid.tsx | 21 +++---- .../listings/detail/ListingDetail.tsx | 61 ++++++++++--------- .../components/listings/detail/SubletMap.tsx | 5 +- frontend/hooks/useListings.ts | 15 ++--- frontend/lib/actions.ts | 2 +- frontend/lib/constants.ts | 1 - frontend/lib/queryKeys.ts | 11 ++++ 12 files changed, 115 insertions(+), 68 deletions(-) create mode 100644 frontend/lib/queryKeys.ts diff --git a/backend/market/views.py b/backend/market/views.py index c85974c..d61d749 100644 --- a/backend/market/views.py +++ b/backend/market/views.py @@ -189,7 +189,7 @@ def retrieve(self, request, *args, **kwargs): serializer_class = ListingSerializer else: serializer_class = ListingSerializerPublic - serializer = serializer_class(instance) + serializer = serializer_class(instance, context={"request": request}) return Response(serializer.data) diff --git a/frontend/app/items/[id]/page.tsx b/frontend/app/items/[id]/page.tsx index bbf5b40..464b8ac 100644 --- a/frontend/app/items/[id]/page.tsx +++ b/frontend/app/items/[id]/page.tsx @@ -1,9 +1,20 @@ +import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { ListingDetail } from "@/components/listings/detail/ListingDetail"; import { getListingOrNotFound } from "@/lib/actions"; +import { queryKeys } from "@/lib/queryKeys"; export default async function ItemPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const item = await getListingOrNotFound(id); - return ; + // seed the cache with the already-fetched listing (no additional fetch). + // We use setQueryData instead of prefetchQuery to preserve the notFound() throw above. + const queryClient = new QueryClient(); + queryClient.setQueryData(queryKeys.listing(item.id), item); + + return ( + + + + ); } diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 1c448a5..65f27ef 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,16 +1,32 @@ +import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { getCurrentUser, getItems } from "@/lib/actions"; +import { queryKeys } from "@/lib/queryKeys"; import { PageHeader } from "@/components/common/PageHeader"; import { ItemFilters } from "@/components/filters/ItemFilters"; import { ListingsGrid } from "@/components/listings/ListingsGrid"; export default async function ItemsPage() { - const [items, currentUser] = await Promise.all([getItems({ pageParam: 1 }), getCurrentUser()]); + const queryClient = new QueryClient(); + + const [, currentUser] = await Promise.all([ + queryClient.fetchInfiniteQuery({ + queryKey: queryKeys.listings("items"), + queryFn: () => getItems({ pageParam: 1 }), + initialPageParam: 1, + getNextPageParam: (lastPage, allPages) => + lastPage.results.length > 0 ? allPages.length + 1 : undefined, + pages: 1, + }), + getCurrentUser(), + ]); return (
- + + +
); } diff --git a/frontend/app/sublets/[id]/page.tsx b/frontend/app/sublets/[id]/page.tsx index 2f71c36..f0d101e 100644 --- a/frontend/app/sublets/[id]/page.tsx +++ b/frontend/app/sublets/[id]/page.tsx @@ -1,9 +1,20 @@ +import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { ListingDetail } from "@/components/listings/detail/ListingDetail"; import { getListingOrNotFound } from "@/lib/actions"; +import { queryKeys } from "@/lib/queryKeys"; export default async function SubletPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const sublet = await getListingOrNotFound(id); - return ; + // seed the cache with the already-fetched listing (no additional fetch). + // We use setQueryData instead of prefetchQuery to preserve the notFound() throw above. + const queryClient = new QueryClient(); + queryClient.setQueryData(queryKeys.listing(sublet.id), sublet); + + return ( + + + + ); } diff --git a/frontend/app/sublets/page.tsx b/frontend/app/sublets/page.tsx index 30c4966..c36ae5f 100644 --- a/frontend/app/sublets/page.tsx +++ b/frontend/app/sublets/page.tsx @@ -1,11 +1,22 @@ +import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { getCurrentUser, getSublets } from "@/lib/actions"; +import { queryKeys } from "@/lib/queryKeys"; import { PageHeader } from "@/components/common/PageHeader"; import { SubletFilters } from "@/components/filters/SubletFilters"; import { ListingsGrid } from "@/components/listings/ListingsGrid"; export default async function SubletsPage() { - const [sublets, currentUser] = await Promise.all([ - getSublets({ pageParam: 1 }), + const queryClient = new QueryClient(); + + const [, currentUser] = await Promise.all([ + queryClient.fetchInfiniteQuery({ + queryKey: queryKeys.listings("sublets"), + queryFn: () => getSublets({ pageParam: 1 }), + initialPageParam: 1, + getNextPageParam: (lastPage, allPages) => + lastPage.results.length > 0 ? allPages.length + 1 : undefined, + pages: 1, + }), getCurrentUser(), ]); @@ -13,7 +24,9 @@ export default async function SubletsPage() {
- + + +
); } diff --git a/frontend/components/listings/ListingsGrid.tsx b/frontend/components/listings/ListingsGrid.tsx index 07ae27e..122b166 100644 --- a/frontend/components/listings/ListingsGrid.tsx +++ b/frontend/components/listings/ListingsGrid.tsx @@ -5,22 +5,15 @@ import { Spinner } from "@/components/ui/spinner"; import { ListingsCard } from "@/components/listings/ListingsCard"; import { NoListingsFound } from "@/components/listings/NoListingsFound"; import { useListings } from "@/hooks/useListings"; -import { Item, PaginatedResponse, Sublet, User } from "@/lib/types"; +import { ListingTypes, User } from "@/lib/types"; -type Props = - | { - type: "items"; - listings: PaginatedResponse; - currentUser: User; - } - | { - type: "sublets"; - listings: PaginatedResponse; - currentUser: User; - }; +type Props = { + type: ListingTypes; + currentUser: User; +}; -export const ListingsGrid = ({ type, listings, currentUser }: Props) => { - const { data, error, isFetchingNextPage, hasNextPage, ref } = useListings({ type, listings }); +export const ListingsGrid = ({ type, currentUser }: Props) => { + const { data, error, isFetchingNextPage, hasNextPage, ref } = useListings({ type }); const totalResults = data?.pages.reduce((acc, page) => acc + page.results.length, 0) || 0; const isEmpty = totalResults === 0; diff --git a/frontend/components/listings/detail/ListingDetail.tsx b/frontend/components/listings/detail/ListingDetail.tsx index fb61972..358023e 100644 --- a/frontend/components/listings/detail/ListingDetail.tsx +++ b/frontend/components/listings/detail/ListingDetail.tsx @@ -1,7 +1,8 @@ "use client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { addToUsersFavorites, deleteFromUsersFavorites } from "@/lib/actions"; +import { addToUsersFavorites, deleteFromUsersFavorites, getListing } from "@/lib/actions"; +import { queryKeys } from "@/lib/queryKeys"; import { Heart, Share } from "lucide-react"; import { Item, Sublet } from "@/lib/types"; import { ListingActions } from "@/components/listings/detail/ListingActions"; @@ -12,42 +13,40 @@ import { BackButton } from "@/components/listings/detail/BackButton"; import { SubletMap } from "@/components/listings/detail/SubletMap"; interface Props { - listing: Item | Sublet; - initialIsFavorited: boolean; + listingId: number; } -export const ListingDetail = ({ listing, initialIsFavorited }: Props) => { - const listingType = listing.listing_type; - const priceLabel = listingType === "sublet" ? "/mo" : undefined; - const listingOwnerLabel = listingType === "item" ? "Seller" : "Owner"; +export const ListingDetail = ({ listingId }: Props) => { const queryClient = useQueryClient(); - const favoritesQuery = useQuery({ - queryKey: ["favorite", listing.id], - queryFn: async () => initialIsFavorited, - initialData: initialIsFavorited, - staleTime: Infinity, + const queryKey = queryKeys.listing(listingId); + + const { data: listing } = useQuery({ + queryKey, + queryFn: () => getListing(String(listingId)), }); - const isFavorited = favoritesQuery.data ?? false; + const isFavorited = listing?.is_favorited ?? false; const toggleFavoriteMutation = useMutation({ meta: { suppressErrorToast: true }, // since it's noisy to show error toast on top of optimistic update mutationFn: async (shouldFavorite: boolean) => { if (shouldFavorite) { - await addToUsersFavorites(listing.id); + await addToUsersFavorites(listingId); } else { - await deleteFromUsersFavorites(listing.id); + await deleteFromUsersFavorites(listingId); } }, onMutate: async (shouldFavorite: boolean) => { - await queryClient.cancelQueries({ queryKey: ["favorite", listing.id] }); - const previous = queryClient.getQueryData(["favorite", listing.id]); - queryClient.setQueryData(["favorite", listing.id], shouldFavorite); + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + if (previous) { + queryClient.setQueryData(queryKey, { ...previous, is_favorited: shouldFavorite }); + } return { previous }; }, onError: (_error, _shouldFavorite, context) => { - if (context?.previous !== undefined) { - queryClient.setQueryData(["favorite", listing.id], context.previous); + if (context?.previous) { + queryClient.setQueryData(queryKey, context.previous); } }, }); @@ -56,10 +55,14 @@ export const ListingDetail = ({ listing, initialIsFavorited }: Props) => { toggleFavoriteMutation.mutate(!isFavorited); }; - const subletCoords = - listingType === "sublet" ? listing.additional_data : null; - const hasLocation = - subletCoords?.latitude != null && subletCoords?.longitude != null; + if (!listing) return null; + + const listingType = listing.listing_type; + const priceLabel = listingType === "sublet" ? "/mo" : undefined; + const listingOwnerLabel = listingType === "item" ? "Seller" : "Owner"; + + const subletCoords = listingType === "sublet" ? listing.additional_data : null; + const hasLocation = subletCoords?.latitude != null && subletCoords?.longitude != null; return (
@@ -94,13 +97,11 @@ export const ListingDetail = ({ listing, initialIsFavorited }: Props) => {

{"Where you'll be living"}

- Approximate location shown. The exact location will be shared once you connect with the owner. + Approximate location shown. The exact location will be shared once you connect + with the owner.

-
- +
+ )} - import("@/components/listings/detail/SubletMapContent").then((m) => m.SubletMapContent), - { ssr: false }, + () => import("@/components/listings/detail/SubletMapContent").then((m) => m.SubletMapContent), + { ssr: false } ); export const SubletMap = ({ latitude, longitude }: Props) => { diff --git a/frontend/hooks/useListings.ts b/frontend/hooks/useListings.ts index e6c6e93..2bdb960 100644 --- a/frontend/hooks/useListings.ts +++ b/frontend/hooks/useListings.ts @@ -2,7 +2,8 @@ import { useEffect } from "react"; import { useInView } from "react-intersection-observer"; import { useInfiniteQuery } from "@tanstack/react-query"; import { getItems, getSublets } from "@/lib/actions"; -import { ListingTypes, ListingDataMap, PaginatedResponse, Listing } from "@/lib/types"; +import { queryKeys } from "@/lib/queryKeys"; +import { ListingTypes, PaginatedResponse, Listing } from "@/lib/types"; import { useFilters } from "@/providers/FiltersProvider"; const LISTING_FETCHERS = { @@ -12,30 +13,22 @@ const LISTING_FETCHERS = { export type UseListingsParams = { type: T; - listings: PaginatedResponse; }; -export function useListings({ type, listings }: UseListingsParams) { +export function useListings({ type }: UseListingsParams) { const { ref, inView } = useInView(); const { debouncedFilters } = useFilters(); const filters = debouncedFilters[type]; - const queryKey = [type, "list", filters]; const queryFn = ({ pageParam = 1 }: { pageParam: unknown }) => LISTING_FETCHERS[type]({ pageParam, ...filters }); - const hasActiveFilters = Object.values(filters).some((value) => { - if (typeof value === "string") return value.trim() !== ""; - return value !== undefined; - }); - const { data, error, fetchNextPage, isFetchingNextPage, hasNextPage } = useInfiniteQuery< PaginatedResponse >({ - queryKey, + queryKey: queryKeys.listings(type, filters), queryFn, - initialData: hasActiveFilters ? undefined : { pages: [listings], pageParams: [1] }, placeholderData: (previousData) => previousData, initialPageParam: 1, getNextPageParam(lastPage, allPages) { diff --git a/frontend/lib/actions.ts b/frontend/lib/actions.ts index 7061b7e..439c970 100644 --- a/frontend/lib/actions.ts +++ b/frontend/lib/actions.ts @@ -182,7 +182,7 @@ export async function getSublets({ // ------------------------------------------------------------ // single listing (items or sublets) // ------------------------------------------------------------ -async function getListing(id: string) { +export async function getListing(id: string) { return await serverFetch(`/market/listings/${id}/`); } diff --git a/frontend/lib/constants.ts b/frontend/lib/constants.ts index 6c1e00a..9d95517 100644 --- a/frontend/lib/constants.ts +++ b/frontend/lib/constants.ts @@ -11,7 +11,6 @@ export const BASE_URL = export const API_BASE_URL = process.env.NODE_ENV === "production" ? "REPLACE WITH PROD API URL" : "http://backend:8000"; // can't be localhost because server fetch happens in container - export const PLATFORM_URL = process.env.PLATFORM_URL; export const CLIENT_ID = process.env.CLIENT_ID; diff --git a/frontend/lib/queryKeys.ts b/frontend/lib/queryKeys.ts new file mode 100644 index 0000000..cf971af --- /dev/null +++ b/frontend/lib/queryKeys.ts @@ -0,0 +1,11 @@ +import { DEFAULT_FILTERS } from "@/lib/constants"; +import { ListingFiltersMap, ListingTypes } from "@/lib/types"; + +export const queryKeys = { + listings: (type: T, filters?: ListingFiltersMap[T]) => [ + type, + "list", + filters ?? DEFAULT_FILTERS[type], + ], + listing: (id: number | string) => ["listing", String(id)], +};