React – generating a unique random key causes infinite loop

I have a componenet that wraps its children and slides them in and out based on the stage prop, which represents the active child’s index.

As this uses a .map() to wrap each child in a div for styling, I need to give each child a key prop. I want to assign a random key as the children could be anything.

I thought I could just do this

key={`pageSlide-${uuid()}`}

but it causes an infinite loop/React to freeze and I can’t figure out why

I have tried

  • Mapping the children before render and adding a uuid key there, calling it via key={child.uuid}
  • Creating an array of uuids and assigning them via key={uuids[i]}
  • Using a custom hook to store the children in a state and assign a uuid prop there

All result in the same issue

Currently I’m just using the child’s index as a key key={pageSlide-${i}} which works but is not best practice and I want to learn why this is happening.

I can also assign the key directly to the child in the parent component and then use child.key but this kinda defeats the point of generating the key

(uuid is a function from react-uuid, but the same issue happens with any function including Math.random())

Here is the full component:

import {
    Children,
    useCallback,
    useEffect,
    useMemo,
    useRef,
    useState,
} from "react";
import PropTypes from "prop-types";
import uuid from "react-uuid";
import ProgressBarWithTicks from "./ProgressBarWithTicks";
import { childrenPropType } from "../../../propTypes/childrenPropTypes";

const calculateTranslateX = (i = 0, stage = 0) => {
    let translateX = stage === i ? 0 : 100;
    if (i < stage) {
        translateX = -100;
    }
    return translateX;
};

const ComponentSlider = ({ stage, children, stageCounter }) => {
    const childComponents = Children.toArray(children);
    const containerRef = useRef(null);
    const [lastResize, setLastResize] = useState(null);
    const [currentMaxHeight, setCurrentMaxHeight] = useState(
        containerRef.current?.childNodes?.[stage]?.clientHeight
    );

    const updateMaxHeight = useCallback(
        (scrollToTop = true) => {
            if (scrollToTop) {
                window.scrollTo(0, 0);
            }
            setCurrentMaxHeight(
                Math.max(
                    containerRef.current?.childNodes?.[stage]?.clientHeight,
                    window.innerHeight -
                        (containerRef?.current?.offsetTop || 0) -
                        48
                )
            );
        },
        [stage]
    );

    useEffect(updateMaxHeight, [stage, updateMaxHeight]);
    useEffect(() => updateMaxHeight(false), [lastResize, updateMaxHeight]);

    const resizeListener = useMemo(
        () => new MutationObserver(() => setLastResize(Date.now())),
        []
    );

    useEffect(() => {
        if (containerRef.current) {
            resizeListener.observe(containerRef.current, {
                childList: true,
                subtree: true,
            });
        }
    }, [resizeListener]);

    return (
        <div className="w-100">
            {stageCounter && (
                <ProgressBarWithTicks
                    currentStage={stage}
                    stages={childComponents.length}
                />
            )}
            <div
                className="position-relative divSlider align-items-start"
                ref={containerRef}
                style={{
                    maxHeight: currentMaxHeight || null,
                }}>
                {Children.map(childComponents, (child, i) => (
                    <div
                        key={`pageSlide-${uuid()}`}
                        className={`w-100 ${
                            stage === i ? "opacity-100" : "opacity-0"
                        } justify-content-center d-flex`}
                        style={{
                            zIndex: childComponents.length - i,
                            transform: `translateX(${calculateTranslateX(
                                i,
                                stage
                            )}%)`,
                            pointerEvents: stage === i ? null : "none",
                            cursor: stage === i ? null : "none",
                        }}>
                        {child}
                    </div>
                ))}
            </div>
        </div>
    );
};

ComponentSlider.propTypes = {
    children: childrenPropType.isRequired,
    stage: PropTypes.number,
    stageCounter: PropTypes.bool,
};

ComponentSlider.defaultProps = {
    stage: 0,
    stageCounter: false,
};

export default ComponentSlider;

It is only called in this component (twice, happens in both instances)

import { useEffect, useReducer, useState } from "react";
import { useParams } from "react-router-dom";

import {
    FaCalendarCheck,
    FaCalendarPlus,
    FaHandHoldingHeart,
} from "react-icons/fa";
import { IoIosCart } from "react-icons/io";
import { mockMatches } from "../../../templates/mockData";
import { initialSwapFormState } from "../../../templates/initalStates";
import swapReducer from "../../../reducers/swapReducer";
import useFetch from "../../../hooks/useFetch";
import useValidateFields from "../../../hooks/useValidateFields";
import IconWrap from "../../common/IconWrap";
import ComponentSlider from "../../common/transitions/ComponentSlider";
import ConfirmNewSwap from "./ConfirmSwap";
import SwapFormWrapper from "./SwapFormWrapper";
import MatchSwap from "../Matches/MatchSwap";
import SwapOffers from "./SwapOffers";
import CreateNewSwap from "./CreateNewSwap";
import smallNumberToWord from "../../../functions/utils/numberToWord";
import ComponentFader from "../../common/transitions/ComponentFader";

