Не удается решить проблемы с типами в мемоизированных высокоархифицированных компонентах React.

Вопрос или проблема

В моем проекте на React TS я работаю над компонентом (RecipeNamer), который позволяет пользователю вводить текст в поле ввода. Вводимые данные затем отправляются в контекст React, провайдером которого является родительский компонент (RecipeCreator), который отображает три дочерних компонента. Все они обновляют разные части контекста, и я хочу, чтобы они не рендерились, когда их часть контекста не изменилась.

Чтобы достичь этого, я решил создать три высших компонента, которые оборачивают каждый дочерний компонент в функцию React.memo(), извлекают соответствующий срез контекста и рендерят дочерние компоненты, передавая им часть контекста, извлеченную в виде пропсов:

hocs.tsx

export function withDetails<T extends object>(Component: ComponentType<T & { ingredients: Array<string> }>) {
    const MemoizedComponent = memo(Component);

    return (props: Omit<T, "title" | "difficulty" | "minutesNeeded">) => {
        const { title, difficulty, minutesNeeded } = useContext(RecipeCreatedContext);

        return <MemoizedComponent {...(props as T)} title={title} difficulty={difficulty} minutesNeeded={minutesNeeded} />;
    };
};

RecipeNamer.tsx

interface RecipeNamerProps {
    title: string;
    difficulty: Difficulty;
    minutesNeeded: number;
    dispatcher: React.Dispatch<RecipeCreatedAction>;
}

const difficultyValues = ["Легкий", "Средний", "Сложный"];

const RecipeNamer = (props: RecipeNamerProps) => {
    const { title, difficulty, minutesNeeded } = props;

    const userData: UserData | null = useAppSelector(getUserData);

    const experienceLevel = useRef<Experience>({level: "Без опыта"});

    useEffect(() => {
        if (userData) {
            experienceLevel.current = calculateChefExperience(userData)
        }
    }, [userData]);

    const handleRecipeNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
        // @TODO: вызов функции диспетчера, переданной как пропс.
    }

    return (
        <Box>
            <TextField id="recipe-title" label="Название рецепта" variant="outlined" 
                       value={title} onChange={(event) => handleRecipeNameChange(event as React.ChangeEvent<HTMLInputElement>)}/>
            <TextField id="minutes-needed" label="Необходимое время" variant="outlined"
                        value={minutesNeeded} /> 
            <TextField id="recipe-difficulty" label="Сложность" variant="outlined"
                        defaultValue={difficulty} select>
                {difficultyValues.map((option) => <option key={option} value={option}>{option}</option>)}
            </TextField> 
            <Typography>Шеф-повар: {userData?.displayName}, {experienceLevel.current.level}</Typography>
        </Box>
    )
}

export const RecipeNamerMemoized = withDetails(RecipeNamer);

RecipeCreator.tsx

function recipeCreatedReducer(state: RecipeCreatedState, action: RecipeCreatedAction): RecipeCreatedState {
    switch (action.type) {
        case 'edit-ingredients':
            return {...state, ingredients: action.payload as string[]};
        case 'edit-preparation':
            return {...state, preparation: action.payload as string};
        case 'edit-image':
            return {...state, image: action.payload as File}
        default:
            throw Error('Неизвестное действие для recipeCreatedReducer.');
    }
}

const initialState: RecipeCreatedState = {
    title: "",
    ingredients: [],
    preparation: "",
    minutesNeeded: 0,
    difficulty: "Легкий",
    image: null,
    imageURL: ''
};

export const RecipeCreatedContext = createContext<RecipeCreatedState>(initialState);

