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

直接导入startTransition与使用useTransition钩子的问题排查

React 18.2.0 + TypeScript + Firebase 中 useTransition 钩子导致应用冻结崩溃的问题

问题背景

我正在开发基于React 18.2.0 + TypeScript的Firebase应用,为避免状态更新时应用崩溃冻结,使用startTransition包裹状态更新逻辑。startTransition有两种使用方式:

  • 直接从React导入使用
  • 通过const [isPending, startTransition] = useTransition();钩子获取

根据React文档,二者差异仅为:

  • useTransition钩子仅能在函数组件和自定义钩子顶层使用
  • 钩子提供isPending布尔值用于展示过渡状态

但实际使用中,直接导入的startTransition功能正常,而使用钩子获取的startTransition(即使不使用isPending)会导致应用冻结甚至崩溃,怀疑是否TypeScript需要额外类型处理。

相关代码片段

触发问题的点击处理函数

const handleEditRecipeClick = (recipeId: string) => {
  startTransition(() => {
    const selectedRecipe = recipes.find((recipe) => recipeId === recipe.id);
    if (selectedRecipe) {
      setCurrentRecipe(selectedRecipe);
      console.log(selectedRecipe);
    }
  });
  window.scrollTo(0, document.body.scrollHeight);
};

完整App组件代码

import {
  useCallback,
  useEffect,
  useState,
  useTransition,
  // startTransition,
} from "react";
import firebase from "firebase/compat";
import "./App.css";
import FirebaseAuthService from "./FirebaseAuthService";
import FirebaseFirestoreService, {
  queryType,
  recipeType,
  recipeWithIdType,
} from "./FirebaseFirestoreService";
import LoginForm from "./components/LoginForm";
import AddEditRecipeForm from "./components/AddEditRecipeForm";

function App() {
  const [isPending, startTransition] = useTransition();
  
  const [user, setUser] = useState<firebase.User | null>(null);
  const [currentRecipe, setCurrentRecipe] = useState<recipeWithIdType | null>(
    null
  );
  const [recipes, setRecipes] = useState<recipeWithIdType[]>([]);


  const fetchRecipes = useCallback(async () => {
    const queries: queryType[] = [];

    if (!user) {
      queries.push({
        field: "isPublished",
        condition: "==",
        value: true,
      });
    }
    try {
      const response = await FirebaseFirestoreService.readDocuments(
        "recipes",
        queries
      );
      const newRecipes = response.docs.map((recipeDoc) => {
        const id = recipeDoc.id;
        const data = recipeDoc.data() as recipeType;
        return { ...data, id };
      });
      setRecipes(newRecipes);
    } catch (error) {
      if (error instanceof Error) {
        alert(error.message);
      }
    }
  }, [user]);

  useEffect(() => {
    fetchRecipes();
  }, [fetchRecipes, user]);

  FirebaseAuthService.subcribeToAuthCahnges(setUser);

  const handleAddRecipe = async (newRecipe: recipeType) => {
    try {
      const response = await FirebaseFirestoreService.createDocument(
        "recipes",
        newRecipe
      );
      fetchRecipes();
      alert(`New recipe created with ID: ${response.id}`);
    } catch (error) {
      if (error instanceof Error) {
        alert(error.message);
      }
    }
  };

  const handleUpdateRecipe = async (
    updatedRecipe: recipeType,
    recipeId: string
  ) => {
    try {
      await FirebaseFirestoreService.updateDocument(
        "recipes",
        recipeId,
        updatedRecipe
      );
      fetchRecipes();
      alert(`New recipe created with ID: ${recipeId}`);
      setCurrentRecipe(null);
    } catch (error) {
      if (error instanceof Error) {
        alert(error.message);
      }
    }
  };

  const handleEditRecipeClick = (recipeId: string) => {
    startTransition(() => {
      const selectedRecipe = recipes.find((recipe) => recipeId === recipe.id);
      if (selectedRecipe) {
        setCurrentRecipe(selectedRecipe);
        console.log(selectedRecipe);
      }
    });
    window.scrollTo(0, document.body.scrollHeight);
  };

  const handleEditRecipeCancel = () => {
    setCurrentRecipe(null);
  };

  //can be more detailed here about types and constatnts, but this is simple
  //more info in button types in crwn-clothing project
  const CATEGORIES = {
    breadsSandwichesAndPizza: "Breads, Sandwiches and Pizza",
    eggsAndBreakfast: "Eggs & Breakfast",
    dessertsAndBakedGoods: "Desserts & Baked Goods",
    fishAndSeafood: "Fish & Seafood",
    vegetables: "Vegetables",
  };
  const lookupCategoryLabel = (categoryKey: string) =>
    CATEGORIES[categoryKey as keyof typeof CATEGORIES];

  //formating the date
  const formatDate = (timestamp: number) => {
    const date = new Date(timestamp);
    const day = date.getUTCDate();
    const month = date.toLocaleString("default", { month: "long" });
    const year = date.getFullYear();

    return `${day} of ${month}, ${year}`;
  };

  return (
    <div className="App">
      <div className="title-row">
        <h1 className="title">Firebase Recipes</h1>
        <LoginForm existingUser={user} />
      </div>
      <div className="main">
        <div className="center">
          <div className="recipe-list-box">
            {recipes && recipes.length > 0 && (
              <div className="recipe-list">
                {recipes.map((recipe) => (
                  <div className="recipe-card" key={recipe.id}>
                    {!recipe.isPublished && (
                      <div className="unpublished">UNPUBLISHED</div>
                    )}
                    <div className="recipe-name">{recipe.name}</div>
                    <div className="recipe-field">
                      Category: {lookupCategoryLabel(recipe.category)}
                    </div>
                    <div className="recipe-field">
                      Published: {formatDate(recipe.publishDate)}
                    </div>
                    {user && (
                      <button
                        type="button"
                        className="primary-button edit-button"
                        onClick={() => handleEditRecipeClick(recipe.id)}
                      >
                        EDIT
                      </button>
                    )}
                  </div>
                ))}
              </div>
            )}
          </div>
        </div>
        {/* {isPending && <p>Loading...</p>} */}
        {user && (
          <AddEditRecipeForm
            //  existingRecipe={currentRecipe}
            onAddRecipe={handleAddRecipe}
            // onUpdateRecipe={handleUpdateRecipe}
            // onCancel={handleEditRecipeCancel}
          />
        )}
      </div>
    </div>
  );
}

