Головна / Статті / Зменшення надмірного використання React Prop-Drilling та „богоподібних“ компонентів

Зменшення надмірного використання React Prop-Drilling та „богоподібних“ компонентів

Дізнайтеся сім конкретних шаблонів рефакторингу для розбирання надмірно великих компонентів React шляхом ізоляції стану, отримання даних, дозволів та логіки завантаження, а не просто розділення файлів.

3299 слів

Був період, коли складність React оцінювалася виключно за кількістю рядків коду. Як тільки компонент досягав трьохсот рядків, інструментальна панель переносилася у окремий файл. Коли кількість рядків сягала чотирьохсот, таблиця також виймалася у окремий файл. Файл верхнього рівня ставав меншим, але сама функціональність не ставала простішою для розуміння. Батьківський компонент все ще містив кожен запит до мережі, кожне модальне вікно, численні поля форми та їхній стан перевірки, правила щодо того, що дозволено поточному користувачеві робити, а також зміни станів екрана під час завантаження, редагування, збереження та іноді помилок.

Насправді відбувалося лише перерозташування елементів коду без жодних змін у тому, хто є власником „будинку“ — проекту.

Це розрізнення варто усвідомити, адже сама по собі величина не є ворогом. Компонент може законно бути великим, якщо він представляє єдиний, цілісний елемент інтерфейсу. Інтенсивний редактор, панель керування чи сторінка зі звітами можуть справедливо містити багато маркапу. Проблеми починаються тоді, коли один компонент стає єдиним місцем, де приймаються абсолютно не пов’язані між собою рішення.

Жоден із наведених нижче патернів не є за своєю суттю поганим у React. Кожен з них має законні сфери застосування в менших чи більш цілеспрямованих середовищах. Вони стають проблемою лише тоді, коли з’являються знову і знову всередині великих компонентів, адже кожне їхнє використання підвищує зв’язність, розширює область, яка піддається переробці, та змушує кожну майбутню зміну враховувати всю сторінку одночасно.

Позбавлення їх не залишає вас із купою безглуздих дрібних компонентів. Це дає більш чітку структуру щодо стану, отримання даних, взаємодії та відображення. Код стає простішим для змін саме тому, що менше елементів функціоналу можуть випадково впливати один на одного.

1. Компоненти, які передають весь функціонал далі

Одна з звичок, яка постійно спостерігається на більших екранах, — це включення великих об’єктів та довгих списків функцій-відповідей майже до кожного дочірнього компонента.

Уявіть таблицю результатів, яка отримує інформацію про поточного користувача, повний об’єкт дозволів, активні фільтри, вибраний рядок, прапорець завантаження, кілька функцій змін, налаштування модальних вікон та обробників сповіщень — а також кілька значень, до яких вона ніколи не звертається безпосередньо. Ця таблиця потім передає частину цих даних безпосередньо до компонентів рядків, які знову передають їх у окремі клітинки та кнопки.

Якщо подивитися на дерево файлів, все здається чітко розділеним. Однак прийом фактичних даних, які передаються між компонентами, показує іншу картину: кожен дочірній компонент все ще пов’язаний із усією функціональністю.

Це створює дві серйозні проблеми. По-перше, погіршується здатність до розуміння. Компонент, який приймає п’ятнадцять параметрів, складний для аналізу, оскільки його справжня функція прихована серед деталей, які насправді належать його батьківському компоненту. По-друге, зміни поширюються на всю структуру. Зміна назви одного поля дозволів чи коректування сигнатури функції певної дії означає необхідність редагування кількох рівнів компонентів, які спочатку не мали жодного реального контролю над цією поведінкою.

Рішення полягає у заміні широких параметрів, орієнтованих на функціонал, на вузькі контракти, які визначають саме те, за що відповідає кожен дочірній компонент. Таблиця потребує лише рядків, стану вибору та щось на кшталт функції-відповідача onRowSelected. Меню дій потребує лише конкретних дій, доступних для певного запису — а не всієї моделі дозволів та кожної функції змін.

Щоб досягти цього, зазвичай потрібно створити невелику модель відображення або модель дії перед рендерингом. Цей додатковий крок підготовки є корисним, адже він змушує батьківський елемент перетворювати первинний стан додатку на простіший, спеціально створений інтерфейс перед тим, як передати щось далі.

Мета ніколи не полягає у мінімізації кількості параметрів заради самої мінімізації. Справжня перевага полягає у тому, що дочірні елементи більше не повинні розуміти, як працює вся сторінка. Їм достатньо знати, які дані потрібно відображати та яку інформацію потрібно надсилати назад.

