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
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
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.
Redux or another State Management Tool is recommended.
https://redux.js.org/