export default App;

可能的问题原因及排查方向

1. Firebase Auth订阅的重复注册(最可能的原因)

代码中FirebaseAuthService.subcribeToAuthCahnges(setUser);直接写在组件顶层,每次组件重渲染时都会执行该订阅逻辑,导致多个Auth状态监听器同时存在。这会引发user状态频繁更新,进而触发组件反复重渲染。当使用useTransition钩子时,组件的渲染优先级发生变化,这种循环会被放大,最终导致应用冻结。

修复方案:将Auth订阅逻辑移入useEffect并添加清理函数,确保只注册一次监听器:

useEffect(() => {
  const unsubscribe = FirebaseAuthService.subcribeToAuthCahnges(setUser);
  return () => unsubscribe(); // 组件卸载时取消订阅
}, []);

2. 状态更新的捕获值问题

钩子版本的startTransition回调可能捕获了过时的recipes状态值。可以尝试用useRef保存recipes的最新值,验证是否是这个问题:

const recipesRef = useRef<recipeWithIdType[]>([]);
useEffect(() => {
  recipesRef.current = recipes;
}, [recipes]);

// 在handleEditRecipeClick中使用ref的值
const handleEditRecipeClick = (recipeId: string) => {
  startTransition(() => {
    const selectedRecipe = recipesRef.current.find((recipe) => recipeId === recipe.id);
    if (selectedRecipe) {
      setCurrentRecipe(selectedRecipe);
      console.log(selectedRecipe);
    }
  });
  window.scrollTo(0, document.body.scrollHeight);
};

3. TypeScript类型验证(可能性较低)

虽然概率不高,但可以显式标注useTransition的返回值类型,确保类型正确:

const [isPending, startTransition]: [boolean, (callback: () => void) => void] = useTransition();

4. 重渲染循环检查

检查fetchRecipes的依赖项,确认是否存在不必要的重创建。当前fetchRecipes依赖user,这是合理的,但如果user频繁更新,会导致fetchRecipes反复执行,进而更新recipes状态,引发组件重渲染。结合前面的Auth订阅问题,这会加剧循环。

内容的提问来源于stack exchange,提问作者waway

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.23 23:04:53