Search code examples
javascriptreactjstypescriptdomredux

React Warning: Each child in a list should have a unique "key" prop NOT caused by lack of key in map


I'm currently working on a React 18 app. On one of pages I'm getting a following error: enter image description here

I know that this kind of error is usually associated with lack of unique key in the map function, but not in my case, because if I inspect the component by using the React DevTools all keys are distinctive, which leads me to thinking that the warning in the console in misleading and is caused by something else, but I might be wrong.

enter image description here

Code of RockfonDropdownFilterResponsive component:

/* eslint-disable @typescript-eslint/no-non-null-assertion */
import React, { PropsWithChildren, useEffect, useRef, useState } from 'react';
import type { ReactElement } from 'react';
import AnimateHeight from 'react-animate-height';
import { useIsMounted } from '../../hooks/useIsMounted';
import { generateFilterRows, generateGroupFilters, generateGroupFiltersMobile } from '../shared/rockfon-filter-factory';
import type { FilterOptions, Group, RockfonProductFilterOrderSettings } from '../shared/types';
import { useOnClickOutside } from '../../hooks/useOnClickOutside';

interface BaseProductFilterProps {
    filterTitle: string;
    optionsTitle: string;
    handleClose: () => void;
    handleOpen: () => void;
    noFiltersAvailableLabel: string;
    selectedOptions: string[];
}

interface ProductFilterProps extends BaseProductFilterProps {
    isOpen: boolean;
    groups?: Group[];
    options: FilterOptions[];
    noFiltersAvailableLabel: string;
    selectOption: (value: string) => void;
    selectGroupOption: (value: string) => void;
    selectedOptions: string[];
    filterNarrowCondition?: (value: string, minValue?: number, maxValue?: number) => boolean;
    applySelections: () => void;
    isMobile: boolean;
    shrinkingOptions?: (options: FilterOptions[], type: string) => FilterOptions[];
    name?: string;
    isShrinking?: number;
    orderSettings: RockfonProductFilterOrderSettings;
}

interface ProductFilterPresentationProps extends BaseProductFilterProps {
    height: number | string;
    handleClose: () => void;
    handleOpen: () => void;
    filterRows: ReactElement[];
    isOpen: boolean;
}

const RockfonDropdownFilterResponsive = (props: PropsWithChildren<ProductFilterPresentationProps>): ReactElement => (
    <div className="filterDropdownContainer">
        <div>
            <div
                role="listbox"
                className={props.isOpen ? 'btn-dropdown is-open' : 'btn-dropdown'}
                onClick={props.isOpen ? props.handleClose : props.handleOpen}
                tabIndex={0}
            >
                <span className="filter-name" title={props.filterTitle}>{props.filterTitle}</span>
                <svg height="20" width="20" aria-hidden="true">
                    <path d="M4.516 7.548c.436-.446 1.043-.481 1.576 0L10 11.295l3.908-3.747c.533-.481 1.141-.446 1.574 0 .436.445.408 1.197 0 1.615-.406.418-4.695 4.502-4.695 4.502a1.095 1.095 0 01-1.576 0S4.924 9.581 4.516 9.163s-.436-1.17 0-1.615z" />
                </svg>
            </div>
            <AnimateHeight className="filter" contentClassName="filter-content" height={props.height} duration={300}>
                {props.children && <>{props.children} <div className="divider" /></>}

                <div className="filter__option-title">{props.optionsTitle}</div>
                {props.filterRows.length > 0 && <div className="filter-scrollbar">{props.filterRows}</div>}
                {props.filterRows.length === 0 && (
                    <div className="no-filters">{props.noFiltersAvailableLabel}</div>
                )}
            </AnimateHeight>
        </div>  
        <div className="filter-item">
            <div
                role="listbox"
                className={props.isOpen ? 'btn-dropdown is-open' : 'btn-dropdown'}
                onClick={props.isOpen ? props.handleClose : props.handleOpen}
                tabIndex={0}
            >
                <span className="filter-name" title={props.filterTitle}>
                    {props.filterTitle}{props.selectedOptions.length > 0 && <sup>({props.selectedOptions.length})</sup>}
                </span>
                <svg height="20" width="20" aria-hidden="true">
                    <path d="M4.516 7.548c.436-.446 1.043-.481 1.576 0L10 11.295l3.908-3.747c.533-.481 1.141-.446 1.574 0 .436.445.408 1.197 0 1.615-.406.418-4.695 4.502-4.695 4.502a1.095 1.095 0 01-1.576 0S4.924 9.581 4.516 9.163s-.436-1.17 0-1.615z" />
                </svg>
            </div>
            <AnimateHeight height={props.height} duration={300}>
                <div className="filter">
                    {props.children}
                    {props.children && <div className="divider" />}
                    {props.filterRows.length > 0 && (
                        <div className="filter-scrollbar">
                            {props.filterRows}
                        </div>
                    )}
                    {props.filterRows.length === 0 && (
                        <div className="no-filters">{props.noFiltersAvailableLabel}</div>
                    )}
                </div>
            </AnimateHeight>
        </div>
    </div>
)