Великий компонент стає крихким у ту мить, коли кожен нащадок несе частину всього внутрішнього світу його батька. Вузькі, спеціально розроблені контракти дозволяють окремим частинам інтерфейсу розвиватися незалежно, замість того щоб втягувати всю функціональність у кожну дискусію.

2. Нові об’єкти та функції створюються під час кожного рендерингу

Кожен компонент React при рендерингу автоматично створює нові значення. Ви передаєте об’єкт опцій дочірньому компоненту, фільтруєте масив або пишете вбудований обробник подій. У більшості випадків усе це не має значення.

Але всередині глибокої ієрархії компонентів свіжостворений об’єкт посилання може тихо порушити мемоізацію на кілька рівнів нижче компонента, який його створив.

Розгляньмо дочірній компонент, створений за допомогою memo: він знову рендериться щоразу, коли об’єкт, який він отримує, має нову ідентичність під час кожної передачі в батьківський компонент, навіть якщо фактичні значення полів у цьому об’єкті ніколи не змінювалися. Ефект перезапускається, оскільки його масив залежностей містить об’єкт конфігурації, створений заново. Таблиця перераховує свої стовпці, тому що визначення стовпців були перестворені після зміни якогось абсолютно не пов’язаного стану модалного вікна.

Код може здаватися абсолютно стабільним, приховуючи цю проблему:

<ResultsTable
  columns={[
    { key: "name", label: "Name" },
    { key: "status", label: "Status" },
  ]}
  options={{
    selectable: true,
    compact: false,
  }}
/>

З точки зору JavaScript кожного разу під час відображення як масив, так і об’єкти всередині нього є абсолютно новими. Те, чи це справді створює проблеми, залежить виключно від того, що ними користується.

Використання useMemo та useCallback як універсального рішення також не є правильним підходом. Обгортання всього в механізм мемоїзації лише додає новий рівень складнощів та проблем із масивами залежностей. Кращим початком є запитання: чи справді потрібно створювати це значення прямо під час відображення?

Статична конфігурація може бути повністю виведена за межі компонента. Конфігурація, яка змінюється час від часу, може бути розміщена у спеціальному хуку. Коли об’єкт існує виключно для об’єднання кількох примітивних елементів, зазвичай краще передавати їх безпосередньо. Обробники подій потребують використання useCallback лише тоді, коли їхня ідентичність справді має значення — для підписок, запам’ятованих дочірніх елементів чи дорогих перерахунків у наступних етапах.

Метою ніколи не є досягнення чистоти посилань заради неї самої. Створення невеликих значень під час відображення є звичайним явищем та зазвичай нешкідливим. Увагу слід приділяти лише тим випадкам, коли ідентичність посилань справді виконує важливу функцію в інших частинах структури.

У великому компоненті одне незначне оновлення стану може змусити переробити відображення батьківського елемента та генерувати цілий набір значень. Якщо кожен дочірній елемент розглядає змінену посилання як змінені дані, одне невелике локальне оновлення перетворюється на анулювання всієї сторінки.

3. Видалення єдиного запиту, який живив усю сторінку

Великі сторінки часто починаються з одного запиту, призначеного для отримання всього, що може знадобитися інтерфейсу. Ця відповідь містить підсумкові показники, рядки таблиць, фільтри, пов’язані записи, дані про дозволи, нещодавню історію та навіть поля, необхідні лише для модального вікна, яке користувач може ніколи не відкрити.

На перший погляд цей підхід здається ефективним, адже у сторінки є лише один стан завантаження та одне очевидне джерело даних. Але він також пов’язує кожну частину сторінки з найповільнішим та найменш надійним елементом цієї відповіді.

Якщо служба історії виконується некоректно, основна таблиця може взагалі не відображатися. Великий набір пов’язаних даних збільшує початковий об’єм інформації навіть для користувачів, які ніколи не відкривають панель, яка її потребує. А оновлення лише однієї частини змушує перезавантажити всю сторінку, оскільки вся сторінка має єдині межі даних.

Кращим підходом є заміна цих запитів розміром з усю сторінку на межі даних, які відповідають фактичним видимим елементам екрана. Основний контент завантажується спочатку. Додаткові панелі отримують свої дані лише тоді, коли вони стають актуальними. Деталі, які потребують багато ресурсів, завантажуються тоді, коли користувач відкриває конкретний запис, а не є частиною кожного рядка за замовчуванням.

