You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React+Firebase表单等待自定义Hook获取有效ID的实现问题

问题描述
  1. 自定义Hook useHighestId 功能正常,但在AdminPanel组件中使用时,表单的禁用输入框会先收到undefined或NaN值,希望表单能等待Hook从数据库获取到真实值后再渲染该输入。
  2. TypeScript阻止使用if (highestID === NaN) { return }语句,提示类型number | undefined不存在于类型number中。
相关代码

useHighestId.ts Hook

import { db } from "../config/firebaseConfig";
import { collection, query, doc, getDoc, getDocs, DocumentSnapshot } from "firebase/firestore";
import { useEffect, useState } from "react";
import { ArticleParameters } from "../AdminPanel/AdminPanel";

export const useHighestId = (category?: string): number => {
  const [allCategories, setAllCategories] = useState<any[]>([]);
  const [articlesList, setArticlesList] = useState<any>({});
  const [highestId, setHighestId] = useState<number>(0);

  useEffect(() => {
    const getCategory = async () => {
      const collectDataFromDB = query(collection(db, "content"));
      const dataFromDB: any[] = [];
      const documents = await getDocs(collectDataFromDB);
      documents.docs.map((document) => {
        dataFromDB.push(document.id);
      });
      setAllCategories(dataFromDB);
    };
    getCategory();
  }, []);

  useEffect(() => {
    allCategories.map((currentCategory) => {
      const getArticlesFromDB = async () => {
        const docRef = doc(db, "content", currentCategory);
        const docSnap = await getDoc(docRef);
        setArticlesList(docSnap.data());
      };
      getArticlesFromDB();
    });
  }, [allCategories]);

  const articlesListAsArray: ArticleParameters[] = Object.values(articlesList);
  const articlesIds: number[] = [];

  articlesListAsArray.forEach((article) => {
    articlesIds.push(article?.id);
  });

  useEffect(() => {
    if (articlesIds.sort().reverse()[0] !== undefined) {
      setHighestId(articlesIds.sort().reverse()[0]);
    }
  }, [articlesIds]);

  return highestId;
};

AdminPanel组件实现

import { useUser } from "../hooks/useUser";
import { db, storage } from "../config/firebaseConfig";
import { doc, setDoc, collection, getDocs, addDoc, query, Timestamp, updateDoc } from "firebase/firestore";
import { ChangeEvent, FormEvent, FormEventHandler, useEffect, useState } from "react";
import { Button, Col, Form, FormCheck, Row } from "react-bootstrap";
import { TextInput } from "../utils/TextInput";
import { useHighestId } from "../hooks/useHighestId";
import { getDownloadURL, ref, uploadBytesResumable } from "firebase/storage";

export interface ArticleParameters {
  category: string;
  id: number;
  title: string;
  content: string;
  author: string;
  date: Timestamp;
  tags: string[];
  isOnline: boolean;
  isAdult: boolean;
  databaseTitle: string;
}

const currentDate = new Date();

