Патерни заради патернів у React
Привіт, спільното! Мене звати Михайло, я Frontend / Full Stack Engineer. Мій основний стек — React.js + Next.js Це моя перша стаття на DOU (сподіваюся, не остання, якщо сподобається нашій шановній українській ІТ-спільноті).
У цьому матеріалі я хочу поділитися власними спостереженнями та роздумами, що виникли під час мого досвіду розробки на React, зокрема щодо застосування класичних патернів проєктування. Ця стаття буде цікава як початківцям, які тільки занурюються у світ React та архітектури, так і досвідченим розробникам, що прагнуть оптимізувати свій підхід до кодування та уникнути «надмірної інженерії».
Чи бувало у вас таке, що ви витрачаєте значний час на вивчення різних архітектурних підходів та патернів проєктування, аналізуєте безліч ресурсів і точок зору, але при спробі застосувати їх на практиці стикаєтеся з труднощами або усвідомлюєте, що вони не зовсім підходять для вашого поточного стеку технологій? Можливо, ми щось робимо не так, або ж це свого роду самонавіювання, викликане вимогами співбесід або гучними заголовками з інтернету, що обіцяють «20 дизайн-патернів, які повинен знати кожен React розробник»?
У цій статті ми зануримося в кілька підходів, розглянемо деякі дискусійні моменти та спірні думки, щоб прийти до якогось, можливо, не до кінця визначеного, але більш глибокого розуміння.
MV* patterns для не-MV* фреймворку: реальність чи аналогія
Багато розробників знайомі з такими MV* архітектурними патернами, як MVC (Model-View-Controller), MVP (Model-View-Presenter) та MVVM (Model-View-ViewModel). Читаючи класичні книги з патернів проєктування «Learning JavaScript Design Patterns» Адді Османі, ми стикаємося з широким спектром патернів (породжувальні, структурні, поведінкові), до яких ми повернемося пізніше. Але давайте спочатку зосередимося на MVC, MVP та MVVM.
Важливо розуміти, що патерни MVC, MVP і MVVM не є чимось, що «належить» React’у напряму. Скоріше, це архітектурні підходи, які можуть бути реалізовані з використанням React як інструменту відображення (View). React, за своєю суттю, є бібліотекою для побудови користувацьких інтерфейсів, що фокусується виключно на шарі View.
З особистих спостережень, найбільш доречним застосуванням концепції MVC я вважаю її інтеграцію в рамках архітектури FSD (Feature-Sliced Design). У контексті FSD, наприклад, на шарі features, цілком логічно розкласти логіку за ролями, які нагадують MVC:
📂 api/— Controller: для бекенд-взаємодій, включаючи хуки із запитами, виклики до сервера, обробку побічних ефектів, пов’язаних з даними.📂 ui/— View: для коду, який безпосередньо керує рендерингом та зовнішнім виглядом UI-компонентів.📂 model/— Model: для зберігання стану та всієї бізнес-логіки. Тут можуть використовуватися такі бібліотеки управління станом, як Zustand, Redux, Jotai, або просто локальні хуки React (useState,useReducer).
Важливо сказати, що «Структура хоч і є частиною архітектури, але не задає її».
Таким чином MVC не «належить» React, але його концепції можуть чудово допомагати структурувати код і зрозуміти ролі різних шарів, особливо у великих та складних проєктах.
Однак виникає питання: хіба ця аналогія тут справді необхідна? Хіба самого розуміння принципів роботи з FSD недостатньо, щоб писати структурований код? Якщо заглибитися в дослідження, то справедливіше буде сказати, що ті ж MVVM та MVC як архітектурні підходи спочатку були створені для таких фреймворків, як Backbone, KnockoutJS, Kendo MVVM, Knockback.js, де вони вирішували конкретні проблеми організації коду. До того ж FSD це тільки один з можливих архітектурних підходів, раджу роздивитись і інші.
У самому ж React традиційні MVC/MVVM патерни часто виявляються надлишковими або навіть недоречними. Причина в тому, що React вже оптимізує та спрощує багато аспектів управління станом та логікою «з коробки» завдяки своїм вбудованим механізмам (але про це пізніше).
Якщо говорити в контексті Next.js з його можливостями SSR (Server-Side Rendering) або SSG (Static Site Generation), то тут можна провести більш явну аналогію з MVC. Коли Next.js діє як бекенд, він взаємодіє з базою даних, обробляє запити і потім надає дані для View (React-компонентів). У цьому випадку:
- Controller: функції
getServerSideProps,getStaticProps, API-маршрути, які обробляють запити та взаємодіють з даними. - Model: логіка роботи з базою даних, ORM, сервіси для обробки даних.
- View: React-компоненти, які отримують дані від контролера та рендерять UI.
Однак навіть ця аналогія, хоча й технічно вірна для серверної частини Next.js, не змінює того факту, що сам React залишається бібліотекою для рендерингу View. Ця аналогія допомагає зрозуміти, як різні частини програми взаємодіють, але не обов’язково диктує, як має бути структурована логіка всередині самих React-компонентів на стороні клієнта.
Підсумуємо: ніхто не сперечатиметься, як і автор згаданої книги, що React не є фреймворком із групи MV*. Він не є таким, у нього немає жорстко визначеного «контролера» у традиційному сенсі, щоб бути ним. React — це, перш за все, бібліотека рендерингу, яка ідеально справляється з обробкою шару View. Його компонентно-орієнтована архітектура та реактивний підхід до управління станом часто дозволяють досягти цілей поділу відповідальності та тестування без необхідності примусового впровадження класичних патернів. Спроба насильно застосувати ці патерни там, де вони не органічні, може призвести до зайвої складності, збільшення «бойлерплейту» та зниження читабельності коду, що суперечить ідіоматичному підходу React до побудови користувацьких інтерфейсів.
Design Patterns для React: що далі
Переходячи до загальних патернів проєктування (породжувальні, структурні, поведінкові) у контексті React, важливо розуміти, що вони застосовуються не до архітектури всієї програми (як MVC), а до конкретних проблем проєктування всередині вашого React-коду. Наприклад:
- Породжувальні: можуть бути корисними для створення складних компонентів або елементів UI (наприклад,
Factory Methodдля створення різних типів кнопок на основі пропсів, абоBuilderдля побудови динамічних форм). - Структурні: допомагають в організації компонентів та їх взаємодії (наприклад,
Adapterдля інтеграції сторонніх бібліотек,Decoratorдля додавання функціональності компонентам без зміни їх вихідного коду,Compositeдля побудови складних UI-дерев). - Поведінкові: описують, як об’єкти взаємодіють та розподіляють обов’язки (наприклад,
Observerдля підписки на зміни стану,Strategyдля вибору алгоритму на основі умов).
Але все це вже є в React, навіщо нам писати якісний код, а потім перейматися тим, що «так, це у нас Observer, а тут Abstract Factory»? У вас може виникнути питання, як я зрозумів, що таке якісний код, а до цього питання ми повернемося в останньому блоці цієї статті.
Важливо не просто «знати» патерни, а розуміти проблеми, які вони вирішують, і коли їх застосування справді виправдане, а не сліпо слідувати модним тенденціям. Іноді «найкращий» патерн — це відсутність патерну, якщо простіше та прямолінійніше рішення з використанням стандартних механізмів React виявляється ефективнішим та зрозумілішим. Ризик «надмірної інженерії» (over-engineering) або «патерніту» (patternitis) полягає в тому, що код стає складнішим без реальної вигоди, що ускладнює його підтримку та розвиток (anti-patterns).
Мета завжди повинна полягати у створенні підтримуваного, читабельного та ефективного коду, а не в демонстрації знання конкретного патерну.
У React класичні JavaScript Design Patterns та MV* патерни не є тим, чим повинні керуватися розробники при продумуванні найкращого підходу до вирішення будь-якої проблеми. Ці патерни, що зародилися в інших парадигмах та контекстах (наприклад, у Vanilla JS, ASP.NET, або у фреймворках, де вимагався більш жорсткий поділ concerns), часто можуть бути краще реалізовані або навіть замінені ідіоматичними підходами React. Навіщо нам тоді спроби впровадити якийсь Design Pattern у нашу логіку, щоб показати, що «я знаю такий патерн»? За такою логікою може послідувати лише складність підтримки вже існуючого коду, а не його покращення.
Тож у підсумку, чому нам слідувати при написанні коду в React? Всевишньому світлу? На це питання у мене є своя думка.
Так а як тоді треба?
Щоб не звучати як розробник, який «проти всього і ні за що одночасно», тепер хочу підкреслити, що в екосистемі React у нас є потужні та більш органічні підходи, які вирішують усі необхідні проблеми, забезпечуючи високу якість та підтримуваність коду:
- Принципи SOLID: це фундаментальні принципи об’єктно-орієнтованого програмування та проєктування, які залишаються актуальними та застосовними в React, незважаючи на його функціональну парадигму. Вони допомагають створювати модульний, гнучкий та легко розширюваний код:
- Single Responsibility Principle (Принцип єдиної відповідальності): компонент або хук повинен мати лише одну причину для зміни. У контексті React це означає, що компонент має відповідати за один аспект UI, а хук — за один аспект логіки. Наприклад, один хук для роботи з API, інший — для управління локальним станом форми.
- Open/Closed Principle (Принцип відкритості/закритості): сутності повинні бути відкриті для розширення, але закриті для модифікації. У React це досягається за рахунок композиції компонентів, використання HOCs, Render Props або Custom Hooks, які дозволяють додавати функціональність без зміни існуючого коду.
- Liskov Substitution Principle (Принцип підстановки Барбари Лісков): об’єкти в програмі повинні бути замінними екземплярами їх підтипів без зміни правильності виконання програми. У React це проявляється в тому, що компоненти, які приймають однакові пропси, можуть бути взаємозамінними, не ламаючи батьківський компонент.
- Interface Segregation Principle (Принцип розділення інтерфейсу): клієнти не повинні залежати від інтерфейсів, які вони не використовують. У React це означає, що компоненти повинні приймати лише ті пропси, які їм дійсно потрібні, уникаючи передачі великих об’єктів пропсів, де лише мала частина використовується.
- Dependency Inversion Principle (Принцип інверсії залежностей): модулі високого рівня не повинні залежати від модулів низького рівня. Обидва повинні залежати від абстракцій. У React це часто реалізується через Context API або патерн Inversion of Control, де залежності (наприклад, сервіси API) передаються компонентам через пропси або контекст, а не жорстко імплементуються всередині.
- Ідіоматичні патерни React: замість того, щоб намагатися втиснути React у рамки класичних патернів, ми використовуємо патерни, які народилися та розвивалися разом із самою бібліотекою:
- Higher-Order Components (HOCs): функції, які приймають компонент і повертають новий компонент з додатковою функціональністю. Це потужний інструмент для повторного використання логіки компонентів.
- Render Props Pattern: техніка для обміну кодом між React компонентами з використанням пропса, значенням якого є функція. Дозволяє гнучко передавати логіку рендерингу.
- Hooks Pattern (Custom Hooks): сучасний і найбільш рекомендований спосіб повторного використання логіки стану та побічних ефектів між компонентами без зміни ієрархії компонентів. Це дозволяє ефективно відокремлювати логіку від UI, роблячи компоненти «чистішими» та легшими для тестування.
- State Hook (
useState/useReducer): вбудовані хуки для управління локальним станом компонента, що є основою реактивності React. - Context API: для ефективного управління глобальним станом та уникнення «prop drilling» (передачі пропсів через багато рівнів компонентів).
- Composition over Inheritance: основний принцип React, який заохочує побудову складних UI з простих, незалежних компонентів, передаючи дані та функції через пропси, а не через успадкування. Це робить код більш гнучким, зрозумілим та легким для масштабування.
- Dynamic Import / Code Splitting: патерни для оптимізації продуктивності, що дозволяють завантажувати код лише тоді, коли він дійсно потрібен, зменшуючи розмір бандлу та прискорюючи початкове завантаження сторінки.
- Controlled vs. Uncontrolled Components: патерни для управління формами та користувацьким введенням, що визначають, чи керується стан елемента форми React (контрольований) чи DOM (неконтрольований).
- Архітектурні підходи, такі як Feature-Sliced Design (FSD): FSD надає чіткі правила з організації коду, розділяючи його на шари та слайси за фічами, що саме по собі сприяє модульності, повторному використанню та підтримуваності, часто без необхідності явного накладання класичних патернів. Цей підхід допомагає масштабувати проєкти та підтримувати порядок у кодовій базі.
Усі ці підходи в сукупності вирішують більшість необхідних проблем, пов’язаних з архітектурою, управлінням станом, повторним використанням логіки та оптимізацією продуктивності в React-застосунках.
Тож тепер, як ви вважаєте, чи потрібні нам JavaScript Design Patterns або MV* Patterns для написання якісного, підтримуваного React-коду? Моя позиція полягає в тому, що хоча знання цих класичних патернів корисне для загального кругозору та розуміння історії розвитку програмної інженерії, у повсякденній React-розробці набагато ефективнішим та ідіоматичнішим є застосування принципів SOLID, а особливо React-патернів та сучасних архітектурних підходів. Ці інструменти дозволяють створювати чистий, масштабований та легкопідтримуваний код, уникаючи непотрібної складності, яка може виникнути при спробі примусово застосувати патерни, не призначені компонентно-орієнтованої парадигми.
8 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівДякую за статтю. Гарна подача.
На мою думку у більшості випадків треба юзати паттерни які юзає команда або найбільше знайома.
Або повинна буди відповідальна людина з належним досвідом, щоб все це танути та пояснювати.
Дякую за відгук!
Повністю згоден — командна узгодженість у патернах і наявність людини, яка може тримати технічний вектор, дійсно критично важливі. Часто й сам проєкт або підхід до розробки диктує вибір патернів.
Дуже радий, що стаття сподобалась!
Дякую, гарна стаття.
MVC складно натягнути на React насправді, кожен коли намагається це зробити отримує щось своє. Але є Model-View-Update, або ще це називається Elm архітектура, вона чудово вписується в React.
Щодо SOLID все ж таки ці принципи були придумані для OOП, і хоч їх також можна частково застосувати до React, багато проблем які SOLID вирішує в React просто не існує.
Я б додов, що в React варто глибоко розібратися з основними концепціями FP на яких побудовано React, а саме:
— іммутабельність і що вона дає
— чисті функції в чому їх перевага
— композиція функцій (чисті, компоненти, хуки)
— композиція об’єктів (агрегація, конкатенація, делегування)
Також для прийняття правильних рішень допомагають принципи KISS, DRY (+ WET), YAGNI
Дякую за розгорнутий коментар!
Так, справді — чисті функції та інші принципи FP мають ключове значення для розуміння React. Повністю згоден, що саме на них варто звертати увагу в першу чергу.
Окрема подяка за згадку Elm-архітектури та патерну MVU — це чудова ідея для майбутніх статей, обов’язково розгляну!
Дуже приємно, що мої матеріали читають і коментують досвідчені розробники. Дякую за зворотний зв’язок — разом стаємо сильнішими 💪
Дякую за статтю!
Дякую! Радий, що було цікаво
Класно подано! Цікаво і змістовно — дякую за корисну інформацію 💡
Дякую!