«Запашки» кода React-компонентов

Перевод статьи React component code smells с сайта antongunnarsson.com, опубликован на CSS-live.ru с разрешения автора — Антона Гуннарсона

Это не окончательный вариант статьи, могут быть новые дополнения.

Растущая коллекция того, что я считаю «запашками» кода React-компонентов.

Что такое «запашок» кода? «Запашок» кода — что-то такое, что вроде бы и не ошибка, но может быть признаком более серьезной проблемы в коде. Больше информации в Википедии.

Запашки 💩

Слишком много пропсов

Если в компонент передается слишком много пропсов — это знак, что его, возможно, стоит разбить на несколько.

Слишком много — это сколько, спросите вы? Ну… по ситуации. Бывает, что у компонента штук двадцать пропсов или даже больше, но он решает одну задачу, так что всё в порядке. Но если вам попался компонент со множеством пропсов или понадобилось добавить еще один пропс к и без того длинному списку, стоит задуматься о паре вопросов:

Этот компонент решает несколько задач?

Как и функции, компоненты должны делать хорошо только что-то одно, поэтому всегда полезно проверить, можно ли разбить компонент на более мелкие. К примеру, если у компонента несовместимые пропсы или он возвращает JSX из функций.

Нельзя ли использовать композицию?

Классный, но часто упускаемый из виду паттерн, заключается в том, чтобы композировать компоненты вместо того, чтобы обрабатывать всю логику в одном компоненте. Скажем, есть компонент, который обрабатывает обращения пользователей в какую-то организацию:

 <ApplicationForm user={userData} organization={organizationData} categories={categoriesData} locations={locationsData} onSubmit={handleSubmit} onCancel={handleCancel} ... /> 

Глядя на пропсы этого компонента, видно, что все они связаны с тем, что делает компонент, но всё-таки его можно улучшить, если перенести некоторые его обязанности в дочерние компоненты:

 <ApplicationForm onSubmit={handleSubmit} onCancel={handleCancel}> <UserField user={userData} /> <OrganizationField organization={organizationData} /> <CategoryField categories={categoriesData} /> <LocationsField locations={locationsData} /> </ApplicationForm> 

Теперь ясно, что ApplicationForm решает только свою узкую задачу, отправляя и отменяя форму. Дочерние компоненты полностью отвечают каждый за свою часть общей картины. Это также отличная возможность использовать React Context для общения между дочерними и родительскими компонентами.

Узнайте больше о том, как создавать составные компоненты в React

Не много ли «конфигурационных» параметров я передаю?

Иногда лучше сгруппировать пропсы в объект параметров, к примеру, чтобы упростить замену этой конфигурации. В случае компонента, который отображает какой-то грид или таблицу:

 <Grid data={gridData} pagination={false} autoSize={true} enableSort={true} sortOrder="desc" disableSelection={true} infiniteScroll={true} ... /> 

Все эти пропсы, кроме data, можно считать конфигурацией. В подобных случаях иногда полезно изменить компонент Grid, чтобы вместо них он принимал один пропс options

 const options = { pagination: false, autoSize: true, enableSort: true, sortOrder: 'desc', disableSelection: true, infiniteScroll: true, ... } <Grid data={gridData} options={options} /> 

Это ещё и означает, что при переключении между разными options проще убрать варианты конфигурации, которые нам не нужны.

Несовместимые пропсы

Избегайте передачи несовместимых друг с другом пропсов

Например, бывает, что мы сначала создали обычный компонент <Input />, чтобы просто работать с текстом, но спустя какое-то время добавили туда еще и возможность работы с телефонными номерами. Реализация может быть примерно такой:»

 function Input({ value, isPhoneNumberInput, autoCapitalize }) { if (autoCapitalize) capitalize(value) return <input value={value} type={isPhoneNumberInput ? 'tel' : 'text'} /> } 

Проблема с этим кодом в том, что пропсы isPhoneNumberInput и autoCapitalize вместе бессмысленны. Мы не можем писать номера телефонов с заглавной буквы.

В таком случае решением будет разбить компонент на несколько компонентов поменьше. Если у компонентов есть общая логика, то можно перенести её в один специальный хук:

 function TextInput({ value, autoCapitalize }) { if (autoCapitalize) capitalize(value) useSharedInputLogic() return <input value={value} type="text" /> } function PhoneNumberInput({ value }) { useSharedInputLogic() return <input value={value} type="tel" /> } 

Хотя этот пример слегка притянут за уши, при нахождении несовместимых свойств лучше лишний раз проверить, не нужно ли разбить компонент.

Копирование пропсов в состояние