Це не означає необхідності ініціювати мережевий запит для кожного дрібного елемента інтерфейсу. Надмірне застосування такого розділення створює власні проблеми — послідовні запити, дублювання викликів та непослідовні стани завантаження. Фактично важливою є межа, яка представляє собою регіон із власним життєвим циклом та власним визначенням того, що означає „невдача“.

Віджет підсумків та журнал аудиту не повинні успішно функціонувати чи зазнавати невдачі як єдине ціле. Таблиця та рідко використовувана панель редагування не обов’язково мають мати однаковий початковий набір даних. Як тільки ці елементи розділено, сторінка може залишатися функціональною навіть у разі збою однієї з необов’язкових частин.

Сам компонент також стає набагато зрозумілішим, оскільки йому більше не потрібно моделювати одну величезну, складну структуру відповіді. Кожен регіон отримує саме ті дані, які йому потрібні, а скасування кешу може стосуватися лише тих записів, які змінилися, а не всієї сторінки.

Великі компоненти схильні ставати крихкими саме тоді, коли їхня модель даних визначається зручністю на рівні сторінки, а не природним життєвим циклом окремих функцій, які знаходяться всередині них.

4. Видалення універсальних компонентів, якими керують десятки флагів

Поширеною першою реакцією на необхідність уникнення дублювання є створення надзвичайно налаштовуваних компонентів. Один панель може бути зробленим здатним до пошуку, вибору, сторінкування, редагування, експорту та згортання, причому все це керується все більшою кількістю булевих параметрів.

Це здається повторно використовуваним, оскільки, здається, воно покриває широкий спектр сценаріїв. Насправді кожен новий екран означає лише додавання ще однієї умови до списку.

<DataPanel
  searchable
  selectable
  showToolbar
  allowExport={canExport}
  inlineEdit={mode === "admin"}
  compact={isInsideModal}
  hidePagination={rows.length < 20}
  stickyHeader={!isMobile}
/>

Справжня проблема полягає не лише у великій кількості параметрів. Флаги взаємодіють між собою непередбачуваним чином. Редагування в рядку поводиться інакше після активації режиму вибору. Компактний формат потребує власної логіки панелі інструментів. Закріплений заголовок руйнується всередині певного контейнера з переповненням. Кількість можливих комбінацій флагів зростає набагато швидше, ніж люди можуть їх перевірити.

Компонент поступово перетворюється на другий додаток, який ховається всередині першого.

Заміна цього підходу, заснованого на флагах, означає віддачу переваги меншим, складовим елементам, а також окремим варіантам там, де відмінності є суттєвими. За потреби таблицю можна поєднати з інструментальною панеллю, контролем сторінкування та засобом вибору. Редагована таблиця може існувати як окрема функція, а не просто ще один булевий елемент, прихований усередині загального компонента таблиці.

Якщо два інтерфейси мають справжню спільну структуру, ця структура використовується безпосередньо. Якщо вони лише схожі на екранному знімку, але використовують різні процеси роботи, примушувати їх через одну спільну абстракцію вже не має сенсу.

Це призводить до повторення даних, які раніше були приховані, що спочатку може здаватися кроком назад. На практиці таке повторення зазвичай значно дешевше, ніж архітектура з гілками, яку воно замінює. Два невеликі, окремі компоненти можуть змінюватися незалежно, без необхідності перевірки кожної зміни щодо десятка непов’язаних комбінацій параметрів.

Повторне використання виправдовує себе, коли воно захищає єдиний, стабільний контракт. Воно стає ризикованим у момент, коли для цього один компонент використовується для прихованого представлення кількох різних продуктів одночасно.

5. Видалення стану модального вікна з сторінки, яка його відкрила

Великі компоненти часто виступають у ролі менеджерів модальних вікон. Вони відстежують, чи відкрите кожне діалогове вікно, який запис воно наразі редагує, на якому етапі процесу знаходиться, чи відбувається збереження, та яку помилку воно останньою показало.

Часто сама сторінка містить лише один рядок, який запускає відкриття модального вікна, проте все одно несе відповідальність за весь життєвий цикл цього діалогового вікна.

Це призводить до того, що стан залишається активним навіть тоді, коли діалогове вікно повністю невидиме. Правильне його закриття означає очищення вибраних записів, значень полів чернетки, помилок перевірки та запланованих запитів у певному порядку. Відкриття іншого діалогового вікна пізніше може призвести до випадкового повторного використання залишених значень з попередньо відкритого вікна.