export const RockfonDropdownFilter = (props: PropsWithChildren<ProductFilterProps>): ReactElement => {
    const [height, setHeight] = useState<0 | 'auto'>(0);
    const [displayedOptions, setDisplayedOptions] = useState<FilterOptions[]>([]);
    const isMounted = useIsMounted();
    const [openTabIndex, setOpenTabIndex] = useState<number>(0);

    const ref = useRef();
    useOnClickOutside(ref, () => {
        if (!props.isMobile && props.isOpen) {
            const filterContainer = document
                .getElementById('filtersDesktopFrm');
            const filterContainerDisplay = getComputedStyle(filterContainer).getPropertyValue('display');
            if (filterContainerDisplay != 'none') {
                props.handleClose();
            }
        }
    });
    useEffect(() => {
        if (!isMounted) {
            return;
        }

        if (props.isOpen) {
            requestAnimationFrame(() => {
                setHeight('auto');
            });
        }
        else {
            requestAnimationFrame(() => {
                setHeight(0);
            });
        }
    }, [props.isOpen]);

    const handleFilterRowChange = (value: string) => {
        if (isMounted) {
            props.selectOption(value);
            if (!props.isMobile) {
                props.applySelections();
            }
        }
    };

    useEffect(() => {
        if (props.isShrinking && props.isMobile) {
            if (props.shrinkingOptions) {
                setDisplayedOptions(props.shrinkingOptions(props.options, props.name));
            }
        }
    }, [props.isShrinking])

    useEffect(() => {
        if (props.isMobile &&  !props.shrinkingOptions) {
            setDisplayedOptions(props.options);
        }
        else {
            if (props.shrinkingOptions) {
                setDisplayedOptions(props.shrinkingOptions(props.options, props.name));
            } else {
                setDisplayedOptions(props.filterNarrowCondition ? props.options.filter(f => !props.filterNarrowCondition(f.value, f.minValue, f.maxValue)) : props.options);
            }
        }
    }, [props.options, props.filterNarrowCondition]);

    const hasGroups = props.groups && props.groups.length > 0;
    const groupsOnOptions: string[] = props.options.map((f) => f.group).filter((g) => g) as string[];
    const isAnyFilterWithGroup =
        hasGroups && groupsOnOptions.some((f) => props.groups!.map((g) => g.value).includes(f));

    let filterRows = [];

    if (isAnyFilterWithGroup) {
        const groupedFilters = props.isMobile
            ? generateGroupFiltersMobile(props.groups!, displayedOptions, props.selectedOptions, openTabIndex, setOpenTabIndex, handleFilterRowChange, props.orderSettings)
            : generateGroupFilters(props.groups!, displayedOptions, props.selectedOptions, handleFilterRowChange, props.orderSettings)
        filterRows.push(groupedFilters);
    }
    else {
        filterRows = generateFilterRows(displayedOptions, props.selectedOptions, props.orderSettings, handleFilterRowChange);
    }

    const presentationProps: ProductFilterPresentationProps = {
        selectedOptions: props.selectedOptions,
        filterTitle: props.filterTitle,
        filterRows,
        height,
        handleClose: props.handleClose,
        handleOpen: props.handleOpen,
        optionsTitle: props.optionsTitle,
        isOpen: props.isOpen,
        noFiltersAvailableLabel: props.noFiltersAvailableLabel
    }
    return <div ref={ref}>
        <RockfonDropdownFilterResponsive {...presentationProps}>{props.children}</RockfonDropdownFilterResponsive>
    </div>
}

Does anyone have any ideas how to get rid of this warning, or how should I find out more about the problem?

EDIT: Updating the question with generateGroupFilters and generateGroupFiltersMobile functions.