const formStageHeaders = [
    "What shift do you want to swap?",
    "What shifts can you do instead?",
    "Pick a matching shift",
    "Good to go!",
];

const NewSwap = () => {
    const { swapIdParam } = useParams();
    const [formStage, setFormStage] = useState(0);
    const [swapId, setSwapId] = useState(swapIdParam || null);
    const [newSwap, dispatchNewSwap] = useReducer(swapReducer, {
        ...initialSwapFormState,
    });

    const [matches, setMatches] = useState(mockMatches);

    const [selectedMatch, setSelectedMatch] = useState(null);
    const [validateHook, newSwapValidationErrors] = useValidateFields(newSwap);

    const fetchHook = useFetch();
    const setStage = (stageIndex) => {
        if (!swapId && stageIndex > 1) {
            setSwapId(Math.round(Math.random() * 100));
        }
        if (stageIndex === "reset") {
            setSwapId(null);
            dispatchNewSwap({ type: "reset" });
        }
        setFormStage(stageIndex === "reset" ? 0 : stageIndex);
    };

    const saveMatch = async () => {
        const matchResponse = await fetchHook({
            type: "addSwap",
            options: { body: newSwap },
        });
        if (matchResponse.success) {
            setStage(3);
        } else {
            setMatches([]);
            dispatchNewSwap({ type: "setSwapMatch" });
            setStage(1);
        }
    };

    useEffect(() => {
        // set matchId of new selected swap
        dispatchNewSwap({ type: "setSwapMatch", payload: selectedMatch });
    }, [selectedMatch]);

    return (
        <div>
            <div className="my-3">
                <div className="d-flex justify-content-center w-100 my-3">
                    <ComponentSlider stage={formStage}>
                        <IconWrap colour="primary">
                            <FaCalendarPlus />
                        </IconWrap>
                        <IconWrap colour="danger">
                            <FaHandHoldingHeart />
                        </IconWrap>
                        <IconWrap colour="warning">
                            <IoIosCart />
                        </IconWrap>
                        <IconWrap colour="success">
                            <FaCalendarCheck />
                        </IconWrap>
                    </ComponentSlider>
                </div>
                <ComponentFader stage={formStage}>
                    {formStageHeaders.map((x) => (
                        <h3
                            key={`stageHeading-${x.id}`}
                            className="text-center my-3">
                            {x}
                        </h3>
                    ))}
                </ComponentFader>
            </div>
            <div className="mx-auto" style={{ maxWidth: "400px" }}>
                <ComponentSlider stage={formStage} stageCounter>
                    <SwapFormWrapper heading="Shift details">
                        <CreateNewSwap
                            setSwapId={setSwapId}
                            newSwap={newSwap}
                            newSwapValidationErrors={newSwapValidationErrors}
                            dispatchNewSwap={dispatchNewSwap}
                            validateFunction={validateHook}
                            setStage={setStage}
                        />
                    </SwapFormWrapper>
                    <SwapFormWrapper heading="Swap in return offers">
                        <p>
                            You can add up to{" "}
                            {smallNumberToWord(5).toLowerCase()} offers, and
                            must have at least one
                        </p>
                        <SwapOffers
                            swapId={swapId}
                            setStage={setStage}
                            newSwap={newSwap}
                            dispatchNewSwap={dispatchNewSwap}
                            setMatches={setMatches}
                        />
                    </SwapFormWrapper>
                    <SwapFormWrapper>
                        <MatchSwap
                            swapId={swapId}
                            setStage={setStage}
                            matches={matches}
                            selectedMatch={selectedMatch}
                            setSelectedMatch={setSelectedMatch}
                            dispatchNewSwap={dispatchNewSwap}
                            saveMatch={saveMatch}
                        />
                    </SwapFormWrapper>
                    <SwapFormWrapper>
                        <ConfirmNewSwap
                            swapId={swapId}
                            setStage={setStage}
                            selectedSwap={selectedMatch}
                            newSwap={newSwap}
                        />
                    </SwapFormWrapper>
                </ComponentSlider>
            </div>
        </div>
    );
};

NewSwap.propTypes = {};

export default NewSwap;