Якщо розглядати будь-яке складне діалогове вікно як окрему функцію, а не як умовний JSX-елемент, доданий до сторінки, ця ситуація змінюється. Завданням сторінки стає визначення того, над яким записом користувач хоче виконати дію. Саме діалогове вікно керує даними чернетки, логікою перевірки, внутрішніми кроками та життєвим циклом надсилання даних.

У деяких випадках достатньо просто підключати діалогове вікно лише тоді, коли воно фактично відкрите, щоб автоматично скинути його тимчасовий стан. У інших випадках стан має зберігатися після закриття, тому він переміщується до спеціального режиму чернетки, замість того щоб залишатися пов’язаним із станом таблиці та фільтрів сторінки.

Це розділення також робить асинхронну поведінку значно безпечнішою. Діалогове вікно для редагування може скасувати або відкинути застарілі запити в момент свого закриття. Дія збереження може самостійно визначати, чи залишиться діалогове вікно відкритим у разі збою. Сторінці більше не потрібно координувати внутрішню механіку форм, якої вона насправді не розуміє.

Сторінка все ще контролює зв’язок між таблицею та діалоговим вікном — вона знає, який запис обраний та що має відбутися після успішної зміни. Але вона більше не керує кожним полем та переходом лише через те, що діалогове вікно було відкрито з цієї сторінки.

Модальне вікно може візуально розташовуватися поверх екрана, але це візуальне розташування не означає, що весь цикл життя його стану має знаходитися всередині компонента екрана.

6. Виокремлення перевірок дозволів з розкиданих JSX-елементів

Логіка авторизації зазвичай потрапляє до компонентів поступово. Кнопка приховується для звичайних користувачів, пункт меню вимикається після архівування запису, а певний розділ відображається лише для адміністраторів.

Ці умови множаться, оскільки JSX дозволяє легко додавати ще одну перевірку:

{user.role === "admin" && record.status !== "archived" && (
  <DeleteButton />
)}

Коли компонент стає досить великим, він може містити кілька трохи різних версій того, що, нібито, є однаковим правилом. Одна умова визначає, чи щось видиме, інша — чи активується відповідний обробник, а ще одна — чи сірим кольором позначений елемент меню. З часом ці копії починають відрізнятися та більше не узгоджуються між собою.

Це відхилення переважно є помилкою коректності, але воно також погіршує читабельність. Маркування макету заплутується з бізнес-правилами, і кожен, хто читає компонент, змушений у своїй голові аналізувати вирази дозволів, розкидані по всьому структурному дереву, щоб зрозуміти, що робить сторінка.

Замість того, щоб розкидати примітивні перевірки політик по JSX, можна заздалегідь обчислити чіткий набір можливостей:

const capabilities = getRecordCapabilities({
  user,
  record,
  organization,
});

Звідти компонент може просто перевірити, чи дозволено поточному користувачеві редагувати, архівувати, експортувати чи видаляти дану запис. Ці назви описують фактичні рішення щодо продукту, а не відображають первинні поля бази даних чи рядки з описом ролей.

Щоб усе було зрозуміло, це не переміщує механізми забезпечення безпеки на клієнтську сторону. Бекенд залишається основним механізмом авторизації — у цьому нічого не змінюється. Об’єкт можливостей на фронтенді існує виключно для того, щоб надати інтерфейсу єдине достовірне джерело інформації, замість того щоб знову формулювати однакові правила у п’яти різних візуальних елементах, які можуть непомітно вийти з синхронізації.

Це також значно полегшує тестування самих правил. Ви можете перевіряти логіку дозволів для різних ролей, конфігурацій власності, статусів та налаштувань організації, не генеруючи всю ієрархію компонентів. Коли змінюється основна політика, супутні компоненти зазвичай зовсім не потребують змін.

Великі дерева JSX стають крихкими, коли в них одночасно створюються бізнес-правила „на льоту“. Отримання змісту має полягати переважно у використанні вже прийнятих рішень, а не у їх повній переробці з нуля в кожному умовному розгалуженні.

7. Відмова від загальнодоступного завантаження сторінки та позначок помилок

Більшість великих компонентів спочатку мають просту структуру з одним значенням isLoading та одним значенням error. Це нормально, якщо сторінка виконує лише одну дію. Але коли функціонал розширюється, ці два показники починають використовуватися для опису кількох незалежних операцій одночасно.

