直接导入startTransition与使用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

