Что такое useReducer, чего не может useState?

У меня есть набор сведений о продукте, хранящийся в Firestore. Я создал специальный крючок для получения сведений о продукте. Категории продуктов должны отображаться в соответствующем параметре URL. Когда я добавляю дополнительные категории продуктов для отображения в других URL-адресах, карточки продуктов не отображаются при монтировании. Когда я пытаюсь отредактировать компонент productcard, карточка товара отображается и исчезает за считанные секунды. Данные извлекаются правильно и отображаются правильно, но отображаемая карточка продукта исчезает через несколько секунд. Я не могу понять, в чем проблема, поскольку в консоли нет ошибок. Может ли это быть что-то связанное с государственным управлением?

Обновлено: я пробовал использовать useReducer вместо useState в пользовательском хуке, и теперь все работает отлично. Мой вопрос в том, что заставляет useReducer работать, а не useState, потому что оба этих хука делают одно и то же. Это из-за сложности структуры данных о деталях продукта?

Пользовательский хук для получения сведений о продукте с помощью useState

import { db } from "../../firebaseConfig";
import { collection, getDocs } from "firebase/firestore";
import { useState, useEffect } from "react";

interface ProductDetails {
  name: string;
  imageURLs: string[];
  rating: number;
  reviewsCount: number;
  monthlySalesCount: number;
  isBestseller: boolean;
  listPrice: number;
  discount: number;
  shippingPrice: number;
  Color: string[]; 
  productDescription: string[];
  dateFirstAvailable: Date;
}

interface Data {
  id: string;
  data: ProductDetails[];
}

const useProductDetails = () => {
  const [productDetails, setProductDetails] = useState<Data[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const fetchProductDetails = async () => {
      try {
        // Fetch all categories from the products collection.
        const productCategories = collection(db, "products");
        const productDocumentsSnapshot = await getDocs(productCategories);

        // An array to store product details
        const productsData: Data[] = [];

        // Iterate over each document in the products collection.
        for (const productDoc of productDocumentsSnapshot.docs) {
          const productId = productDoc.id;
          // For each product category, fetch all documents from the productslist subcollection.
          const productListCollection = collection(
            db,
            `products/${productId}/productslist`
          );
          const productsListSnapshot = await getDocs(productListCollection);
          // Map over the documents in productslist to extract ProductDetails data.
          const productDetails: ProductDetails[] =
            productsListSnapshot.docs.map(
              (detailDoc) => detailDoc.data() as ProductDetails
            );

          // Push an object with id and data to productsData.
          productsData.push({
            id: productId,
            data: productDetails,
          });

          // Update the productDetails state with the fetched data.
          setProductDetails(productsData);
          console.info("Updated products state:", productsData);
        }
      } catch (error) {
        console.error("Error fetching products:", error);
      } finally {
        setLoading(false);
      }
    };

    fetchProductDetails();
  }, []);

  return { loading, productDetails };
};

export default useProductDetails;

Специальный хук для получения сведений о продукте с помощью useReducer, и это сработало!

import { useReducer, useEffect } from "react";
import { db } from "../../firebaseConfig";
import { collection, getDocs } from "firebase/firestore";

interface ProductDetails {
  name: string;
  imageURLs: string[];
  rating: number;
  reviewsCount: number;
  monthlySalesCount: number;
  isBestseller: boolean;
  listPrice: number;
  discount: number;
  shippingPrice: number;
  Color: string[];
  Size?: string;
  Style?: string;
  Pattern?: string;
  Brand: string;
  earPlacement?: string;
  formFactor?: string;
  noiseControl?: string;
  impedance?: number;
  compatibleDevices?: string;
  specialFeature?: string;
  productDescription: string[];
  dateFirstAvailable: Date;
}

interface Data {
  id: string;
  data: ProductDetails[];
}

interface State {
  productDetails: Data[];
  loading: boolean;
  error: string | null;
}