Сторінка може отримувати початкові дані, оновлювати таблицю на фоні, зберігати форму, видаляти рядок та експортувати звіт — іноді все це протягом однієї сесії. Один спільний булевий показник завантаження не дозволяє визначити, яка саме з цих операцій зараз виконується. Одна заявка завершується та змінює показник на false, тоді як зовсім інша заявка все ще працює. Помилка, що виникає під час змін даних, перезаписує помилку, яка мала описувати початкове завантаження сторінки. Вся інтерфейсна система блокується лише через те, що відбувається яке-небудь незалежне оновлення на фоні.

Кращий підхід — це заміна цих флагів статусу для всієї сторінки на статус, який належить конкретній операції, що описується.

Це означає, що початковий запит може завантажуватися, тоді як існуюча таблиця залишається повністю видимою та інтерактивною. Один рядок може знаходитися на стадії видалення, не призводячи до недоступності всіх інших рядків на сторінці. Редактор може знаходитися в процесі надсилання даних, тоді як фільтри сторінки залишаються придатними для використання. Експорт може відображати власний індикатор прогресу, не свідчачи про те, що вся сторінка стала недоступною.

У результаті з’являється більше окремих значень статусу, причому кожне з них має однозначне значення. У коді не потрібно здогадуватися, до чого саме на будь-якому етапі посилається загальний показник isLoading.

Та сама логіка застосовується й до збоїв: не кожен з них мусить відображатися на єдиному верхньому рівні екрану помилок. Необов’язкова панель, яка не спрацювала, може самостійно показати опцію повторної спроби. Помилка мутації може залишатися обмеженою тим робочим процесом, який її спричинив. Сама головна сторінка стає недоступною лише тоді, коли дані, необхідні для її відображення, не вдалося завантажити.

Це створює більш стійкий інтерфейс та модель компонентів, яку легше розуміти. Статус більше не вважається глобальним лише через те, що операція знаходиться всередині великого компонента сторінки.

Великий компонент часто здається нестабільним, оскільки кілька різних робочих процесів змушені користуватися одним сигналом. Надання кожному робочому процесу власного статусу дозволяє інтерфейсу чесно відображати, що саме працює, а що — у будь-який момент часу.

Метою ніколи не були лише менші файли

Як тільки ці шаблони видаляються, багато компонентів дійсно стають коротшими — але це побічний ефект, а не справжня мета.

Справжньою перевагою є те, що всередині одних меж відображення приймається менше непов’язаних рішень. Дочірні компоненти отримують контракти, які визначають лише їхні обов’язки, а не стан усього функціоналу. Ідентичність посилань більше не призводить до мовчазної недійсності великих піддрощів під час кожного оновлення. Дані завантажуються відповідно до того, що насправді видно на екрані, а не за допомогою одного запиту розміром з сторінку. Компоненти, які можна повторно використовувати, більше не накопичують нові флаги для кожної можливої варіації, яка коли-небудь могла знадобитися.

Модали самостійно керують своїм станом. Правила дозволів перетворюються на названі можливості замість вбудованих умов. Стани завантаження та помилок належать конкретним операціям, які їх створюють.

Деякі екрани залишаються великими просто тому, що сам інтерфейс справді великий — і це нормально. Код стає простішим у роботі не тому, що він став меншим, а тому, що його розмір відображає реальну, видиму структуру замість прихованої логіки координації.

На цьому етапі важливе питання щодо компонента React — це не кількість рядків у ньому, а кількість незалежних причин, які можуть змусити його змінитися. Якщо для роботи з редактором потрібно також розуміти запит до таблиці, стан експорту, модель дозволів та кожне модальне вікно на сторінці, проблема не у форматуванні чи довжині файлу. Межі власності просто позначені не там.

Великі компоненти React залишаються керованими, як тільки вони перестають намагатися самостійно поводитися як вся програма.

Метою ніколи не було продовжувати розділяти файл, поки кожна функція не стане мініатюрною.

Мета полягає у тому, щоб кожна частина функціоналу знала лише про ті рішення, за прийняття яких вона насправді відповідає.

Пов’язана література

  • Продуктивність фронтенду: від недоліків перегляду коду до показників продукту — Дізнайтеся, чому достатньо лише пройти перегляд коду, які саме Core Web Vitals мають значення, та як вимірювати та усувати проблеми з продуктивністю React у реальних умовах.
  • Усунення проблем умов конкуренції: чому дебаунсинг не може допомогти у інтерфейсах пошуку — Дізнайтеся, чому сам дебаунсинг не може запобігти тому, що застарілі відповіді API перезаписують свіжий стан інтерфейсу, та ознайомтесь з чотирма практичними рішеннями для забезпечення правильної послідовності запитів.