export const RecipeCreator = () => {

    const [recipeCreated, recipeCreatedDispatch] = useReducer(recipeCreatedReducer, initialState);

    const [toaster, setToaster] = useState<ToasterData>({
        open: false,
        message: "",
        type: "success",
        transition: "Slide",
        key: null
    });

    const loggedUser = useAppSelector(getLoggedUser);

    const navigate = useNavigate();

    useEffect(() => {
        if (!loggedUser) {
            alert("Вы должны войти в систему сначала.");
            navigate('/sign-in');
        }
    }, [loggedUser, navigate]);

    const handleCloseToaster = () => {
        setToaster((prevState) => { return { ...prevState, open: false} });
    }

    const handleSubmitRecipe = async () => {
        if (!recipeCreated || !recipeCreated.title || !recipeCreated.ingredients || !recipeCreated.preparation || !recipeCreated.minutesNeeded || !recipeCreated.difficulty) {
            setToaster({
                open: true,
                message: "Нельзя отправить рецепт с отсутствующими аргументами. Пожалуйста, заполните все необходимые поля.",
                type: "error",
                transition: "Slide",
                key: "Error"
            })
        }
        else {
            try {
                if (recipeCreated.image) {
                    const recipeCreatedWithDetails: Omit<RecipeDetails, "id" | "imageURL"> = {
                        ...recipeCreated,
                        chef: loggedUser ? loggedUser.uid : "",
                        views: 0,
                        likes: 0
                    }
                    const retValue = await publishNewRecipe(recipeCreatedWithDetails, recipeCreated.image);
                    if (retValue) setToaster({
                        open: true,
                        message: `Новый рецепт "${recipeCreated?.title}" успешно опубликован!`,
                        type: "success",
                        transition: "Slide",
                        key: recipeCreated.title 
                    })
                }
            }
            catch (error) {
                console.error("Ошибка при публикации нового рецепта в базе данных: ", error);
                setToaster({
                    open: true,
                    message: (error as Error).message,
                    type: "error",
                    transition: "Slide",
                    key: "Error"
                });
            }
        }
    }

    return (
        <>
            <Box display="flex" flexDirection="column" minHeight="100vh">
                <RecipeAppBar />
                <RecipeCreatedContext.Provider value={recipeCreated}>
                    <Box display="flex" flexDirection="row" width="100%" justifyContent="space-evenly">
                        <Box flexDirection="column" height="100%" width="50%">
                            <IngredientsSelectorMemoized setToaster={setToaster} dispatcher={recipeCreatedDispatch}/>
                            <RecipeNamerMemoized dispatcher={recipeCreatedDispatch} />
                        </Box>
                        <Box display="flex" flexDirection="column" height="100%" alignItems="center">
                            <RecipeImageMemoized dispatcher={recipeCreatedDispatch} currentImageURL={recipeCreated.imageURL}/>
                            <RecipeEditorMemoized dispatcher={recipeCreatedDispatch} />
                            <Button onClick={handleSubmitRecipe}>Отправить</Button>
                        </Box>
                        <Snackbar 
                            anchorOrigin={{ vertical: "top", horizontal: "right" }}
                            autoHideDuration={4000}
                            open={toaster.open}
                            key={toaster.key}
                            onClose={() => handleCloseToaster()}
                            sx={{ display: "flex", justifyContent: "space-between", alignItems: "center"}}
                        >
                            <Alert severity={toaster.type} variant="filled" sx={{display:"flex", flexDirection:"row", alignItems: "center"}}>
                                <Box display="flex" justifyContent="space-between" alignItems="center">
                                    <Typography>{toaster.message}</Typography>
                                    <Close onClick={() => handleCloseToaster()} sx={{ marginLeft: "2%", cursor: "pointer" }}/>
                                </Box>
                            </Alert>
                        </Snackbar>
                    </Box>
                </RecipeCreatedContext.Provider>
            </Box>       
        </>
    )
}

Хотя мне кажется, что это правильно, я получаю одну и ту же ошибку TypeScript для всех трех HOC, которые я написал (здесь я привел только withDetails(), но они все похожи), и ошибка выделена в return <MemoizedComponent {…(props as T)} title={title} difficulty={difficulty} minutesNeeded={minutesNeeded} />.

TypeScript говорит:

