Що таке React custom hooks і як вони допомагають рефакторити компоненти на тисячі рядків

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Привіт, мене звати Сергій Оніщенко, я фронтенд-девелопер. Певний час працював на аутсорсі, cебе знайшов в аутстафі, де і працюю останні чотири роки переважно з React та React Native. Також займаюсь консультаціями щодо проєктів на React, react-native та інколи менторством. Маю досить різноманітний досвід роботи, за час своєї кар’єри був частиною команд з Європи та США, бачив різні підходи до розробки та різні практики написання коду. У цій статті хочу поділитись однією з них.

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

Користувацькі хуки як спосіб побудови багаторівневої архітектури

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

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

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

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

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

Бізнес-логіка, описана хуками

Як на мене, правильне використання користувацьких хуків — це явне впровадження Separation of Concerns принципу, що покриває 2 з 5 SOLID-принципів (Single Responsibility and Interface Segregation). Тобто ідея полягає в створенні самодостатніх модулів, доступних для використання через інтерфейс. Ми об’єднуємо наші функції та ефекти в хук (функцію вищого порядку) за зонами відповідальності без зовнішніх залежностей (High Cohesion and Low Coupling) з виділенням комплексної стейтфул-логіки.

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

Отже, бізнес-логіка — це код, описаний функціями та хуками, які, по суті, теж є функціями, тож ми можемо просто їх винести назовні та організувати за зонами відповідальності. Є певне обмеження неймінгу — назва кастомного хука повинна починатись з «use», наприклад useActiveManager.

Це потрібно з двох причин:

  1. Це говорить eslint про те, що ця функція є хуком та може містити юз-ефекти, стейт etc та повинна бути розміщена зверху компонента. Не забуваємо, що порядок розміщення звичайних функцій та хуків у компонентові може вплинути на результат рендерингу. Огорнути «іфом» хук не вийде :)
  2. Кращої читабельності під час код-рев’ю. За назвою ми можемо сказати, що компонент стейтфул або містить сторонні ефекти.

Наприклад, у нас є компонент вибору користувачів з додатковою логікою:

const UserSelection = () => {
 const [availableParticipants, setAvailableParticipants] = useState([]);
 const [selectedParticipants, setSelectParticipants] = useState([]);
 const [error, setError] = useState(null);
 
 useEffect(() => {
   const fetchParticipants = async () => {
     axios("https://jsonplaceholder.typicode.com/users")
       .then(({ data }) => setAvailableParticipants(data))
       .catch((err) => {
         console.error(err);
         setError(ERRORS.NETWORK_ERROR);
       });
   };
   fetchParticipants();
 }, []);
 
 useEffect(() => {
   if (selectedParticipants.length <= 2) {
     setError(null);
   }
 }, [selectedParticipants.length]);
 
 const deleteError = () => setError(null);
 
 const clearSlectedValues = () => {
   setSelectParticipants([]);
   deleteError();
 };
 
 const selectAvailableParticipant = (value) => {
   if (value.length > 2) {
     setError(ERRORS.CANT_SELECT_MORE);
   } else {
     setSelectParticipants(value);
   }
 };
 
 return (
   <div>
     <button onClick={clearSlectedValues}>Clear all selected values</button>
     <ListSelect
       className={styles.textInput}
       onChangeValues={selectAvailableParticipant}
       label="Select users"
       value={selectedParticipants}
       options={availableParticipants}
       filterSelectedOptions
       multiple
     />
     <Snackbar open={!!error} autoHideDuration={6000} onClose={deleteError}>
       <Alert severity="warning">{error}</Alert>
     </Snackbar>
   </div>
 );
};

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

const useUsersSelection = () => {
 const [availableParticipants, setAvailableParticipants] = useState([]);
 const [selectedParticipants, setSelectParticipants] = useState([]);
 const [error, setError] = useState(null);
 
 useEffect(() => {
   const fetchParticipants = async () => {
     axios("https://jsonplaceholder.typicode.com/users")
       .then(({ data }) => setAvailableParticipants(data))
       .catch((err) => {
    // тут може бути метод іншого хука який обробить помилку
         console.error(err);
         setError(ERRORS.NETWORK_ERROR);
       });
   };
   fetchParticipants();
 }, []);
 
 useEffect(() => {
   if (selectedParticipants.length <= 2) {
     setError(null);
   }
 }, [selectedParticipants.length]);
 
 const deleteError = () => setError(null);
 
 const clearSlectedValues = () => {
   setSelectParticipants([]);
   deleteError();
 };
 
 const selectAvailableParticipant = (value) => {
   if (value.length > 2) {
     setError(ERRORS.CANT_SELECT_MORE);
   } else {
     setSelectParticipants(value);
   }
 };
 
 return {
   availableParticipants,
   selectAvailableParticipant,
   selectedParticipants,
   error,
   clearSlectedValues,
   deleteError
 };
};

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

Після пророблених дій нам залишається викликати хук та отримати потрібні дані, і наш компонент буде мати такий вигляд:

const UserSelection = () => {
 const {
   availableParticipants,
   selectAvailableParticipant,
   selectedParticipants,
   error,
   clearSlectedValues,
   deleteError
 } = useUsersSelection();
 
 return (
   <div>
     <button onClick={clearSlectedValues}>Clear all selected values</button>
     <ListSelect
       className={styles.textInput}
       onChangeValues={selectAvailableParticipant}
       label="Select users"
       value={selectedParticipants}
       options={availableParticipants}
       filterSelectedOptions
       multiple
     />
     <Snackbar open={!!error} autoHideDuration={6000} onClose={deleteError}>
       <Alert severity="warning">{error}</Alert>
     </Snackbar>
   </div>
 );
};

Пропоную ознайомитися з прикладом.

Декомпозиція користувацьких хуків

Отже, ми покращили читабельність коду, код тепер більш читабельний, легко оптимізовується та тестується (наприклад). Інкапсулювали бізнес-логіку, розділили громіздкий компонент на рівень представлення та рівень бізнес-логіки. Наша UI більш незалежна. Далі можливо розділяти наш хук на ще менші і можливо перевикористовувані частини. А ще тепер легко можна оптимізувати модуль додавши useCallback та useMemo за необхідності в потрібні місця, що в свою чергу допоможе оптимізувати рендер, реалізувати кеш і т.д.

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

Одже код у хуках можливо а інколи і потрібно розділяти на менші перевикористовувані частини. У прикладі вище, додати хуки для роботи з API та для обробки помилок. Після того створити «батьківський» хук, де ми скомбінуємо потрібні нам менші хуки. Розділення хуків дає можливість реалізувати приватні частини, які будуть доступні лише для хуків верхнього рівня, наприклад:

// Хук для отримання внутрішніх даних
const useMessageToolInfo = () => {
 const { userRoom } = useContext(MessagesContext);
 const socket = SocketService.getInstance().getSocket();

 return {
   socket,
   room_id: userRoom.room_id 
 };
};

// Хук що використовує потрібні дані
const useMessagesToolEmit = () => {
 const { room_id, socket } = useMessageToolInfo();

 return {
   reactOnMessage: (reactionInfo) =>
     socket.emit('create_reaction', { room_id, ...reactionInfo })
 };
};

// Експортим лише хуки верхнього рівня
export {
 useMessagesToolEmit
};

Також прошу зауважити, що не потрібно всюди створювати користувацькі хуки. Якщо код невеликий та достатньо простий, як на мене, немає негайної потреби розділяти його на UI та користувацький хук. Як бонус для любителів все обмежувати: можна використовувати return Object.freeze(hookObjectInterface), щоб обмежити доступ до перезапису результату хуку. Аналогічно Class Free OOP підходу до функцій:

const useAddToNumber = (initialValue = 0) => {
  // private state
const [value, setValue] = useState(initialValue);
  // public, immutable interface
  return Object.freeze({
    addToNumber: number => setValue(prevNum => prevNum + number),
    value
  });
};

Також можна повертати результат, обгорнений new Proxy(hookObjectInterface, permissionHandler), де permissionHandler буде, наприклад, перевіряти, чи є в користувача доступ для роботи з камерою (корисно на React Native проєктах).


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

👍НравитсяПонравилось16
В избранноеВ избранном8
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Класна стяття, трішки з іншого боку подивився на використання хуків, зазвичай виношу сервісний рівень (апі методи, і т.д.) в них, і думаю куди б це приткнути бізнес-логіку з компонентів щоб це ще й легко тестувалось!
Головне з таким підходом не перетворити проект в hooks hell :)

Дякую, як і всюди потрібно дотримуватись балансу)

Цікавий приклад із Proxy для React Native, якраз останні версії підтримують це.

Дякую за статтю.
Кастомні хуки легко тестувати за допомогою testing library github.com/...​act-hooks-testing-library

Дякую, додав посилання в статтю можливо буде комусь корисним.

prnt.sc/1s79usk я в а*** с этих накрутчиков)

так власне немає) Хтось з підписників вирішив певно підтримати, або це прикол якийсь

дякую, але в цьому немає сенсу, на доу немає таких топів і це ні на що не впливає

Хм... Я конечно не реактовец, но зачем там второй эффект- только чтобы опять же продублировать условие и сбросить переменную ошибки в null аж через рендер реакта? Это же лишний рендер компонента, без него и так нормально

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

щоб розділити логіку ерор хендлінга

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

Саме так) Ідею як я розумію уловили. Реальні приклади з пректів паблішити важко бо вийде забагато коду, а я хотів лиш показати напрямок

Дуже хороша тема піднята, Сергію, дякую! Такий підхід у себе на проекті використовую вже доволі давно і з того часу простота роботи з кодом значно виросла :)

таким способом новачкам буде набагато легше розібратись на прокті коли все поділено на зони відповідальності

amen. іноді аж боляче від компонентів на 2к+ рядків.

З таким стикався тільки один раз, але у мене потрошку око сіпатися пчинає вже десь на 201-му рядку :D

сподіваюсь не в App.js 😁

Подписаться на комментарии