Не останавливайте поток данных, копируя свойства в состояние.

Возьмём этот компонент:

 function Button({ text }) { const [buttonText] = useState(text) return <button>{buttonText}</button> } 

Передав пропс text в качестве начального значения useState, мы заставили компонент практически игнорировать все обновлённые значения text. Когда пропс text обновился, компонент всё равно отображает его начальное значение. Для большинства компонентов это будет неожиданным поведением, что, в свою очередь, увеличивает риск багов.

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

 function Button({ text }) { const [formattedText] = useState(() => slowlyFormatText(text)) return <button>{formattedText}</button> } 

Положив его в состояние, мы решили проблему повторного запуска, но, как и выше, лишили компонента возможности повторно обновляться. Лучший способ решить эту проблему — использовать хук useMemo для мемоизации результата:

 function Button({ text }) { const formattedText = useMemo(() => slowlyFormatText(text), [text]) return <button>{formattedText}</button> } 

Теперь slowlyFormatText запускается только при изменении text, и мы не мешаем компоненту обновляться.

Иногда нам и правда нужен пропс, который никогда не обновляется. К примеру, выбор цвета, когда изначально выбранный цвет устанавливает программа, но после того, как пользователь изменил этот выбор, обновления в ней не должны его переопределять. В таком случае совершенно нормально скопировать пропс в состояние, но чтобы сделать это поведение нагляднее, большинство разработчиков добавляют к пропсу префикс «initial» или «default» (initialColor/defaultColor)

Дальнейшее чтение: «Написание гибких компонентов» от Дэна Абрамова

Возвращение JSX из функций

Не возвращайте JSX из функций внутри компонента

Этот паттерн в основном исчез, когда мы начали активно использовать фукнциональные компоненты, но порой я всё ещё сталкиваюсь с ним. Вот пример того, о чём идёт речь:

 function Component() { const topSection = () => { return ( <header> <h1>Component header</h1> </header> ) } const middleSection = () => { return ( <main> <p>Some text</p> </main> ) } const bottomSection = () => { return ( <footer> <p>Some footer text</p> </footer> ) } return ( <div> {topSection()} {middleSection()} {bottomSection()} </div> ) } 

На первый взгляд вроде бы нормально, но это мешает анализировать код и применять хорошие паттерны, и этого следует избегать. Чтобы решить эту проблему, я либо вывожу весь JSX одним куском, потому что большой return не такая уж большая проблема, но чаще это причина разбить эти разделы в отдельные компоненты.

Помните, что ничто не заставляет вас выносить каждый вновь созданный компонент в отдельный файл. Иногда есть смысл хранить несколько компонентов в одном файле, если они тесно связаны.

Множественные булевые значения для состояния

Избегайте множества булевых значений для представления состояния компонента.

Когда вы пишете компонент, а потом расширяете его функциональность, легко оказаться в ситуации, когда есть много булевых значений для указания, в каком состоянии находится компонент. В маленьком компоненте, который выполняет запрос при нажатии кнопки, может находится что-то вроде этого:

 function Component() { const [isLoading, setIsLoading] = useState(false) const [isFinished, setIsFinished] = useState(false) const [hasError, setHasError] = useState(false) const fetchSomething = () => { setIsLoading(true) fetch(url) .then(() => { setIsLoading(false) setIsFinished(true) }) .catch(() => { setHasError(true) }) } if (isLoading) return <Loader /> if (hasError) return <Error /> if (isFinished) return <Success /> return <button onClick={fetchSomething} /> } 

По нажатию на кнопку мы устанавливаем isLoading в true и делаем запрос к серверу с помощью fetch. При успешном запросе значением isLoading становится false, а значением isFinishedtrue, но в случае ошибки устанавливаем hasError в true

Чисто технически это работает нормально, но состояние (state) компонента слишком неочевидно, и он больше подвержен багам, чем альтернативы. Мы также можем оказаться в «невозможном состоянии», например, если случайно установим для isLoading и isFinished значение true одновременно.

В такой ситуации лучше управлять состоянием с помощью «перечисления» (enum). В других языках перечисления — это способ определить переменную, которую можно установить только в одно значение из предопределенного набора констант, и хотя формально перечислений в Javascript нет, вместо них можно использовать строку и всё равно извлечь много плюсов:

 function Component() { const [state, setState] = useState('idle') const fetchSomething = () => { setState('loading') fetch(url) .then(() => { setState('finished') }) .catch(() => { setState('error') }) } if (state === 'loading') return <Loader /> if (state === 'error') return <Error /> if (state === 'finished') return <Success /> return <button onClick={fetchSomething} /> } 

