React+TS Quiz应用条件渲染时如何合规调用自定义Hook
React Context 复用自定义Hook时的加载状态实现方案
问题背景
- 基于React + TypeScript开发测验应用,封装
QuizProvider做状态上下文,通过自定义HookuseQuiz对外暴露游戏全量逻辑:读取URL分类参数、处理后端题目数据,返回游戏运行所需的状态与操作方法。 - 现有矛盾:如果在JSX中加条件判断(比如加载题目时展示加载页),担心违反Hooks调用规则;如果把
useQuiz逻辑全内联到Provider里,又会导致代码冗余、可读性差。 - 当前临时处理:首次渲染
questions为undefined时,引入本地questionsJson作为默认mock数据兜底,仅作演示用,需要替换为正式的加载逻辑。
现有代码
QuizProvider 实现
import { createContext, useEffect } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import { Question, Provider } from '../types'; import useQuiz from '../hooks/useQuiz'; import questionsJson from '../lib/questions.json'; import useFetch from '../hooks/useFetch'; export const QuizContext = createContext({} as ReturnType<typeof useQuiz>); export default function _QuizProvider({ children }: Provider) { const { pathname } = useLocation(); const { category } = useParams(); const { fetchData: fetchQuestions, data: questions, loading, error } = useFetch<Question[]>(); useEffect(() => { fetchQuestions(`${pathname}/questions`, 'GET'); } , []); return ( <QuizContext.Provider value={useQuiz({ category: category!, questions: questions || questionsJson.geography })}> {children} </QuizContext.Provider> ); }
useQuiz 自定义Hook实现
import { useState } from 'react'; import { Answer, Quiz, useQuiz } from '../types'; export default function _useQuiz({ category, questions }: useQuiz): Quiz { const QUESTIONS_TIMER = 10 * 60; const [questionNo, setQuestionNo] = useState(1); const [answers, setAnswers] = useState(new Array<Answer>(questions.length)); const [finish, setFinish] = useState(false); return { category, questions, timer: QUESTIONS_TIMER, questionNo, totalQuestions: questions.length, currentQuestion: questions[questionNo - 1], finish, nextQuestion: () => setQuestionNo(questionNo + 1), previousQuestion: () => setQuestionNo(questionNo - 1), toggleQuestion: (index: number) => setQuestionNo(index), onAnswer: (answer: Answer) => { const newAnswers = [...answers]; newAnswers[questionNo - 1] = answer; setAnswers(newAnswers); }, isSelected: (answer: Answer) => JSON.stringify(answers[questionNo - 1]) === JSON.stringify(answer), isAnswersMarked: () => answers.every(answer => answer !== undefined), finishQuiz: () => setFinish(true), score: () => answers.filter(answer => answer && answer.correct).length }; }
实现方案
Hooks规则的核心要求是每次组件渲染时,Hook的调用顺序、调用次数必须保持一致,并没有禁止条件渲染——只要所有Hook都放在组件顶层、不包裹在条件/循环/嵌套函数内,Hook执行完成后再做条件判断返回不同UI,完全符合规范。
改造后的QuizProvider
把useQuiz调用移到组件顶层,加载/错误状态判断放在所有Hook执行完成后,同时移除临时mock数据依赖:
import { createContext, useEffect } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import { Question, Provider } from '../types'; import useQuiz from '../hooks/useQuiz'; import useFetch from '../hooks/useFetch'; export const QuizContext = createContext({} as ReturnType<typeof useQuiz>); export default function _QuizProvider({ children }: Provider) { // 所有Hook顶层无条件调用,保证渲染顺序稳定 const { pathname } = useLocation(); const { category } = useParams(); const { fetchData: fetchQuestions, data: questions, loading, error } = useFetch<Question[]>(); useEffect(() => { fetchQuestions(`${pathname}/questions`, 'GET'); }, [pathname, fetchQuestions]); // 补全依赖项,避免闭包陈旧问题 // 加载阶段传入空数组兜底,保证Hook初始化不报错 const quizContextValue = useQuiz({ category: category ?? '', questions: questions ?? [] }); // Hook全部执行完成后再做条件渲染,不违反任何规则 if (loading) return <div className="loading">题目加载中...</div>; if (error || !questions?.length) return <div className="error">题目加载失败,请刷新重试</div>; return ( <QuizContext.Provider value={quizContextValue}> {children} </QuizContext.Provider> ); }
适配useQuiz的边界逻辑
针对加载阶段传入空数组的场景,补充简单的边界判断,避免数组越界报错:
import { useState } from 'react'; import { Answer, Quiz, useQuiz as UseQuizProps } from '../types'; export default function _useQuiz({ category, questions }: UseQuizProps): Quiz { const QUESTIONS_TIMER = 10 * 60; const [questionNo, setQuestionNo] = useState(1); const [answers, setAnswers] = useState<Answer[]>(() => new Array(questions.length)); const [finish, setFinish] = useState(false); return { category, questions, timer: QUESTIONS_TIMER, questionNo, totalQuestions: questions.length, currentQuestion: questions[questionNo - 1] ?? null, finish, nextQuestion: () => questionNo < questions.length && setQuestionNo(prev => prev + 1), previousQuestion: () => questionNo > 1 && setQuestionNo(prev => prev - 1), toggleQuestion: (index: number) => index >= 1 && index <= questions.length && setQuestionNo(index), onAnswer: (answer: Answer) => { setAnswers(prev => { const newAnswers = [...prev]; newAnswers[questionNo - 1] = answer; return newAnswers; }); }, isSelected: (answer: Answer) => { const current = answers[questionNo - 1]; return current ? JSON.stringify(current) === JSON.stringify(answer) : false; }, isAnswersMarked: () => questions.length > 0 && answers.every(ans => ans !== undefined), finishQuiz: () => setFinish(true), score: () => answers.filter(ans => ans?.correct).length }; }
方案优势
- 完全符合Hooks调用规则,没有任何规范问题
- 保留
useQuiz的独立封装,不需要把逻辑内联到Provider中,代码分层清晰 - 移除了临时mock数据依赖,完整支持加载、错误状态的UI展示
- 补充了原代码中缺失的useEffect依赖、操作边界判断,修复了潜在的闭包问题、数组越界问题
内容的提问来源于stack exchange,提问作者Shalev Sror
相关产品推荐
相关产品推荐

