Перейти к основному содержимому
Блокчейн50ГЛАВА 50Асинхронность в Redux: запросы и состояниеdispatch(load)pendingfulfilledrejectedтри действия сами

Асинхронность в Redux: запросы и состояние

Прочитано0%
Квизы0/4
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

Обработчик обязан быть вычислением: вход, выход, никаких запросов. Но состояние приложения почти целиком приходит из сети — баланс, список операций, подтверждение транзакции.

Значит, между «пользователь нажал» и «состояние изменилось» должно быть место, где живёт ожидание. В этой главе — что это за место, какие действия отправляются сами и почему после отказа в состоянии остаётся старое значение.

Этой главой заканчивается веб-блок: дальше — подключение кошелька и настоящие транзакции.

Обработчик не умеет ждать​

Попытка сделать запрос в обработчике ломает главное свойство подхода: по списку действий больше нельзя воспроизвести состояние. Одинаковый вход перестаёт давать одинаковый выход, потому что между вызовами узел может ответить по-разному.

Поэтому запрос выносят наружу, а в хранилище отправляют три отдельных действия: «начали», «получилось», «не получилось». Обработчик по-прежнему остаётся вычислением — он просто раскладывает по состоянию то, что ему принесли.

Писать эти три действия руками не нужно.

Запрос как действие​

export const loadBalance = createAsyncThunk(
"vault/loadBalance",
async (address, { rejectWithValue }) => {
const res = await fetch(RPC, { method: "POST", body: … });
if (!res.ok) return rejectWithValue(`узел ответил ${res.status}`);
const data = await res.json();
if (data.error) return rejectWithValue(data.error.message);
return { address, value: formatEther(data.result) };
},
);
Нажми на часть выражения

Три действия, и ни одного написанного руками​

Прогон ставит в хранилище наблюдателя за всеми действиями и трижды запускает запрос: успешный, отказ узла, сетевая ошибка.

── успешный запрос ──
vault/loadBalance/pending
vault/loadBalance/fulfilled {"address":"0xf39F","value":"1000.0"}
состояние: {"status":"ok","value":"1000.0","error":null}

── узел отказал: отказ отдан значением ──
vault/loadBalance/pending
vault/loadBalance/rejected "узел ответил: -32000 unknown account"
состояние: {"status":"error","value":"1000.0","error":"узел ответил: -32000 unknown account"}

── исключение внутри: поймано и превращено в действие ──
vault/loadBalance/pending
vault/loadBalance/rejected error: сеть недоступна
состояние: {"status":"error","value":"1000.0","error":"сеть недоступна"}
ОДИН ЗАПРОС — ТРИ ДЕЙСТВИЯ, И НИ ОДНО НЕ НАПИСАНО РУКАМИdispatch(loadBalance(a))…/pendingотправляется сразуstatus: loading…/fulfilledзапрос удалсяstatus: ok, value: 1000.0…/rejectedзапрос не удалсяstatus: error, error: …имена складываются из имени среза, имени запроса и исхода — их видно в списке действий при отладке
На каждый запрос — «начали» и один из двух исходов; имена складываются сами

Разбор обработчиков:

extraReducers: (b) => {
b.addCase(loadBalance.pending, (s) => { s.status = "loading"; s.error = null; })
.addCase(loadBalance.fulfilled, (s, a) => { s.status = "ok"; s.value = a.payload.value; })
.addCase(loadBalance.rejected, (s, a) => { s.status = "error"; s.error = a.payload ?? a.error.message; });
},
Нажми на часть выражения

Отказ значением или исключение​

ДВА СПОСОБА СООБЩИТЬ ОБ ОТКАЗЕrejectWithValue(«…»)вы сами решаете, что попадёт в состояниеpayload: «узел ответил: −32000»годится для ожидаемого отказа узласообщение можно писать по-человеческиthrow new Error(«…»)исключение поймано и превращено в действиеpayload: нет, error.message: «сеть недоступна»годится для неожиданноготекст приходит как есть, часто непонятныйв обоих случаях отправка не бросает наружу: она возвращает значение, а не падаетпоэтому обработчик отказа читает сначала свою нагрузку, а потом уже текст исключения
Отказ значением попадает в нагрузку и пишется по-человечески; исключение приходит текстом как есть

