Ловушка React: объявление компонентов внутри других компонентов
В React есть одна ошибка, которая выглядит совершенно безобидно, проходит TypeScript, работает в разработке… и при этом может незаметно ломать приложение.
Особенно если вы используете styled-components.
Этот код выглядит нормально:
export const Layout = ({ children }) => {
const Main = styled.main`
min-height: 100dvh;
`
return <Main>{children}</Main>
}
Многие разработчики годами пишут подобный код, даже не подозревая, что здесь есть серьёзная проблема.
Но React не воспринимает это как «тот же самый компонент».
Для него при каждом рендере создаётся совершенно новый компонент.
Что происходит на самом деле
Вот эта строка:
const Main = styled.main``
создаёт новый React-компонент.
Не обновляет.
Не переиспользует.
Создаёт.
А поскольку она находится внутри другого компонента, этот код выполняется заново при каждом ререндере родителя.
В результате React получает примерно следующее:
Render 1 -> <Main_v1 />
Render 2 -> <Main_v2 />
Render 3 -> <Main_v3 />
Для React это разные типы компонентов.
А когда тип компонента меняется, React размонтирует старое дерево и монтирует новое.
Это означает:
- состояние сбрасывается;
- эффекты запускаются заново;
- refs сбрасываются;
- анимации перезапускаются;
- фокус может пропасть;
- формы могут потерять данные.
Самая неприятная часть
Обычно такой баг выглядит вообще не связанным с рендерингом.
Можно часами отлаживать:
- React Hook Form;
- Framer Motion;
- Zustand;
- Tabs;
- Modals;
- Context;
- Memoization;
хотя настоящая проблема заключается всего лишь в этом:
const Something = styled.div``
внутри функции рендера.
Реальный пример
Представим такой код:
export const Page = () => {
const Wrapper = styled.div`
padding: 20px;
`
const [count, setCount] = useState(0)
return (
<Wrapper>
{' '}
Counter: {count} <br />
<button onClick={useCallback(() => setCount((count) => count + 1), [])}>
{' '}
Update{' '}
</button>{' '}
</Wrapper>
)
}
Вы нажимаете кнопку.
Page ререндерится.
Wrapper становится новым компонентом.
React перемонтирует всё, что находится внутри него.
Состояние Counter может полностью сброситься.
Особенно болезненно это проявляется в случае:
- форм;
- редакторов;
- анимаций;
- тяжёлых компонентов.
Правильный вариант
Вынесите объявление компонента наружу:
const Wrapper = styled.div`
padding: 20px;
`
export const Page = () => {
const [count, setCount] = useState(0)
return (
<Wrapper>
{' '}
Counter: {count} <br />
<button onClick={useCallback(() => setCount((count) => count + 1), [])}>
{' '}
Update{' '}
</button>{' '}
</Wrapper>
)
}
Теперь у Wrapper стабильная идентичность.
React может корректно выполнять reconciliation.
Никаких повторных монтирований.
Никаких случайных сбросов состояния.
Проблема не только в styled-components
То же самое происходит и здесь:
function Parent() {
function Child() {
return <div>Hello</div>
}
return <Child />
}
или здесь:
const Child = () => <div />;
если объявить такой компонент внутри другого компонента.
Вложенные объявления компонентов по своей природе нестабильны.
Почему это опасно
Потому что:
- ESLint часто этого не замечает;
- TypeScript не предупреждает;
- React не предупреждает;
- интерфейс может «в основном работать».
До тех пор, пока однажды:
- форма внезапно не очистится;
- анимации не начнут мерцать;
- модальные окна не начнут переоткрываться;
- состояние не начнёт пропадать;
- компоненты не станут бесконечно перемонтироваться.
И отладка превращается в кошмар.
Простое правило
Никогда не объявляйте:
- React-компоненты;
styled-components;memo(...);forwardRef(...);
внутри другого React-компонента, если вы точно не понимаете последствия повторного монтирования.
Если компонент должен переживать ререндеры, его объявление тоже должно переживать ререндеры.