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

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.27 23:28:11