export const AdminPanel = () => {
  const user = useUser();
  let highestIdFromHook = useHighestId();

  const parameters: ArticleParameters = {
    category: "news",
    id: highestIdFromHook + 1,
    title: "",
    content: "",
    author: "",
    date: Timestamp.fromDate(currentDate),
    tags: [""],
    isOnline: false,
    isAdult: false,
    databaseTitle: "",
  };

  const [data, setData] = useState<ArticleParameters>(parameters);
  const [articleDatabaseName, setArticleDatabaseName] = useState<string>(`${data.category}_id_${data.id}`);
  const [file, setFile] = useState<any>("");
  const [percent, setPercent] = useState<number>(0);

  // Handle file upload event and update state
  function handleChange(event: any) {
    setFile(event.target.files[0]);
  }

  const handleUpload = () => {
    const storageRef = ref(storage, `/files/${file.name}`);

    // progress can be paused and resumed. It also exposes progress updates.
    // Receives the storage reference and the file to upload.
    const uploadTask = uploadBytesResumable(storageRef, file);

    uploadTask.on(
      "state_changed",
      (snapshot) => {
        const percent = Math.round((snapshot.bytesTransferred / snapshot.totalBytes) * 100);

        // update progress
        setPercent(percent);
      },
      (err) => console.log(err),
      () => {
        // download url
        getDownloadURL(uploadTask.snapshot.ref).then((url) => {
          console.log(url);
        });
      }
    );
  };

  const addArticle = async (newArticleData: ArticleParameters) => {
    await updateDoc(doc(db, "content", newArticleData.category), {
      [newArticleData.databaseTitle]: {
        id: newArticleData.id,
        title: newArticleData.title,
        content: newArticleData.content,
        author: newArticleData.author,
        date: newArticleData.date,
        tags: newArticleData.tags,
        is_online: newArticleData.isOnline,
        is_adult: newArticleData.isAdult,
      },
    });
  };

  const handleSwitch = (e: ChangeEvent<HTMLInputElement>) => {
    setData({ ...data, [e.target.name]: e.target.checked });
  };

  const { category, id, title, content, author, date, tags, isOnline, isAdult } = data;

  const resetForm = () => {
    setArticleDatabaseName(`${data.category}_id_${data.id}`);
    setData({
      category: "news",
      id: highestIdFromHook + 1,
      title: "",
      content: "",
      author: "",
      date: Timestamp.fromDate(currentDate),
      tags: [""],
      isOnline: false,
      isAdult: false,
      databaseTitle: articleDatabaseName,
    });
  };

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (!file) {
      alert("Please upload an image first!");
    }
    if (data.databaseTitle !== "") {
      addArticle(data);
      handleUpload();
      resetForm();
    } else {
      console.log("Shit");
    }
  };

  return (
    <div className="articles__content--wrapper">
      <h1 className="text-center mb-4">Dodaj newsa</h1>
      <Form
        style={{
          width: "80%",
          alignSelf: "center",
          alignItems: "center",
        }}
        onSubmit={handleSubmit}>
        <TextInput input="Tytuł" isRequired="true" type="text" name="title" data={data} setData={setData} />
        <TextInput input="Treść" isRequired="true" type="text" name="content" data={data} setData={setData} textarea />
        <Form.Group controlId="formFile" className="mb-3">
          <Form.Label>Dodaj obrazek</Form.Label>
          <Form.Control className="form__control--input" type="file" accept="image/*" onChange={handleChange} />
        </Form.Group>
        <div style={{ display: "flex", flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
          <TextInput input="Data dodania" isRequired="true" disabled type="text" name="date" data={data} setData={setData} />
          <TextInput input="ID Newsa" isRequired="true" disabled type="number" name="id" data={data} setData={setData} />
          <div style={{ display: "flex", flexDirection: "column", justifyContent: "space-between", alignItems: "flex-start" }}>
            <Form.Check type="switch" label="Opublikować?" id="isOnline" name="isOnline" onChange={handleSwitch} className="me-3"></Form.Check>
            <Form.Check type="switch" label="Tylko dla dorosłych?" id="isAdult" name="isAdult" onChange={handleSwitch} className="mb-3 me-3"></Form.Check>
          </div>
        </div>
        <TextInput input="Autor" isRequired="true" disabled type="text" name="author" data={data} setData={setData} />
        <TextInput input="Nazwa w bazie" isRequired="true" disabled type="text" name="databaseTitle" data={data} setData={setData} />
        <div style={{ display: "flex", flexDirection: "row", justifyContent: "space-around", alignItems: "center" }}>
          <Button variant="info" type="submit">
            Zapisz dane
          </Button>
          <Button variant="warning" type="reset" onClick={resetForm}>
            Wyczyść formularz
          </Button>
        </div>
      </Form>
    </div>
  );
};

TextInput组件实现

import FloatingLabel from "react-bootstrap/FloatingLabel";
import Form from "react-bootstrap/Form";
import { Dispatch, SetStateAction } from "react";
import { ArticleParameters } from "../AdminPanel/AdminPanel";
import { useUser } from "../hooks/useUser";

export interface DispatchTypes {
  nickname?: string;
  email: string;
  password: string;
  error?: string;
}

interface Properties {
  input: string;
  isRequired: string;
  type: string;
  name: string;
  data: any;
  setData: Dispatch<SetStateAction<DispatchTypes>> | Dispatch<SetStateAction<ArticleParameters>> | any;
  textarea?: boolean;
  disabled?: boolean;
}

export const TextInput = ({ input, isRequired, type, name, data, setData, textarea, disabled }: Properties) => {
  const user = useUser();
  
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setData({ ...data, [name]: e.target.value });
  };

  if (name === "author" && user?.name !== undefined) {
    data.author = user?.name;
  }
  if (name === "databaseTitle") {
    data.databaseTitle = `${data.category}_id_${data.id}`
  }

  return (
    <FloatingLabel  label={`${input}${isRequired === "true" ? "*" : " (opcjonalnie)"}`} className="mb-3" style={{fontSize: "0.75rem"}}>
      <Form.Control
        as={textarea ? "textarea" : 'input'}
        style={textarea ? {height: "200px", fontSize: "1rem"} : { height: "45px", fontSize: "1rem"}}
        className="form__control--input"
        type={type}
        placeholder={`${name}${isRequired === "true" ? "*" : " (opcjonalnie)"}`}
        required={isRequired === "true" ? true : false}
        disabled={disabled ? true : false}
        name={name}
        onChange={handleChange}
        value={name === "date" ? data.date.toDate() : data[name]}
      />
    </FloatingLabel>
  );
};
解决方案

