Асинхронность в Redux: запросы и состояние
- Зачем это нужно
- Обработчик не умеет ждать
- Запрос как действие
- Три действия, и ни одного написанного руками
- Отказ значением или исключение
- Старое значение после отказа
- Читать состояние запроса
- Когда всё это уже написано за вас
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Куда углубиться
- Экзамен по блоку «Веб»
- Куда дальше: проверенные ресурсы
Зачем это нужно
Обработчик обязан быть вычислением: вход, выход, никаких запросов. Но состояние приложения почти целиком приходит из сети — баланс, список операций, подтверждение транзакции.
Значит, между «пользователь нажал» и «состояние изменилось» должно быть место, где живёт ожидание. В этой главе — что это за место, какие действия отправляются сами и почему после отказа в состоянии остаётся старое значение.
Этой главой заканчивается веб-блок: дальше — подключение кошелька и настоящие транзакции.
Обработчик не умеет ждать
Попытка сделать запрос в обработчике ломает главное свойство подхода: по списку действий больше нельзя воспроизвести состояние. Одинаковый вход перестаёт давать одинаковый выход, потому что между вызовами узел может ответить по-разному.
Поэтому запрос выносят наружу, а в хранилище отправляют три отдельных действия: «начали», «получилось», «не получилось». Обработчик по-прежнему остаётся вычислением — он просто раскладывает по состоянию то, что ему принесли.
Писать эти три действия руками не нужно.
Запрос как действие
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":"сеть недоступна"}
Разбор обработчиков:
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; });
},
Отказ значением или исключение
Отказ значением — для ожидаемого. Узел ответил «нет такого счёта», сервер вернул код 404, не хватает денег. Это не поломка, а обычный ход событий, и сообщение можно написать так, чтобы его было не стыдно показать.
Исключение — для неожиданного. Сеть упала, разбор ответа не удался, в коде ошибка. Текст придёт как есть; показывать его пользователю обычно нельзя, зато в списке действий он бесценен.
И то, и другое даёт одно и то же действие «не получилось» — разница только в том, где лежит причина. Отсюда и запись payload ?? error.message.
Сама отправка запроса не бросает наружу. Она возвращает значение, даже когда всё плохо. Поэтому конструкция «отправили и обернули в перехват» не работает так, как ожидается: перехват не сработает. Если нужно узнать исход прямо в компоненте, у результата отправки есть отдельный способ развернуть его в обычное обещание.
Старое значение после отказа
Посмотрите на состояние после отказа ещё раз:
{"status":"error","value":"1000.0","error":"узел ответил: -32000 unknown account"}
Баланс остался прежним. Обработчик отказа его не трогал — и это выбор, который вы делаете осознанно:
- оставить и пометить «устарело» — пользователь видит последнее известное значение и знает, что оно старое;
- очистить — честнее, но экран становится пустым на каждой сетевой икоте.
Для баланса обычно выбирают первое. Для списка операций после смены адреса — второе: показывать чужие операции хуже, чем пустой список.
Главное — чтобы это было решением, а не тем, что получилось само.
Читать состояние запроса
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» вместо человеческого объяснения.
Не решают, что делать со старым значением. Получается как получится: чужой баланс или чужие операции на экране.
Кладут в хранилище данные, которые прекрасно живут в средстве запросов. Три файла вместо одной строки.
Забыли отправку в списке зависимостей эффекта. Работать будет, но проверка на неполный список будет ругаться — и однажды окажется права.
Слова главы
- Запрос-действие — обёртка вокруг асинхронной работы; отправляет три действия сама.
- «Начали», «получилось», «не получилось» — три исхода, имена складываются из имени запроса.
- Отказ значением — для ожидаемого; причина попадает в нагрузку.
- Исключение — для неожиданного; текст приходит как есть.
- Реакция на чужие действия — обработчики среза, отвечающие на действия, созданные не им.
- Состояние запроса — покой, загрузка, готово, отказ; то же, что в главе про данные из сети.
- Гонка ответов — решена самой библиотекой: ответы устаревших запросов не применяются.
- Средство запросов — готовое решение для данных с сервера: запас, повтор, обновление.
Тренажёр: набрать вслепую
Цель: скорость ≥ 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 — три действия, отказ значением, отмена.
- Redux: асинхронная логика — пошагово, с состоянием запроса.
- RTK Query: обзор — когда обработчики писать не нужно.
- RTK Query: сравнение с thunk — где проходит граница.
- Redux Toolkit: TypeScript — типы для запросов-действий.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.