refactor(storefront): extract the catalogue machine from App.tsx as useCatalogue (#98)
App.tsx was 361 lines, and roughly sixty of them were one cohesive concern with nothing to do with laying out a page: fetching the catalogue for the current filters, debouncing it, and negotiating with the session before it could ask. Six pieces of state, four callbacks and two effects, sharing scope with the header, footer, filter drawer and auth modal that make up the rest of the file. This is the same shape #81 dealt with once. That change took out the rendering half by extracting Catalogue and brought the file under the cognitive-complexity limit. The state half stayed. App keeps the URL as the source of filter truth, because that genuinely belongs to the page: a reload, a shared link and the back button all have to restore the same view. What moves is the request, the debounce, and the auth negotiation — and that last one is the reason this is worth doing. The rule the comments explain at length, that firing before the session resolves would 401 and show an outage banner to someone who is in fact signed in, now has somewhere to live rather than being a pair of derived booleans in a page component. The hook decides when the favorites filter needs a session; the page decides what to do about it, through an onAuthRequired callback, because the prompt is a modal the page owns. The serialise-then-reparse of the filters is kept, with the reason written down rather than left to be rediscovered. Depending on a string is what keeps `load` referentially stable while the filters are value-equal, and `load` is what the debounce effect depends on — depending on the object would give a new `load` every render, restart the debounce each time, and fire a request per keystroke. The alternatives considered were a ref written during render and threading the key through the page, and both are worse than one honest comment. filterKey is returned rather than recomputed by the caller: the page needs exactly that value to reset the catalogue's error boundary, so a crashed grid gets another chance when the filters change. App.tsx is 300 lines. No behaviour change is intended, so the bar is the end-to-end suite unchanged — the storefront listing, the filters, the favorites-requires-sign-in prompt, the failure banner and the boundary reset all pass. Also removes what the extraction left dead in App.tsx: the useEffect import, the debounce constant, and `authLoading`, which existed only to decide whether to fire the request. Refs #98
This commit is contained in:
+8
-69
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import Layout from 'antd/es/layout';
|
||||
import Typography from 'antd/es/typography';
|
||||
import Switch from 'antd/es/switch';
|
||||
@@ -13,7 +13,8 @@ import Alert from 'antd/es/alert';
|
||||
import Segmented from 'antd/es/segmented';
|
||||
import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons';
|
||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api';
|
||||
import { Item } from './api';
|
||||
import { useCatalogue } from './useCatalogue';
|
||||
import ItemCard from './components/ItemCard';
|
||||
import BrandMark from './components/BrandMark';
|
||||
import FilterDrawer from './components/FilterDrawer';
|
||||
@@ -39,10 +40,6 @@ import DevThrow from './components/DevThrow';
|
||||
const { Header, Content, Footer } = Layout;
|
||||
const { Title } = Typography;
|
||||
|
||||
// Dragging the price slider fires a change per pixel; without this every one
|
||||
// would become its own request.
|
||||
const FILTER_DEBOUNCE_MS = 250;
|
||||
|
||||
interface CatalogueProps {
|
||||
failed: boolean;
|
||||
loading: boolean;
|
||||
@@ -116,29 +113,22 @@ function Catalogue({
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [options, setOptions] = useState<FilterOptions | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const { mode, toggle } = useThemeMode();
|
||||
const { customer, loading: authLoading } = useCustomerAuth();
|
||||
const { customer } = useCustomerAuth();
|
||||
const { items: cartItems } = useCart();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
// The URL is the single source of truth for filter state, so a reload, a
|
||||
// shared link, and the back button all restore the same view.
|
||||
const filters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams]);
|
||||
const filterKey = filtersToSearchParams(filters).toString();
|
||||
const openAuthModal = useCallback(() => setAuthModalOpen(true), []);
|
||||
|
||||
// "Only my favorites" needs to know who is asking. Until the session has
|
||||
// resolved we hold rather than guess: firing the request early would 401 and
|
||||
// show the outage banner to someone who is in fact signed in.
|
||||
const awaitingAuth = filters.favoritesOnly && authLoading;
|
||||
const needsFavoritesAuth = filters.favoritesOnly && !authLoading && !customer;
|
||||
const { items, loading, failed, options, needsFavoritesAuth, reload, retry, filterKey } =
|
||||
useCatalogue(filters, openAuthModal);
|
||||
|
||||
const applyFilters = useCallback(
|
||||
(next: ItemFilters) => {
|
||||
@@ -153,57 +143,6 @@ export default function App() {
|
||||
setSearchParams(new URLSearchParams(), { replace: true });
|
||||
}, [setSearchParams]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
return fetchItems(filtersFromSearchParams(new URLSearchParams(filterKey)))
|
||||
.then((loaded) => {
|
||||
setItems(loaded);
|
||||
setFailed(false);
|
||||
})
|
||||
// A failed request must never fall through to the empty state: telling a
|
||||
// customer "no items yet" when the server is broken hides the outage and
|
||||
// reads as an empty shop.
|
||||
.catch(() => setFailed(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [filterKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (awaitingAuth) {
|
||||
setLoading(true);
|
||||
return;
|
||||
}
|
||||
// Prompt instead of requesting. The server would answer 401, and rendering
|
||||
// that as "no items match these filters" would tell a signed-out visitor
|
||||
// they have no favorites rather than that we do not know who they are.
|
||||
if (needsFavoritesAuth) {
|
||||
setItems([]);
|
||||
setFailed(false);
|
||||
setLoading(false);
|
||||
setAuthModalOpen(true);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const timer = setTimeout(() => void load(), FILTER_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [load, awaitingAuth, needsFavoritesAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFilterOptions().then(setOptions).catch(() => setOptions(null));
|
||||
}, []);
|
||||
|
||||
// Adding to cart flips an item to reserved, and the filter options' price
|
||||
// bounds shift as inventory changes.
|
||||
const reload = useCallback(() => {
|
||||
void load();
|
||||
fetchFilterOptions().then(setOptions).catch(() => undefined);
|
||||
}, [load]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
setLoading(true);
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const openAuthModal = useCallback(() => setAuthModalOpen(true), []);
|
||||
|
||||
const activeCount = activeFilterCount(filters);
|
||||
|
||||
return (
|
||||
@@ -327,7 +266,7 @@ export default function App() {
|
||||
items={items}
|
||||
filters={filters}
|
||||
needsFavoritesAuth={needsFavoritesAuth}
|
||||
onRetry={handleRetry}
|
||||
onRetry={retry}
|
||||
onSignIn={openAuthModal}
|
||||
onClearFilters={clearFilters}
|
||||
onChanged={reload}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api';
|
||||
import { ItemFilters, filtersFromSearchParams, filtersToSearchParams } from './filters';
|
||||
import { useCustomerAuth } from './customer/CustomerAuthContext';
|
||||
|
||||
/**
|
||||
* Fetching the catalogue for a set of filters.
|
||||
*
|
||||
* This was sixty lines sharing scope with the page that renders a header, a
|
||||
* footer, a filter drawer and an auth modal — six pieces of state, four
|
||||
* callbacks and two effects, none of which are about laying anything out. #81
|
||||
* took the rendering half out by extracting `Catalogue`; this is the state half.
|
||||
*
|
||||
* The page keeps the URL as the source of filter truth, because that genuinely
|
||||
* belongs to it: a reload, a shared link and the back button all have to restore
|
||||
* the same view. What moves here is the request, the debounce, and the session
|
||||
* negotiation the request depends on.
|
||||
*/
|
||||
|
||||
// Dragging the price slider fires a change per pixel; without this every one
|
||||
// would become its own request.
|
||||
const FILTER_DEBOUNCE_MS = 250;
|
||||
|
||||
export interface Catalogue {
|
||||
items: Item[];
|
||||
loading: boolean;
|
||||
/** A failed request, which must never be rendered as an empty catalogue. */
|
||||
failed: boolean;
|
||||
options: FilterOptions | null;
|
||||
/** The favorites filter is set, the session has resolved, and nobody is signed in. */
|
||||
needsFavoritesAuth: boolean;
|
||||
/** Refetch both the items and the filter bounds — after a cart change, say. */
|
||||
reload: () => void;
|
||||
retry: () => void;
|
||||
/**
|
||||
* A stable identity for the current filter set.
|
||||
*
|
||||
* Returned rather than recomputed by the caller because it is already derived
|
||||
* here, and the page needs exactly this value: it resets the catalogue's error
|
||||
* boundary, so changing the filters gives a crashed grid another chance
|
||||
* without a reload.
|
||||
*/
|
||||
filterKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param onAuthRequired Called when the favorites filter is asked for by someone
|
||||
* signed out. The hook decides the moment; the page decides what to do about it,
|
||||
* because the prompt is a modal the page owns. Must be stable, or it re-triggers
|
||||
* the effect below on every render.
|
||||
*/
|
||||
export function useCatalogue(filters: ItemFilters, onAuthRequired: () => void): Catalogue {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [options, setOptions] = useState<FilterOptions | null>(null);
|
||||
|
||||
const { customer, loading: authLoading } = useCustomerAuth();
|
||||
|
||||
// "Only my favorites" needs to know who is asking. Until the session has
|
||||
// resolved we hold rather than guess: firing the request early would 401 and
|
||||
// show the outage banner to someone who is in fact signed in.
|
||||
const awaitingAuth = filters.favoritesOnly && authLoading;
|
||||
const needsFavoritesAuth = filters.favoritesOnly && !authLoading && !customer;
|
||||
|
||||
// Serialised, then parsed back inside `load`. The round trip looks redundant
|
||||
// and is not: depending on a *string* means `load` stays referentially stable
|
||||
// while the filters are value-equal, and `load` is what the debounce effect
|
||||
// below depends on. Depending on the object instead would give a new `load`
|
||||
// every render, restarting the debounce each time and firing a request per
|
||||
// keystroke.
|
||||
const filterKey = filtersToSearchParams(filters).toString();
|
||||
|
||||
const load = useCallback(() => {
|
||||
return fetchItems(filtersFromSearchParams(new URLSearchParams(filterKey)))
|
||||
.then((loaded) => {
|
||||
setItems(loaded);
|
||||
setFailed(false);
|
||||
})
|
||||
// A failed request must never fall through to the empty state: telling a
|
||||
// customer "no items yet" when the server is broken hides the outage and
|
||||
// reads as an empty shop.
|
||||
.catch(() => setFailed(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [filterKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (awaitingAuth) {
|
||||
setLoading(true);
|
||||
return;
|
||||
}
|
||||
// Prompt instead of requesting. The server would answer 401, and rendering
|
||||
// that as "no items match these filters" would tell a signed-out visitor
|
||||
// they have no favorites rather than that we do not know who they are.
|
||||
if (needsFavoritesAuth) {
|
||||
setItems([]);
|
||||
setFailed(false);
|
||||
setLoading(false);
|
||||
onAuthRequired();
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const timer = setTimeout(() => void load(), FILTER_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [load, awaitingAuth, needsFavoritesAuth, onAuthRequired]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFilterOptions().then(setOptions).catch(() => setOptions(null));
|
||||
}, []);
|
||||
|
||||
// Adding to cart flips an item to reserved, and the filter options' price
|
||||
// bounds shift as inventory changes.
|
||||
const reload = useCallback(() => {
|
||||
void load();
|
||||
fetchFilterOptions().then(setOptions).catch(() => undefined);
|
||||
}, [load]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
setLoading(true);
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return { items, loading, failed, options, needsFavoritesAuth, reload, retry, filterKey };
|
||||
}
|
||||
Reference in New Issue
Block a user