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

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Привіт, мене звати Сергій Оніщенко, я фронтенд-девелопер. Певний час працював на аутсорсі, 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 = () => {
     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 також може бути окремим хуком для роботи з API
 // як приклад: https://www.smashingmagazine.com/2020/07/custom-react-hook-fetch-cache-data/
 useEffect(() => {
   const fetchParticipants = () => {
     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 у майбутньому, адже саме їхнє створення допомагає мені на проєктах рефакторити компоненти на тисячі рядків.

👍ПодобаєтьсяСподобалось23
До обраногоВ обраному9
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

Не нравится мне какой подход в виде

useUsersSelection

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

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

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

«Не все сіре то э вовк». В данном примере в этом нет необходимости.
Если действительно хотите следовать этим принципам обратите внимание на получение данных:

useEffect(() => {
   const fetchParticipants = async () => {
     axios("https://jsonplaceholder.typicode.com/users")
       .then(({ data }) => setAvailableParticipants(data))
       .catch((err) => {
         console.error(err);
         setError(ERRORS.NETWORK_ERROR);
       });
   };
   fetchParticipants();
 }, []);
Вынесите это в отдельный файл, выполните валидацию полученных данных, перед тем как передать данные в стейт выполните проверку на существование компонента.

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

Основна суть статті в тому що складна бізнес логіка повинна виноситись в хуки навіть якщо вони більше ніде не будуть перевикористані.

Зачем, кто вам такое сказал? Я вижу в данном подходе намеренное усложнение, как результат больше источников ошибок.
Если интересно, вот мои примеры:
github.com/...​thFetchingDataOnDemand.js
github.com/...​onents/Cart/CartWindow.js
github.com/...​ents/Login/LoginWindow.js

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

В React, компонент есть и модель и представление это нужно понимать и не нужно усложнять там где в этом нет никакой надобности.
Хотите разобраться когда нужно выносить логику работы компонента в отдельный хук, посмотрите на react-table, formik, material-ui/useAutocomplete

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

обратите внимание на получение данных:

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

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

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

Інколи потрібно апі методи викликати не з компонентів, як тоді поступаєш?

У мене зазвичай немає такої потреби, так як все на хуках та контексті, якщо в двух словах. Використовуємо react-query для роботи з апі. Самий простий варіант це винести запити в окремі функції або класи, якщо це просто axios або fetch виклики.

Нариклад потрібно в axios перехоплювачі для помилок відловити 401 та зробити лоґаут. Якщо потрібно звідти виконати логіку яка написана в react-query.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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