type Action =
  | { type: "FETCH_PRODUCTS_REQUEST" }
  | { type: "FETCH_PRODUCTS_SUCCESS"; payload: Data[] }
  | { type: "FETCH_PRODUCTS_FAILURE"; payload: string };

// Define initial state
const initialState: State = {
  productDetails: [],
  loading: true,
  error: null,
};

// Define reducer function
function productDetailsReducer(state: State, action: Action): State {
  switch (action.type) {
    case "FETCH_PRODUCTS_REQUEST":
      return {
        ...state,
        loading: true,
        error: null,
      };
    case "FETCH_PRODUCTS_SUCCESS":
      return {
        ...state,
        loading: false,
        productDetails: action.payload,
      };
    case "FETCH_PRODUCTS_FAILURE":
      return {
        ...state,
        loading: false,
        error: action.payload,
      };
    default:
      return state;
  }
}

// Fetch products function
const fetchProductDetails = async (dispatch: React.Dispatch<Action>) => {
  dispatch({ type: "FETCH_PRODUCTS_REQUEST" });
  try {
    const productCategories = collection(db, "products");
    const productDocumentsSnapshot = await getDocs(productCategories);

    const productsData: Data[] = [];

    for (const productDoc of productDocumentsSnapshot.docs) {
      const productId = productDoc.id;
      const productListCollection = collection(
        db,
        `products/${productId}/productslist`
      );
      const productsListSnapshot = await getDocs(productListCollection);

      const productDetails = productsListSnapshot.docs.map(
        (detailDoc) => detailDoc.data() as ProductDetails
      );

      productsData.push({
        id: productId,
        data: productDetails,
      });
    }

    dispatch({ type: "FETCH_PRODUCTS_SUCCESS", payload: productsData });
  } catch (error: any) {
    dispatch({ type: "FETCH_PRODUCTS_FAILURE", payload: error.message });
  }
};

// Custom hook
const useProductDetails = () => {
  const [state, dispatch] = useReducer(productDetailsReducer, initialState);

  useEffect(() => {
    fetchProductDetails(dispatch);
  }, []);

  return {
    loading: state.loading,
    productDetails: state.productDetails,
    error: state.error,
  };
};

export default useProductDetails;

Код для отрисовки карточки товара

import StarRating from "../sidebar/StarRating";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import useProductDetails from "../../useProductDetails";
import RenderColourOptions from "../../RenderColourOptions";
import { Link } from "react-router-dom";
import useFetchCountry from "../../../useFetchCountry";

interface ProductListProps {
  id: string | undefined;
}