Благодаря этому мы исключили вероятность невозможных состояний и добились того, что разобраться в этом компоненте стало гораздо легче. А если вы используете какую-то систему типов вроде TypeScript-а, то это ещё лучше, поскольку можно указать возможные состояния:

 const [state, setState] = useState<'idle' | 'loading' | 'error' | 'finished'>('idle') 

Слишком много useState

Избегайте использования большого количества хуков useState в одном компоненте.

Компонент со множеством хуков useState, скорее всего, делает Слишком Много Всего™ и вероятный кандидат для разбивки на несколько, но бывают трудные случаи, когда нам нужно управлять каким-то сложным состоянием в одном компоненте.

Вот пример того, как может выглядеть некоторое состояние и пара функций в компоненте ввода с автозаполнением:

 function AutocompleteInput() { const [isOpen, setIsOpen] = useState(false) const [inputValue, setInputValue] = useState('') const [items, setItems] = useState([]) const [selectedItem, setSelectedItem] = useState(null) const [activeIndex, setActiveIndex] = useState(-1) const reset = () => { setIsOpen(false) setInputValue('') setItems([]) setSelectedItem(null) setActiveIndex(-1) } const selectItem = (item) => { setIsOpen(false) setInputValue(item.name) setSelectedItem(item) } ... } 

У нас есть функция reset, которая сбрасывает всё состояние, и функция selectItem, которая обновляет часть нашего состояния. Обеим функциям для штатной работы необходимо довольно много сеттеров состояния из всех наших useState. Теперь представьте, что обновлять состояние нужно еще массе других действий, и станет ясно, что без багов такое долго не продержится. В этих случаях бывает полезно управлять нашим состоянием с помощью хука useReducer.

 const initialState = { isOpen: false, inputValue: "", items: [], selectedItem: null, activeIndex: -1 } function reducer(state, action) { switch (action.type) { case "reset": return { ...initialState } case "selectItem": return { ...state, isOpen: false, inputValue: action.payload.name, selectedItem: action.payload } default: throw Error() } } function AutocompleteInput() { const [state, dispatch] = useReducer(reducer, initialState) const reset = () => { dispatch({ type: 'reset' }) } const selectItem = (item) => { dispatch({ type: 'selectItem', payload: item }) } ... } 

Используя редьюсер, мы инкапсулировали логику управления нашим состоянием и вынесли сложность из нашего компонента. И теперь гораздо легче понять, что происходит, ведь мы можем думать про состояние и про компонент по отдельности

У useState и useReducer есть свои плюсы, минусы и места, где уместно их юзать (каламбур). Один из моих любимых редьюсеров — паттерн редьюсера состояния Кента. К. Доддса.

Огромные useEffect

Избегайте больших useEffect-ов, которые делают много всего. С ними ваш код будет подвержен ошибкам и его сложно понять.

Когда хуки только появились, я часто совершал ошибку — размещал слишком много всего в одном useEffect. Для наглядности, вот компонент с одним useEffect:

 function Post({ id, unlisted }) { ... useEffect(() => { fetch(`/posts/${id}`).then(/* do something */) setVisibility(unlisted) }, [id, unlisted]) ... } 

Хотя этот эффект не такой большой, он всё же делает несколько вещей. При изменении пропса unlisted мы загрузим статью, даже если идентификатор не изменился.

Чтобы отловить подобные ошибки, я, когда пишу эффекты, стараюсь описывать их самому себе вслух: «когда [зависимости] меняются, делай то-то и то-то». Если применить это к вышеописанному эффекту, то выходит «при изменении id или unlisted, загрузить пост и обновить видимость. Если в этом предложении есть слова «и/или», то обычно это указывает на проблему.

Вместо этого разбейте этот эффект на два:

 function Post({ id, unlisted }) { ... useEffect(() => { // когда id меняется, то запрашивается пост fetch(`/posts/${id}`).then(/* ... */) }, [id]) useEffect(() => { // когда unlisted изменяется, то видимость setVisibility(unlisted) }, [unlisted]) ... } 

После этого компонент стал намного проще: его стало легче понять и снизилась вероятность ошибок.

Заключение

Ладно, пока всё! Помните, что это ни в коем случае не правила, а скорее признаки того, что что-то может быть «не так». Вы обязательно столкнётесь с тем, что вам захочется сделать что-то из вышеперечисленного по уважительной причине.

Не желает ли кто-нибудь разобъяснить мне, где я тут в корне неправ? Может, хотите добавить другие «запашки», с которыми сталкивались у себя в компонентах? Напишите мне в твиттер!