Отказ значением — для ожидаемого. Узел ответил «нет такого счёта», сервер вернул код 404, не хватает денег. Это не поломка, а обычный ход событий, и сообщение можно написать так, чтобы его было не стыдно показать.

Исключение — для неожиданного. Сеть упала, разбор ответа не удался, в коде ошибка. Текст придёт как есть; показывать его пользователю обычно нельзя, зато в списке действий он бесценен.

И то, и другое даёт одно и то же действие «не получилось» — разница только в том, где лежит причина. Отсюда и запись payload ?? error.message.

Важно

Сама отправка запроса не бросает наружу. Она возвращает значение, даже когда всё плохо. Поэтому конструкция «отправили и обернули в перехват» не работает так, как ожидается: перехват не сработает. Если нужно узнать исход прямо в компоненте, у результата отправки есть отдельный способ развернуть его в обычное обещание.

Отвечено вопросов: 0 из 2
Почему запрос нельзя делать в обработчике?
Чем отказ значением отличается от брошенного исключения?
Отвечено верно: 0 из 2

Старое значение после отказа​

Посмотрите на состояние после отказа ещё раз:

{"status":"error","value":"1000.0","error":"узел ответил: -32000 unknown account"}
ЧТО ЛЕЖИТ В ХРАНИЛИЩЕ ПОСЛЕ КАЖДОГО ИСХОДАпосле успеха{ status: "ok", value: "1000.0", error: null }после отказа узла{ status: "error", value: "1000.0", error: "…-32000…" }после сетевой ошибки{ status: "error", value: "1000.0", error: "сеть недоступна" }обратите внимание: после отказа старое значение осталось лежать в состоянииэто решение, а не случайность: показывать прежний баланс с пометкой «устарел» или очищать — выбираете вы
После отказа прежнее значение осталось в состоянии — это решение, а не случайность

Баланс остался прежним. Обработчик отказа его не трогал — и это выбор, который вы делаете осознанно:

  • оставить и пометить «устарело» — пользователь видит последнее известное значение и знает, что оно старое;
  • очистить — честнее, но экран становится пустым на каждой сетевой икоте.

Для баланса обычно выбирают первое. Для списка операций после смены адреса — второе: показывать чужие операции хуже, чем пустой список.

Главное — чтобы это было решением, а не тем, что получилось само.

Отвечено вопросов: 0 из 2
После отказа в состоянии осталось прежнее значение баланса. Это ошибка?
Нужен ли флаг отмены против гонки ответов при использовании запроса-действия?
Отвечено верно: 0 из 2

Читать состояние запроса​

function Balance({ address }) {
const { status, value, error } = useAppSelector((s) => s.vault);
const dispatch = useAppDispatch();

useEffect(() => { dispatch(loadBalance(address)); }, [address, dispatch]);

if (status === "loading") return <Skeleton />;
if (status === "error") return <Retry message={error} onRetry={() => dispatch(loadBalance(address))} />;
return <b>{value} ETH</b>;
}

Три замечания.

Отправка вызывается в эффекте, а не в теле — по той же причине, что и обычный запрос.

Здесь выбран весь раздел вопреки правилу прошлой главы. Это осознанно: компоненту нужны все три поля, и любое их изменение всё равно требует перерисовки.

Гонка ответов решена сама. Библиотека следит за тем, какой запрос последний, и ответы предыдущих в состояние не попадают. То, что в главе про запросы приходилось писать флагом, здесь уже сделано.

Когда всё это уже написано за вас​

Написанное выше — типовое: загрузка, отказ, повтор, запас ответов, обновление. У той же библиотеки есть готовое средство, которое делает это по описанию запроса, без единого обработчика:

export const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: "/" }),
endpoints: (build) => ({
balance: build.query({ query: (address) => ({ url: "rpc", method: "POST", body: … }) }),
}),
});

// в компоненте
const { data, isLoading, error, refetch } = api.useBalanceQuery(address);

Оно само хранит ответы, само не отправляет повторный запрос за тем же, само обновляет данные после изменений и само даёт все состояния.

Когда брать что. Для данных, которые приходят и уходят, — готовое средство. Для состояния самого приложения (подключён ли кошелёк, какая сеть выбрана, что сейчас подписывается) — обычные срезы: это не данные с сервера, а ваше собственное состояние.