export const generateGroupFilters = (
    groups: Group[],
    filterOptions: FilterOptions[],
    checkedFilters: string[],
    handleFilterRowChange: (value: string) => void,
    orderSettings: RockfonProductFilterOrderSettings
): ReactElement => {
    const groupRows = sortGroups(groups, orderSettings).map((gr) => {
        const groupOptions = filterOptions.filter((f) => f.group === gr.value);
        const groupFilterRows = generateFilterRows(groupOptions, checkedFilters, orderSettings, handleFilterRowChange);
        return (
            <React.Fragment key={`Group${gr.value}`}>
                <GroupNameRow key={gr.value} {...gr} />
                {groupFilterRows}
            </React.Fragment>
        );
    });
    const groupValues = groups.map((gr) => gr.value);
    const filterOptionsWithoutGroup = filterOptions.filter((f) => !groupValues.includes(f.group || ''));
    const filterRowsWithoutGroup = generateFilterRows(filterOptionsWithoutGroup, checkedFilters, orderSettings, handleFilterRowChange);
    return (
        <>
            {groupRows}
            {filterRowsWithoutGroup && filterRowsWithoutGroup.length > 0 && (
                <>
                    <GroupNameRow key="no-group-indicator" {...{ label: 'Others', value: '' }} />
                    {filterRowsWithoutGroup}
                </>
            )}
        </>
    );
};

export const generateGroupFiltersMobile = (
    groups: Group[],
    filterOptions: FilterOptions[],
    checkedFilters: string[],
    openTabIndex: number,
    setOpenTabIndex: (number) => void,
    handleFilterRowChange: (value: string) => void,
    orderSettings: RockfonProductFilterOrderSettings
): ReactElement => {
    const getHeight = (index: number) => (index === openTabIndex ? 'auto' : 0);

    const groupRows = sortGroups(groups, orderSettings).map((gr, index) => {
        const groupOptions = filterOptions.filter((f) => f.group === gr.value);
        const groupFilterRows = generateFilterRows(groupOptions, checkedFilters, orderSettings, handleFilterRowChange);
        const tabIndex = index + 1;

        return (
            <React.Fragment key={`Group_${gr.value}`}>
                <GroupNameRowMobile
                    key={gr.value}
                    {...gr}
                    count={groupOptions.filter((x) => checkedFilters.includes(x.value)).length}
                    className={tabIndex === openTabIndex ? '' : 'is-open'}
                    onClick={() => setOpenTabIndex(openTabIndex === tabIndex ? 0 : tabIndex)}
                />
                <AnimateHeight height={getHeight(tabIndex)}>{groupFilterRows}</AnimateHeight>
            </React.Fragment>
        );
    });
    const groupValues = groups.map((gr) => gr.value);
    const otherIndex = groupRows.length + 1;
    const filterOptionsWithoutGroup = filterOptions.filter((f) => !groupValues.includes(f.group || ''));
    const filterRowsWithoutGroup = generateFilterRows(filterOptionsWithoutGroup, checkedFilters, orderSettings, handleFilterRowChange);
    return (
        <div key="group_container">
            {groupRows}
            {filterRowsWithoutGroup && filterRowsWithoutGroup.length > 0 && (
                <>
                    <GroupNameRowMobile
                        key="no-group-indicator"
                        {...{ label: 'Others', value: '' }}
                        onClick={() => setOpenTabIndex(otherIndex)}
                    />
                    <AnimateHeight height={getHeight(otherIndex)}>{filterRowsWithoutGroup}</AnimateHeight>
                </>
            )}
        </div>
    );
};

Solution

  • Your generateGroupFilters returns a fragment with no key:

    return (
        <>
            ...
        </>
    );
    

    You're pushing those fragments into filterRows and using it.

    Even fragments in arrays need keys, so you get the error.

    Here's an example of the problem (sadly, Stack Snippets only support <React.Fragment>...<React.Fragment>, not <>...</>, but it doesn't make any difference):

    const Example = () => {
        const example = [
            <React.Fragment>a</React.Fragment>,
            <React.Fragment>b</React.Fragment>,
            <React.Fragment>c</React.Fragment>,
        ];
        return <div>{example}</div>;
    };
    
    const root = ReactDOM.createRoot(document.getElementById("root"));
    root.render(<Example />);
    <div id="root"></div>
    
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.1.0/umd/react.development.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.1.0/umd/react-dom.development.js"></script>

    When you run that, you get the standard warning about keys. Here's a live copy using <>...</> on CodeSandbox, showing the same error.

    Add keys to the fragments being returned that are unique throughout filterRows.