function ProductCard(props: ProductListProps) {
  const { loading, productDetails } = useProductDetails();
  const { country } = useFetchCountry();

  if (loading) {
    return <p>Loading...</p>;
  }

  const calculateDiscountedPrice = (
    listPrice: number,
    discount: number
  ): string => {
    const discountedPrice = (listPrice - listPrice * (discount / 100)).toFixed(
      2
    );
    return discountedPrice;
  };

  const renderFormattedPrice = (price: string | number) => {
    const priceString = typeof price === "number" ? price.toString() : price;
    const [wholeNumber, fractional] = priceString.split(".");

    return (
      <>
        <span className = "text-clamp16 ">{wholeNumber}</span>
        {fractional === "00" ? null : (
          <sup>
            <span className = "text-clamp6 align-super">{fractional}</span>
          </sup>
        )}
      </>
    );
  };

  const formatSalesCount = (num: number) => {
    if (num >= 1000) {
      const formattedSalesCount = new Intl.NumberFormat("en-US", {
        notation: "compact",
      }).format(num);
      return `${formattedSalesCount}+`;
    } else return num;
  };

  const calculateDeliveryDate = () => {
    const currentDate = new Date();
    currentDate.setDate(currentDate.getDate() + 20);

    return currentDate.toLocaleString("en-US", {
      weekday: "short",
      month: "short",
      day: "numeric",
    });
  };

  return (
    <>
      {productDetails.map((product) => {
        if (product.id === props.id) {
          return product.data.map((details) => (
            <div
              key = {product.id}
              className = "mr-[1%] mb-[1%] flex border-[1px] border-gray-100 rounded-[6px]"
            >
              <div className = "bg-gray-100 w-[25%] rounded-l-[4px]">
                <img
                  src = {details.imageURLs[0]}
                  alt = {details.name}
                  className = "mix-blend-multiply py-[15%] px-[5%]"
                  key = {details.name}
                />
              </div>
              <div className = "bg-white w-[75%] rounded-r-[4px] pl-[1.5%]">
                <Link to = "">
                  <h1 className = "text-clamp15 my-[0.75%] line-clamp-2">
                    {details.name}
                  </h1>
                </Link>
                <div className = "flex items-center -mt-[0.75%]">
                  <StarRating
                    rating = {details.rating}
                    fontSize = "clamp(0.5625rem, 0.2984rem + 1.1268vi, 1.3125rem)"
                  />
                  <KeyboardArrowDownIcon
                    style = {{
                      fontSize:
                        "clamp(0.375rem, 0.1109rem + 1.1268vi, 1.125rem)",
                    }}
                    className = "-ml-[1.75%] text-gray-400"
                  />
                  <p className = "text-clamp11 text-cyan-800 font-sans">
                    {details.reviewsCount.toLocaleString()}
                  </p>
                </div>
                <p className = "text-clamp13 text-gray-700 mb-[1%]">{`${formatSalesCount(
                  details.monthlySalesCount
                )} bought in past month`}</p>
                <div className = "flex items-baseline">
                  <p>
                    {details.discount ? (
                      <>
                        <sup>
                          <span className = "text-clamp10 align-super">$</span>
                        </sup>
                        {renderFormattedPrice(
                          calculateDiscountedPrice(
                            details.listPrice,
                            details.discount
                          )
                        )}
                      </>
                    ) : (
                      <>
                        <sup>
                          <span className = "text-clamp10 align-super">$</span>
                        </sup>
                        {renderFormattedPrice(details.listPrice)}
                      </>
                    )}
                  </p>

                  {details.discount ? (
                    <p className = " text-clamp13 text-gray-700 ml-[1%]">
                      List:
                      <span className = "line-through ml-[5%]">
                        ${details.listPrice}
                      </span>
                    </p>
                  ) : null}
                </div>
                <p className = "mt-[1%] text-clamp13">
                  Delivery{"  "}
                  <span className = "font-bold tracking-wide">
                    {calculateDeliveryDate()}
                  </span>
                </p>
                <p className = "text-clamp1 mt-[0.5%]">Ships to {country}</p>
                <button className = "bg-yellow-400 px-[1.75%] py-[0.5%] mt-[1%] rounded-[25px] text-clamp10">
                  Add to cart
                </button>
                <RenderColourOptions colors = {details.Color} />
              </div>
            </div>
          ));
        }
        return null;
      })}
    </>
  );
}

export default ProductCard;

🤔 А знаете ли вы, что...
React поддерживает создание контекста для передачи данных между компонентами.


57
1

Ответ:

Решено

Разница в том, что ваш useState на основе useProductDetails вызывает setProductDetails внутри цикла над productDocumentsSnapshot.docs, тогда как крючок на основе useReduce делает dispatch с productData после цикла.

Это вызывает проблему, потому что вам setProductDetails каждый раз звонят с одним и тем же productsData. React будет считать, что состояние на самом деле не изменилось, и не будет повторно отображать ваш компонент. Он не будет заглядывать внутрь массива, чтобы проверить, содержит ли он другие элементы, чем раньше. Чтобы исправить это,

  • либо создавайте новый массив во время каждой итерации вместо того, чтобы использовать один и тот же массив, например push
  • или измените состояние только один раз после завершения цикла