skip to Main Content

I am implementing some sort of a typing test app with React. I have got the following hierarchy of components:

ContainerForWords 1->N Word 1->N Letter.

At first I tried doing it with refs, so I can change words’ state depending on the user input, but it became a nightmare since refs are not designed to be used like this (they do not trigger re-renders). Is there any other valid way of accessing child components and changing their state from parent?

// ========== 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 };

2

Answers


  1. Lift the state up

    Old official documentation

    Brand new official documentation

    Hard to answer precisely without an example, but if you want to change the child’s state in the parent, it likely means that the parent needs to hold its children states. Something like

    function ContainerForWords(){
      const [wordsStates, setWordsStates] = useState( /* initial states as an array */);
    
      const changeWordState(wordIndex, newState){
       setWordsStates(previousStates => {
        const newStates = [...previousStates]; // copy previous state as we shouldn't mutate it directly
        newStates[wordIndex] = newState;
        return newStates;
       })
      }   
        
      return wordsStates.maps(wordState => <Word state={wordState} />);
    }
    

    Alternatively if the order doesn’t matter, instead of an array you can use a Map/Object word -> state. Note to not modify the map/object directly, you always need to copy it first as states are meant to be immutable.

    Login or Signup to reply.
  2. Redux or another State Management Tool is recommended.

    https://redux.js.org/

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search