Совет

На чемпионате это различие экономит время. Баланс и список операций — данные; средство запросов закроет их само. Подключённый кошелёк и ход транзакции — состояние приложения; для них срез и запрос-действие из этой главы.

Типичные ошибки​

Запрос внутри обработчика. Ломает воспроизводимость.

Отправку оборачивают в перехват. Она не бросает — перехват не сработает никогда.

Не обнуляют прошлую ошибку при начале нового запроса. Старое сообщение висит поверх успешного ответа.

Читают только текст исключения. Отказ значением при этом теряется, и пользователь видит «Rejected» вместо человеческого объяснения.

Не решают, что делать со старым значением. Получается как получится: чужой баланс или чужие операции на экране.

Кладут в хранилище данные, которые прекрасно живут в средстве запросов. Три файла вместо одной строки.

Забыли отправку в списке зависимостей эффекта. Работать будет, но проверка на неполный список будет ругаться — и однажды окажется права.

Отвечено вопросов: 0 из 2
Отправку запроса-действия обернули в перехват ошибок. Что произойдёт?
Где проходит граница между средством запросов и обычным срезом?
Отвечено верно: 0 из 2

Слова главы​

  • Запрос-действие — обёртка вокруг асинхронной работы; отправляет три действия сама.
  • «Начали», «получилось», «не получилось» — три исхода, имена складываются из имени запроса.
  • Отказ значением — для ожидаемого; причина попадает в нагрузку.
  • Исключение — для неожиданного; текст приходит как есть.
  • Реакция на чужие действия — обработчики среза, отвечающие на действия, созданные не им.
  • Состояние запроса — покой, загрузка, готово, отказ; то же, что в главе про данные из сети.
  • Гонка ответов — решена самой библиотекой: ответы устаревших запросов не применяются.
  • Средство запросов — готовое решение для данных с сервера: запас, повтор, обновление.

Тренажёр: набрать вслепую​

Цель: скорость ≥ 95 зн/мин, точность ≥ 88%

export const loadBalance = createAsyncThunk("vault/loadBalance", async (address) => {});

На чемпионате​

Состояние запроса — на экране. Заготовка при загрузке, понятная причина при отказе, кнопка «повторить». Это видно проверяющему за секунду.

Отказ узла — человеческими словами. Отказ значением придуман ровно для этого.

Решите про старое значение заранее. Баланс — оставить с пометкой; операции после смены адреса — очистить.

Откройте список действий на защите. «vault/loadBalance/pending», потом «fulfilled» — это наглядный рассказ о том, что произошло.

И общий совет по блоку: интерфейс оценивают не по количеству библиотек, а по тому, понятно ли им пользоваться. Четыре состояния, понятные сообщения и работающая кнопка «повторить» дают больше, чем любая архитектура.

Куда углубиться​

  • Условие запуска — не отправлять запрос, если он уже идёт или данные свежие.
  • Отмена запроса-действия — и как она связана с прерыванием запроса.
  • Обновление данных после изменения — как средство запросов узнаёт, что список устарел.
  • Промежуточный обработчик под свои задачи — например, запись всех действий в журнал.
  • Прослойка запросов — как подменить обычный запрос на обращение к узлу через библиотеку кошелька.

Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Экзамен по блоку «Веб»​

Тринадцать глав: от первого тега до хранилища с запросами. Проверка — по всему блоку.

Экзамен по блоку: Веб-интерфейс

Сводная проверка по 13 главам блока: 20 вопросов, 10:00 на всё, по одному вопросу, без возврата. Пересдавать можно сколько угодно раз.

Готовность: экзамены глав сданы 0 из 13. Блок можно сдавать и так, но шанс выше после всех глав.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоАсинхронная работа с Redux Toolkit и createAsyncThunkМихаил Непомнящий
ВидеоУрок 4.4. Redux Toolkit / createAsyncThunk. Часть 1Web-garage
ВидеоУрок 4.5. Redux Toolkit / createAsyncThunk. Часть 2 / abort, rejectWithValue, conditionWeb-garage
ВидеоRTK Query или альтернативный Redux по работе с APIМихаил Непомнящий
ВидеоRTK Query: понятный курс на реальных примерахСалимов Руслан IT

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.