Search code examples
reactjsreact-router-dom

Problem with page refresh react-router-dom


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 };
};

Solution

  • 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 };
    };