一、解决表单提前渲染undefined/NaN的问题

步骤1:修改useHighestId Hook,添加加载状态

让Hook返回加载状态和最高ID,组件可通过状态判断数据是否加载完成:

import { db } from "../config/firebaseConfig";
import { collection, query, doc, getDoc, getDocs } from "firebase/firestore";
import { useEffect, useState } from "react";
import { ArticleParameters } from "../AdminPanel/AdminPanel";

export const useHighestId = (category?: string) => {
  const [allCategories, setAllCategories] = useState<any[]>([]);
  const [articlesList, setArticlesList] = useState<any>({});
  const [highestId, setHighestId] = useState<number | undefined>(undefined);
  const [isLoading, setIsLoading] = useState<boolean>(true);

  useEffect(() => {
    const getCategory = async () => {
      try {
        const collectDataFromDB = query(collection(db, "content"));
        const documents = await getDocs(collectDataFromDB);
        const dataFromDB = documents.docs.map(doc => doc.id);
        setAllCategories(dataFromDB);
      } catch (err) {
        console.error("获取分类失败:", err);
      }
    };
    getCategory();
  }, []);

  useEffect(() => {
    if (allCategories.length === 0) return;

    const fetchAllArticles = async () => {
      try {
        const articlesPromises = allCategories.map(async (currentCategory) => {
          const docRef = doc(db, "content", currentCategory);
          const docSnap = await getDoc(docRef);
          return docSnap.data();
        });
        const allArticles = await Promise.all(articlesPromises);
        const mergedArticles = allArticles.reduce((acc, articles) => ({ ...acc, ...articles }), {});
        setArticlesList(mergedArticles);
      } catch (err) {
        console.error("获取文章数据失败:", err);
      }
    };

    fetchAllArticles();
  }, [allCategories]);

  useEffect(() => {
    if (Object.keys(articlesList).length === 0) return;

    const articlesListAsArray: ArticleParameters[] = Object.values(articlesList);
    const validIds = articlesListAsArray
      .map(article => article?.id)
      .filter(id => typeof id === "number");

    if (validIds.length > 0) {
      setHighestId(Math.max(...validIds));
    } else {
      setHighestId(0);
    }
    setIsLoading(false);
  }, [articlesList]);

  return { highestId, isLoading };
};

步骤2:在AdminPanel中根据加载状态条件渲染表单

等待数据加载完成后再初始化表单,避免提前渲染无效值:

import { useUser } from "../hooks/useUser";
import { db, storage } from "../config/firebaseConfig";
import { doc, updateDoc, Timestamp } from "firebase/firestore";
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
import { Button, Form, FormCheck } from "react-bootstrap";
import { TextInput } from "../utils/TextInput";
import { useHighestId } from "../hooks/useHighestId";
import { getDownloadURL, ref, uploadBytesResumable } from "firebase/storage";

export interface ArticleParameters {
  category: string;
  id: number;
  title: string;
  content: string;
  author: string;
  date: Timestamp;
  tags: string[];
  isOnline: boolean;
  isAdult: boolean;
  databaseTitle: string;
}

export const AdminPanel = () => {
  const user = useUser();
  const { highestId, isLoading } = useHighestId();
  const currentDate = new Date();
  const [data, setData] = useState<ArticleParameters | null>(null);
  const [file, setFile] = useState<any>("");
  const [percent, setPercent] = useState<number>(0);

  useEffect(() => {
    if (!isLoading && highestId !== undefined) {
      setData({
        category: "news",
        id: highestId + 1,
        title: "",
        content: "",
        author: user?.name || "",
        date: Timestamp.fromDate(currentDate),
        tags: [""],
        isOnline: false,
        isAdult: false,
        databaseTitle: `news_id_${highestId + 1}`,
      });
    }
  }, [isLoading, highestId, user, currentDate]);

  if (isLoading || !data) {
    return <div className="articles__content--wrapper text-center">加载中...</div>;
  }

  const handleChange = (event: any) => {
    setFile(event.target.files[0]);
  };

  const handleUpload = () => {
    const storageRef = ref(storage, `/files/${file.name}`);
    const uploadTask = uploadBytesResumable(storageRef, file);

    uploadTask.on(
      "state_changed",
      (snapshot) => {
        const percent = Math.round((snapshot.bytesTransferred / snapshot.totalBytes) * 100);
        setPercent(percent);
      },
      (err) => console.log(err),
      () => {
        getDownloadURL(uploadTask.snapshot.ref).then((url) => {
          console.log(url);
        });
      }
    );
  };

  const addArticle = async (newArticleData: ArticleParameters) => {
    await updateDoc(doc(db, "content
相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.11 03:25:27