React打字测试应用:无需Ref修改子组件状态的方案咨询
问题描述
我正在使用React开发一款打字测试应用,组件层级结构为:ContainerForWords 一对多(1->N)Word,Word一对多(1->N)Letter。最初我尝试使用Ref来根据用户输入修改子组件的状态,但由于Ref并非为此场景设计(无法触发重渲染),导致实现陷入困境。请问是否存在其他有效的方式,可在父组件中访问并修改子组件的状态?
当前实现代码
// ========== GeneratedTextAreaContainer export const GeneratedTextAreaContainer = (): JSX.Element => { const { isRestartScheduled, setRestartScheduledStatus } = useContext(RestartContext); const trainingConfiguration = useAppSelector((store) => store.data.training); const wordRefs = useRef<WordRef[]>([]); const queryResult = useQuery({ queryKey: ['generatedText'], queryFn: async () => { const data = await trainingHttpClient.getGeneratedText({ ...trainingConfiguration, languageId: trainingConfiguration.languageInfo.id }); wordRefs.current = []; setRestartScheduledStatus(false); return data; }, enabled: isRestartScheduled }); const generatedText = queryResult.data?.value ?? []; const wordElements = generatedText.map((wordLetters, wordIndex) => ( <Word key={`word_${wordIndex}`} letters={wordLetters} isActive={wordIndex === 0} ref={(ref: WordRef) => { wordRefs.current.push(ref); }} /> )); return ( <> {isRestartScheduled ? ( <LoaderElement /> ) : ( <GeneratedTextAreaFragment words={wordElements} wordRefs={wordRefs.current} /> )} </> ); }; // ========== GeneratedTextAreaFragment interface Props { words: ReactNode; wordRefs: WordRef[]; } interface PositionInfo { positionInWord: number; wordIndex: number; } export const GeneratedTextAreaFragment = (props: Props): JSX.Element => { const [rerenderTrigger, setRerenderTrigger] = useState(false); const [positionInfo, setPositionInfo] = useState<PositionInfo>({ positionInWord: 0, wordIndex: 0 }); useEffect(() => { setRerenderTrigger(!rerenderTrigger); }, []); const activeWord = props.wordRefs.find((w) => w.isActive); console.log(props.wordRefs); const keyDownHandler = (event: KeyboardEvent<HTMLDivElement>): void => { const char = event.key; const currentLetter = activeWord?.letterRefs[positionInfo.positionInWord]; if (currentLetter?.character === char) { currentLetter?.setStatus('correct'); } else { currentLetter?.setStatus('incorrect'); } if ( activeWord?.letterRefs !== undefined && positionInfo.positionInWord === activeWord?.letterRefs?.length - 1 ) { const newActiveWord = props.wordRefs[positionInfo.wordIndex + 1]; activeWord.setIsActive(false); newActiveWord.setIsActive(true); } setPositionInfo((oldPositionInfo) => { if ( activeWord !== undefined && oldPositionInfo.positionInWord === activeWord?.letterRefs.length ) { return { wordIndex: oldPositionInfo.wordIndex + 1, positionInWord: 0 }; } return { wordIndex: oldPositionInfo.wordIndex, positionInWord: oldPositionInfo.positionInWord + 1 }; }); }; return ( <FocusLock> <Box sx={styles.wordsContainer} tabIndex={0} onKeyDown={keyDownHandler}> {props.words} </Box> </FocusLock> ); }; // ========== Word interface Props { isActive: boolean; letters: string[]; } interface WordRef { isActive: boolean; setIsActive: (isActive: boolean) => void; letterRefs: LetterRef[]; } const Word = forwardRef((props: Props, ref) => { const [rerenderTrigger, setRerenderTrigger] = useState(false); const [isActive, setIsActive] = useState(props.isActive); const letterRefs = useRef<LetterRef[]>([]); useEffect(() => { setRerenderTrigger(!rerenderTrigger); }, []); useImperativeHandle( ref, () => { return { isActive, setIsActive, letterRefs: letterRefs.current }; }, [isActive, letterRefs] ); const letterElements = props.letters.map((character, characterIndex) => { return ( <Letter key={`char_${characterIndex}`} character={character} ref={(ref: LetterRef) => letterRefs.current.push(ref)} /> ); }); console.log(letterRefs.current); return <Box sx={styles.word}>{letterElements}</Box>; }); Word.displayName = 'Word'; export { Word }; export type { WordRef }; // ========== Letter type Status = 'initial' | 'correct' | 'incorrect'; interface Props { character: string; } interface LetterRef { character: string; setStatus: (status: Status) => void; } const Letter = forwardRef((props: Props, ref) => { const [status, setStatus] = useState<Status>('initial'); useImperativeHandle(ref, () => { return { character: props.character, setStatus }; }); const getStylesBasedOnState = (): SxProps => { if (status === 'correct') { return styles.correct; } if (status === 'incorrect') { return styles.incorrect; } return styles.initial; }; return <Typography sx={{ ...getStylesBasedOnState() }}>{props.character}</Typography>; }); Letter.displayName = 'Letter'; export { Letter }; export type { Status, LetterRef };
解决方案
核心思路是将状态提升到父组件统一管理,让子组件成为纯展示组件,通过props接收状态和回调,完全遵循React单向数据流原则,彻底规避refs操作子组件状态的问题。
1. 定义统一状态结构
在顶层组件(如GeneratedTextAreaContainer)中维护所有核心状态:
interface LetterState { character: string; status: Status; } interface WordState { letters: LetterState[]; isActive: boolean; } // 初始化状态 const [wordsState, setWordsState] = useState<WordState[]>([]); const [positionInfo, setPositionInfo] = useState({ positionInWord: 0, wordIndex: 0 });
2. 初始化状态
当获取到生成文本后,将其转换为统一状态结构:
useEffect(() => { if (queryResult.data?.value) { const initialWordsState = queryResult.data.value.map((wordLetters, wordIndex) => ({ letters: wordLetters.map(char => ({ character: char, status: 'initial' as Status })), isActive: wordIndex === 0 })); setWordsState(initialWordsState); // 重置输入位置 setPositionInfo({ positionInWord: 0, wordIndex: 0 }); } }, [queryResult.data]);
3. 改造子组件为受控组件
Word组件
去掉内部状态和refs,通过props接收状态与回调:
interface WordProps { word: WordState; onLetterUpdate: (letterIndex: number, status: Status) => void; } export const Word = ({ word, onLetterUpdate }: WordProps) => { return ( <Box sx={styles.word}> {word.letters.map((letter, index) => ( <Letter key={`char_${index}`} letter={letter} onStatusChange={(status) => onLetterUpdate(index, status)} /> ))} </Box> ); };
Letter组件
纯展示组件,完全由props驱动渲染:
interface LetterProps { letter: LetterState; } export const Letter = ({ letter }: LetterProps) => { const getStylesBasedOnState = (): SxProps => { if (letter.status === 'correct') return styles.correct; if (letter.status === 'incorrect') return styles.incorrect; return styles.initial; }; return ( <Typography sx={{ ...getStylesBasedOnState() }}> {letter.character} </Typography> ); };
4. 父组件处理输入逻辑
在GeneratedTextAreaFragment中直接修改顶层状态,驱动UI更新:
const keyDownHandler = (event: KeyboardEvent<HTMLDivElement>): void => { const char = event.key; const currentWord = wordsState[positionInfo.wordIndex]; if (!currentWord) return; const currentLetterIndex = positionInfo.positionInWord; if (currentLetterIndex >= currentWord.letters.length) return; // 更新当前字母状态 setWordsState(prev => { const newWords = [...prev]; const targetWord = { ...newWords[positionInfo.wordIndex] }; targetWord.letters[currentLetterIndex] = { ...targetWord.letters[currentLetterIndex], status: targetWord.letters[currentLetterIndex].character === char ? 'correct' : 'incorrect' }; // 切换下一个单词激活状态 if (currentLetterIndex === targetWord.letters.length - 1 && positionInfo.wordIndex < prev.length - 1) { newWords[positionInfo.wordIndex] = { ...targetWord, isActive: false }; newWords[positionInfo.wordIndex + 1] = { ...newWords[positionInfo.wordIndex + 1], isActive: true }; } else { newWords[positionInfo.wordIndex] = targetWord; } return newWords; }); // 更新输入位置 setPositionInfo(prev => { if (currentLetterIndex === currentWord.letters.length - 1 && prev.wordIndex < wordsState.length - 1) { return { wordIndex: prev.wordIndex + 1, positionInWord: 0 }; } return { ...prev, positionInWord: prev.positionInWord + 1 }; }); };
5. 传递状态与回调给子组件
在GeneratedTextAreaContainer中渲染Word组件时,传递对应数据:
const wordElements = wordsState.map((word, wordIndex) => ( <Word key={`word_${wordIndex}`} word={word} onLetterUpdate={(letterIndex, status) => { setWordsState(prev => { const newWords = [...prev]; newWords[wordIndex].letters[letterIndex].status = status; return newWords; }); }} /> ));
方案优势
- 完全符合React单向数据流,状态变更可追踪,调试更简单
- 子组件无内部状态,复用性强,易编写单元测试
- 彻底避免refs带来的副作用,UI渲染完全由状态驱动
内容的提问来源于stack exchange,提问作者tokechu
相关产品推荐
相关产品推荐