Тип 'T & { title: string; difficulty: Difficulty; minutesNeeded: number; }' не может быть присвоен типу 'IntrinsicAttributes & ((PropsWithoutRef<T & { ingredients: string[]; }> & RefAttributes<Component<T & { ingredients: string[]; }, any, any>>) | PropsWithRef<...>)'.
  Тип 'T & { title: string; difficulty: Difficulty; minutesNeeded: number; }' не может быть присвоен типу 'IntrinsicAttributes & PropsWithRef<T & { ingredients: string[]; }>'.
    Тип 'T & { title: string; difficulty: Difficulty; minutesNeeded: number; }' не может быть присвоен типу 'PropsWithRef<T & { ingredients: string[]; }>'.ts(2322)
const MemoizedComponent: React.MemoExoticComponent<ComponentType<T & {
    ingredients: Array<string>;
}>>

Может кто-то помочь мне с этим, пожалуйста? Я попробовал различные способы переписывания пропсов, указывая соответствующие отдельные интерфейсы, но безуспешно. Ошибка остается более или менее одинаковой.

Ответ или решение

Решение проблем с типами в мемоизированных высших компонентах порядка (HOC) React с использованием TypeScript

В вашем проекте React с TypeScript вы столкнулись с проблемами типирования при использовании мемоизированных высших компонент порядка (HOC). Давайте разберёмся в проблеме подробнее и предложим возможные решения.

Описание проблемы

Ваш компонент RecipeNamer использует HOC для получения части контекста и передаёт её в качестве пропсов мемоизированному компоненту. Однако TypeScript выдаёт ошибку, указывая на то, что типы T & { title: string; difficulty: Difficulty; minutesNeeded: number; } не совпадают с ожидаемым типом компонента:

const MemoizedComponent = memo(Component);
return <MemoizedComponent {...(props as T)} title={title} difficulty={difficulty} minutesNeeded={minutesNeeded} />;

Ошибка возникает из-за того, что TypeScript не может сопоставить передаваемые пропсы с ожидаемыми пропсами в виде типа HOC.

Анализ и решение

Ваша HOC-реализация определяет тип T, который используется в сигнатуре функции, однако вы добавляете дополнительные пропсы, что требует более сложной типовой аннотации для обеспечения совместимости. Мы можем исправить вашу HOC, добавив соответствующие типы.

  1. Определите новые типы пропсов:
    Вам нужно создать новый тип для пропсов, который будет учитывать пропсы родительского компонента и пропсы, добавляемые HOC.
type WithDetailsProps<T> = T & {
    title: string;
    difficulty: Difficulty;
    minutesNeeded: number;
};
  1. Обновите HOC:
    Перепишите вашу HOC с учётом новых типов. Используйте тот тип, который мы определили ранее.
export function withDetails<T extends object>(
    Component: ComponentType<WithDetailsProps<T>>
) {
    const MemoizedComponent = memo(Component);

    return (props: Omit<T, "title" | "difficulty" | "minutesNeeded">) => {
        const { title, difficulty, minutesNeeded } = useContext(RecipeCreatedContext);

        return (
            <MemoizedComponent
                {...(props as T)}
                title={title}
                difficulty={difficulty}
                minutesNeeded={minutesNeeded}
            />
        );
    };
}
  1. Обновите RecipeNamer:
    Убедитесь, что интерфейс RecipeNamerProps правильно связан с HOC.
export const RecipeNamerMemoized = withDetails(RecipeNamer);

Дополнительные рекомендации

  • Рефакторинг и повторное использование HOC:
    Если у вас есть несколько HOC с подобной логикой, рассмотрите возможность создания обобщённого HOC, который может обрабатывать разные части контекста, передавая соответствующие пропсы.

  • Тестирование:
    После внесения изменений не забудьте протестировать приложение, чтобы убедиться, что поведение компонентов остаётся неизменным и что ошибки компиляции решены.

  • Документация:
    Регулярно документируйте свои HOC и обрабатываемые типы. Это поможет вам и вашим коллегам лучше понимать код в будущем.

Надеюсь, вышеуказанные шаги помогут вам решить проблемы с типами в ваших мемоизированных HOC. Если возникнут дополнительные вопросы, не стесняйтесь обращаться за помощью!

Оцените материал
Добавить комментарий

Капча загружается...