Що таке React custom hooks і як вони допомагають рефакторити компоненти на тисячі рядків
Привіт, мене звати Сергій Оніщенко, я фронтенд-девелопер. Певний час працював на аутсорсі, 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.
Це потрібно з двох причин:
- Це говорить eslint про те, що ця функція є хуком та може містити юз-ефекти, стейт etc та повинна бути розміщена зверху компонента. Не забуваємо, що порядок розміщення звичайних функцій та хуків у компонентові може вплинути на результат рендерингу. Огорнути «іфом» хук не вийде :)
- Кращої читабельності під час код-рев’ю. За назвою ми можемо сказати, що компонент стейтфул або містить сторонні ефекти.
Наприклад, у нас є компонент вибору користувачів з додатковою логікою:
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 у майбутньому, адже саме їхнє створення допомагає мені на проєктах рефакторити компоненти на тисячі рядків.
30 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів