Вопрос или проблема
В моем проекте на 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, добавив соответствующие типы.
- Определите новые типы пропсов:
Вам нужно создать новый тип для пропсов, который будет учитывать пропсы родительского компонента и пропсы, добавляемые HOC.
type WithDetailsProps<T> = T & {
title: string;
difficulty: Difficulty;
minutesNeeded: number;
};
- Обновите 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}
/>
);
};
}
- Обновите
RecipeNamer
:
Убедитесь, что интерфейсRecipeNamerProps
правильно связан с HOC.
export const RecipeNamerMemoized = withDetails(RecipeNamer);
Дополнительные рекомендации
-
Рефакторинг и повторное использование HOC:
Если у вас есть несколько HOC с подобной логикой, рассмотрите возможность создания обобщённого HOC, который может обрабатывать разные части контекста, передавая соответствующие пропсы. -
Тестирование:
После внесения изменений не забудьте протестировать приложение, чтобы убедиться, что поведение компонентов остаётся неизменным и что ошибки компиляции решены. -
Документация:
Регулярно документируйте свои HOC и обрабатываемые типы. Это поможет вам и вашим коллегам лучше понимать код в будущем.
Надеюсь, вышеуказанные шаги помогут вам решить проблемы с типами в ваших мемоизированных HOC. Если возникнут дополнительные вопросы, не стесняйтесь обращаться за помощью!