Більшість фронтенд-коду сприймає асинхронну роботу як щось однотипне. Ви запускаєте Promise, чекаєте на його завершення, записуєте результат у локальний стан і дозволяєте фреймворку зіставити різницю (reconcile the diff). Цей патерн спокусливий, бо він працює всюди: REST-виклик, відправка форми, повідомлення WebSocket. Усі вони потрапляють в один і той самий useEffect або обробник подій, усі проходять через setState, і всі виглядають як ідентична асинхронна механіка. Ця одноманітність — пастка. У реальному застосунку не вся асинхронна робота однакова. Припускати протилежне — означає перетворювати ваші UI-компоненти на випадкових архітекторів даних, склеєних за допомогою хуків useEffect та надії на удачу.

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

Запити: факти з адресою

Запит — це не просто отримання даних (fetch). Це запит на ідентифікований факт. Ви запитуєте /user/123, а не «якісь дані користувача». Ця різниця важлива, оскільки саме ідентифікація робить кешування можливим. Якщо двом компонентам на одному екрані потрібен один і той самий запис користувача, вони мають отримувати одну й ту саму відповідь. Коли кожен компонент зберігає власну локальну копію в useState, ви фрагментуєте свою «істину». Аватар у хедері та ім'я у сайдбарі починають розходитися, тому що вони були отримані в різний час, або один запит завершився помилкою, а інший — успішно.

Думайте про запит як про ресурс, а не як про дію. Він має ключ кешу, політику свіжості та життєвий цикл, який триває довше за будь-який окремий компонент. Добре побудований шар запитів розуміє, що читання /projects?page=2 відрізняється від читання /projects?page=3. Кожен URL та набір параметрів формує адресу, а дані за цією адресою можуть бути застарілими, свіжими або відсутніми. UI не повинен займатися цим обліком. Він має запитувати у шару даних user:123 і отримувати знімок (snapshot). Чи прийшов цей знімок із сервера дві секунди тому, чи з кешу дві мілісекунди тому — компоненту не має жодного діла.

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

Мутації: зміна світу

Якщо запити запитують про світ, то мутації його змінюють. Сама мережева операція — POST, PUT або DELETE — зазвичай є найпростішою частиною. Найскладніше — це все, що відбувається після того, як сервер каже «OK».

Припустимо, користувач оновлює своє відображуване ім'я. Сама мутація — це один запит. Але радіус ураження охоплює все. Сторінка профілю містить старе ім'я. Панель навігації показує старе ім'я. Історія коментарів може посилатися на нього. Якщо ваш код мутації не робить нічого, окрім перемикання локального прапорця isLoading і оновлення однієї частини стану, ваш застосунок тепер бреше самому собі. Деякі частини UI вдають, що зміни відбулися. Інші навіть не знають, що щось змінилося.

Мутація повинна декларувати свій вплив на граф даних. Вона має повідомляти системі, які запити тепер є недійсними, які ключі кешу потрібно перечитати та які зв'язки змінилися. Це принципово відрізняється від запиту. Запит призначений лише для читання і є спільним. Мутація орієнтована на запис і руйнує існуючі кеші. Спроба звести їх до однієї абстракції призводить до того, що розробники змушені вручну викликати refetch() у випадкових компонентах або, що ще гірше, розкидати хуки useEffect по всьому дереву, щоб «синхронізувати» локальний стан із сервером.

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