I'm writing an online store that has a catalog and also an input for query input. By clicking on Enter (on any page except Catalog) we get to the Catalog itself, and the query goes into the search parameters.
The essence of the problem: if I am in the Catalog and enter a new query in the input, the url is updated, but the Catalog does not see the changes, and therefore "does not see" that the query has been updated. Could you please tell me how to do this?
My code:
Header.tsx
import Logo from '@/components/shared/Logo/Logo';
import Quantity from '@/components/shared/Quantity/Quantity';
import { toast } from '@/components/ui/use-toast';
import { PagePaths } from '@/enum/PagePaths';
import { useActions } from '@/hooks/general/useActions';
import { useTypedSelector } from '@/hooks/general/useTypedSelector';
import { getBasketQuantitySelector } from '@/store/slices/basket/basket.selectors';
import { getFavouriteQuantitySelector } from '@/store/slices/favourites/favourites.selectors';
import { getUserInfoSelector } from '@/store/slices/user/user.selectors';
import { Link, useNavigate } from 'react-router-dom';
import classes from './Header.module.scss';
import SearchInput from "./components/SearchInput";
function Header() {
const { isAuth, isAdmin } = useTypedSelector(getUserInfoSelector);
const { logout } = useActions();
const favouritesQuantity = useTypedSelector(getFavouriteQuantitySelector);
const basketItemsQuantity = useTypedSelector(getBasketQuantitySelector);
const navigate = useNavigate();
async function handleButton() {
if (!isAuth) return navigate(PagePaths.AUTHENTICATION.LOGIN);
logout();
toast({ title: `Success!` });
}
return (
<header className={classes.header}>
<Logo />
<SearchInput placeholder="Search..." />
<div className={classes.header__right}>
<Link
to="/favourites"
className={[classes.header__btn, classes.header__favourite].join(' ')}
>
<Quantity quantity={favouritesQuantity} />
</Link>
<Link
to={PagePaths.CART}
className={[classes.header__btn, classes.header__cart].join(' ')}
>
<Quantity quantity={basketItemsQuantity} />
</Link>
<button onClick={handleButton}>{isAuth ? 'Logout' : 'Sign in'}</button>
{isAdmin && <Link to={PagePaths.ADMIN.HOME}>Admin</Link>}
</div>
</header>
);
}
export default Header;
SearchInput.tsx
import { PagePaths } from '@/enum/PagePaths';
import { useMySearchParams } from '@/hooks/general/useMySearchParams';
import { InputHTMLAttributes, KeyboardEvent } from 'react';
import { useNavigate } from 'react-router-dom';
interface SearchInputProps extends InputHTMLAttributes<HTMLInputElement> {}
function SearchInput({ placeholder }: SearchInputProps) {
const navigate = useNavigate();
const searchTerm = useMySearchParams('searchTerm');
function handleEnterDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key !== 'Enter') return;
navigate(`${PagePaths.CATALOG}?searchTerm=${e.currentTarget.value}`);
}
return (
<form
className="max-w-[400px] w-full"
role="search"
onSubmit={(e) => e.preventDefault()}
>
<input
className="w-full py-1 px-3 rounded-md"
type="search"
placeholder={placeholder}
defaultValue={searchTerm || ''}
aria-label="Search"
onKeyDown={handleEnterDown}
/>
</form>
);
}
export default SearchInput;
Catalog.tsx
import CardsContainer from '@/components/shared/CardsContainer/CardsContainer';
import {
Sheet,
SheetHeader,
SheetTitle,
SheetTrigger,
SheetContent as SheetWrapper,
} from '@/components/ui/sheet';
import { useProducts } from '@/hooks/features/useProducts/useProducts';
import { Settings2 as FiltersIcon } from 'lucide-react';
import CatalogPagination from './components/CatalogPagination';
import FiltersLayout from './components/FiltersLayout/FiltersLayout';
import { useFilters } from './useFilters';
function Catalog() {
const { filters, changeFilters, page, changePage, searchTerm } = useFilters();
const { data } = useProducts({ filters, page, searchTerm });
const isPaginationNeeded = data && data.totalPages > 1;
const isProductsFound = data?.products && data?.products.length !== 0;
return (
<section className="rows-container">
<Sheet>
<div className="flex gap-5 items-center mb-3">
<strong className="subtitle">Catalog</strong>
<SheetTrigger className="link flex items-center gap-2 py-1 px-2 rounded-md">
<FiltersIcon />
Show filters
</SheetTrigger>
</div>
<SheetWrapper>
<SheetHeader className="mb-7">
<SheetTitle className="flex items-center gap-2">
<FiltersIcon /> Filters
</SheetTitle>
</SheetHeader>
{/* Sheet Content */}
<div>
<FiltersLayout filters={filters} changeFilters={changeFilters} />
</div>
{/* Sheet Content END */}
</SheetWrapper>
</Sheet>
{isProductsFound ? (
<CardsContainer products={data.products} />
) : (
<p>Empty...</p>
)}
{isPaginationNeeded && (
<CatalogPagination
currentPage={page}
totalPages={data.totalPages}
setCurrentPage={changePage}
/>
)}
</section>
);
}
export default Catalog;
useFilters.tsx
import { IProductFitlers } from '@/hooks/features/useProducts/filters.types';
import { ProductQueryData } from '@/services/product/product.types';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { configurateUrlParams, parseParamsFromUrl } from './catalog.helpers';
export const useFilters = () => {
const [_, setSearchParams] = useSearchParams();
const [page, setPage] = useState(1);
const [filters, setFilters] = useState<IProductFitlers>({});
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
const params = window.location.search.slice(1);
if (!params) return;
const queryData = parseParamsFromUrl<ProductQueryData>(params, {
tryToParseNumbersInArray: true,
tryToParseNumbersInObject: true,
tryToParsePrimitive: true,
});
setFilters(queryData?.filters || {});
setPage(queryData?.page ? +queryData.page : 1);
setSearchTerm(queryData.searchTerm || '');
}, []);
function changeFilters(newFilters: IProductFitlers) {
const isReset = Object.keys(newFilters).length === 0;
let objectToServer: ProductQueryData = { page: 1 };
if (!isReset) {
objectToServer = {
...objectToServer,
filters: newFilters,
searchTerm,
};
} else setSearchTerm('');
setSearchParams(configurateUrlParams(objectToServer));
setFilters(newFilters);
setPage(1);
}
function changePage(newPage: number) {
setSearchParams(
configurateUrlParams({ filters, page: newPage, searchTerm })
);
setPage(newPage);
}
return { filters, changeFilters, page, changePage, searchTerm };
};
The issue is that you are duplicating the URL search params into local React state which is a bit of a React anti-pattern since you need to keep them synchronized. The various components should just read from and update the search params directly instead.
Just be aware that anytime you have a useState
|useEffect
coupling that you are likely implementing this anti-pattern. Compute and use the derived "state" directly or use the useMemo
hook to memoize and provide a stable reference value.
SearchInput
Update to use the useSearchParams
hook to read and set the "searchTerm"
query parameter.
import { useSearchParams } from 'react-router-dom';
function SearchInput({ placeholder }: SearchInputProps) {
const [searchParams, setSearchParams] = useSearchParams();
const searchTerm = searchParams.get('searchTerm');
function handleEnterDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key !== 'Enter') return;
setSearchParams(searchParams => {
searchParams.set("searchTerm", e.currentTarget.value);
return searchParams;
});
}
return (
<form
className="max-w-[400px] w-full"
role="search"
onSubmit={(e) => e.preventDefault()}
>
<input
className="w-full py-1 px-3 rounded-md"
type="search"
placeholder={placeholder}
defaultValue={searchTerm || ''}
aria-label="Search"
onKeyDown={handleEnterDown}
/>
</form>
);
}
useFilters
Update the hook to read from, and manage the search params instead of local state that needs to be kept synchronized.
import { IProductFitlers } from '@/hooks/features/useProducts/filters.types';
import { useSearchParams } from 'react-router-dom';
import { configurateUrlParams } from './catalog.helpers';
export const useFilters = () => {
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get("page") || 1);
const filters = searchParams.get("filters") || {};
const searchTerm = searchParams.get("searchTerm") || "";
function changeFilters(newFilters: IProductFitlers) {
const isReset = Object.keys(newFilters).length === 0;
if (!isReset) {
setSearchParams(searchParams => {
searchParams.set("page", 1);
// I don't know what `configurateUrlParams` does so I'm just assuming
// it does some string serialization of the filters array/object
// to be parameterized for the URL
searchParams.set(
"filters",
configurateUrlParams({ filters: newFilters }).filters
);
return searchParams;
});
} else {
setSearchParams(searchParams => {
searchParams.delete("searchTerm");
searchParams.set("page", 1);
return searchParams;
});
}
}
function changePage(newPage: number) {
setSearchParams(searchParams => {
searchParams.set("page", newPage);
return searchParams;
});
}
return { filters, changeFilters, page, changePage, searchTerm };
};