Wallets, transactions, and “did this actually go through?”
Introduction
Blockchain dApps have more state than a typical web app.
You’re not only tracking UI and API responses. You’re tracking:
- whether a wallet is connected,
- which chain you’re on,
- which transactions are signing, broadcasting, pending, confirmed, or failed,
- what the UI should show before the chain catches up (optimistic state).
If you don’t centralise that logic, you end up with:
- half a dozen ad‑hoc hooks,
- duplicated state in multiple components,
- a UI that disagrees with the wallet or the explorer.
Redux – and in practice, Redux Toolkit – is still a solid fit for this kind of cross‑cutting, multi‑component state, especially in complex dApps. The modern Redux docs explicitly push you toward slice‑based architecture, predictable updates, and serialisable state.¹
In this article I’ll focus on how I structure Redux state in a web3 frontend:
- what belongs in Redux vs local state vs data‑fetching libraries,
- how to model wallet connections and transaction lifecycles,
- how to do optimistic updates in a way that survives mainnet reality.
No code, just shapes, diagrams, and invariants you can map to your stack (Redux Toolkit, RTK Query, React Query, wagmi, etc.).
Prerequisites
I’ll assume you:
- know React and basic Redux / Redux Toolkit concepts (store, slices, actions, reducers, selectors),¹
- are comfortable with wallet libraries (MetaMask, RainbowKit, wagmi, Viem, etc.),⁷
- already have some backend/indexer or direct RPC calls for on‑chain data.
If you’re still writing “hand‑rolled” Redux, I recommend reading the Redux Style Guide and RTK usage docs first; they define most of the patterns I lean on.¹ ³
1. What state do blockchain dApps actually have?
React apps already juggle several kinds of state: local UI, global UI, URL, and server state. Web3 adds more:
- Wallet state : connected? which account(s)? which chain?
- Transaction state : signing, broadcasting, pending, confirmed, failed
- Session state : selected network, language, feature flags
- UI state : modals, toast queue, global loading banners
- Remote data : balances, positions, order books, metadata
The Redux docs are very clear on one point: you do not have to put all of this into Redux. Local, ephemeral UI (inputs, dropdowns, one‑off toggles) usually belongs in component state.² ⁸
A mapping I use in most dApps:
Kind of state Where it usually lives
-----------------------------------------------------------
Local form inputs React useState / useReducer
Component-only modals Local state or small UI context
Wallet connection Redux slice (or wallet lib + Redux bridge)
Tx lifecycle Redux slice (global, cross-cutting)
Server data (lists) React Query / RTK Query / Apollo
Global banners/toasts Redux slice
Redux is for shared, long‑lived, cross‑page concerns. Wallet and transaction state are exactly that.
2. High‑level Redux store design for a dApp
At the top level, my Redux state for a non‑trivial dApp usually looks like:
rootState
├─ wallet
├─ transactions
├─ session
├─ ui
└─ domain... (orders, pools, portfolios, etc.)
Rough responsibilities:
-
wallet Connection status, selected account(s), chain ID, wallet type, last error, capabilities.
-
transactions Normalised map of user‑initiated transactions the app cares about, including status, metadata, and any optimistic effects.
-
session App‑level choices: selected network (if you support multiple), language, time range, feature flags.
-
ui Global UI concerns: “connect wallet” modal, signature prompts, toast notifications, banners (“indexer behind chain head”).
-
domain… Optional slices for client‑only domain state that isn’t just server cache (e.g. a complex order builder wizard).
Server‑derived data (balances, pools, validator sets) generally lives in a data‑fetching layer (TanStack Query / RTK Query) rather than raw Redux slices. Modern guidance from both Redux and the broader ecosystem is very explicit about separating client state (Redux) from server state (React Query / RTK Query).⁵ ⁶ ¹⁷ ²¹
From the trenches. The messiest state trees I’ve seen tried to store everything in Redux: API responses, cache flags, wallet state, local form drafts… Rewriting them to “Redux for client state, query library for server state” immediately made reducers simpler and reasoning about bugs easier.
3. Wallet state: connections, chains, and errors
Libraries like wagmi / RainbowKit already maintain rich wallet state via hooks. You can build a perfectly fine dApp just off those hooks.⁷ ¹⁵ ¹⁹
I still like to mirror a simplified, dApp‑centric view of the wallet in Redux:
wallet slice
------------
status : "disconnected" | "connecting" | "connected" | "error"
addresses : [primary, ...] // or a single address if your UX assumes 1
chainId : current chain / network id
walletType : "metamask" | "walletConnect" | "ledger" | ...
lastError : last connection/signing error (code + message)
capabilities : {
canSignTypedData: boolean
canSwitchNetwork: boolean
...
}
This slice is driven by:
- wallet library events (
connect,disconnect,accountsChanged,chainChanged), - explicit user actions (“connect wallet”, “disconnect”),
- error callbacks from sign / send operations.
A few hard rules:
- Don’t store non‑serialisable objects here: no
provider,signer,connectioninstances in Redux. Keep those in module‑level variables, React context, or hooks. Redux’s own docs and middleware are explicit about keeping state serialisable.³ ⁴ ²⁰ - Treat the
walletslice as the source of truth for the rest of the app: if something cares about connected account or chain, it reads from Redux, not from half a dozen hooks.
This slice is also a natural place to hang cross‑cutting behaviour:
- show a global banner if
chainIdis unsupported, - track “eager connect” attempts and failures,
- log connection/sign failures for debugging.
4. Transaction state: from intent to on‑chain finality
Transaction state is where dApps diverge hardest from classic web apps.
The lifecycle usually looks like:
+---------+ sign +------------+ broadcast +-----------+
| idle | -------> | signing | ----------> | pending |
+---------+ +------------+ +-----------+
|
confirm / fail / |
timeout / drop v
+---------+
| final |
| ok/err |
+---------+
I model this via a transactions slice, normalised by a client ID or transaction hash:
transactions slice
------------------
byId: {
"": {
hash? : string | null // known after broadcast
chainId : string | number
from : string
to : string | null
type : "swap" | "deposit" | "withdraw" | "vote" | ...
status : "draft" | "signing" | "pending" | "confirmed" | "failed"
createdAt : number (timestamp ms)
lastUpdatedAt : number
error? : { code: string; message: string } | null
optimisticDelta?: OptimisticEffect | null
},
...
}
order: [ "", "", ... ]
Events that update this slice:
- user initiates an action → create a draft or signing entry;
- wallet confirms signing → attach
hash, move to pending; - indexer / RPC sees inclusion → move to confirmed;
- indexer / RPC sees failure → move to failed, attach error code/reason;
- timeout / mempool drop → mark as failed or unknown and surface in UI.
This gives you:
- a global “recent activity” view,
- per‑page hints (“this position has a pending change”),
- a single place to implement “retry”, “speed‑up”, or “cancel” flows.
Open‑source dApp frameworks that use Redux tend to follow very similar patterns: wallet + transactions as first‑class slices, sometimes with helpers like <TransactionLink> components reading from the Redux store.¹¹ ²³
5. Optimistic updates: making slow chains feel fast
If you wait for confirmations before updating anything, your dApp feels broken. Optimistic UI is how you bridge the gap between user intent and chain finality.
In Redux terms, an optimistic update looks like:
1) User clicks "swap":
- dispatch(startTx({ type: "swap", optimisticDelta: ... }))
2) Reducer:
- add a "pending" tx entry with optimisticDelta
- optionally update local domain state to reflect the change
3) Async:
- sign and send tx
- watch for inclusion / failure
4) Outcome:
- dispatch(confirmTx({ id, hash, receipt })) // success
- or dispatch(failTx({ id, error })) // failure
5) Reducer:
- on success: mark confirmed, keep delta or fold into "confirmed" state
- on failure: mark failed, roll back optimisticDelta
For balances and positions, I prefer an overlay model instead of mutating base values:
displayedBalance(asset) =
confirmedBalance(asset)
+ sum(optimisticCredits(asset))
- sum(optimisticDebits(asset))
The transactions slice stores the optimistic deltas; selectors compute the final displayed numbers.
This has nice properties:
- you can show both “on‑chain” and “including pending” views if needed;
- rollbacks are local (remove or flip the delta), not scattered through many reducers;
- reorgs are easier to handle: if a “confirmed” tx disappears, you can reclassify it and adjust derived state accordingly.
Redux Toolkit and RTK Query both have first‑class support and guidance for optimistic updates; RTK Query’s onQueryStarted and manual cache update utilities follow exactly this pattern.⁶ ¹⁰ ³¹ ²² ²⁸
The web3‑specific twist is that “failure” can include:
- mempool drop with no on‑chain trace,
- inclusion in a block that later gets orphaned,
- partial fills (for certain DEX designs),
- out‑of‑gas or revert with meaningful reason.
A centralised transactions slice makes those edge cases survivable.
6. Where Redux stops: server state and caching
Redux is not a replacement for your server cache. Modern guidance is pretty consistent:
- use Redux Toolkit for client state (UI, workflows, wallet, txs, app‑level flags),
- use React Query / RTK Query / Apollo for server‑derived state (API data, indexer results, chain history).⁵ ⁶ ¹³ ¹⁷ ²¹
In a dApp, “server state” typically includes:
- indexed on-chain data (swaps, orders, vaults, validator sets)
- address histories and event streams
- historical charts (TVL, volume, gas prices)
- off-chain APIs (limits, entitlements, KYC flags)
I let those stay in their respective caches and only reflect in Redux:
- current filters and sort orders
- which "view mode" is active (list vs chart)
- any optimistic overlays driven by pending txs
- derived flags (e.g. "portfolio has pending changes")
This keeps the Redux tree:
- smaller and more serialisable,
- easier to time‑travel in devtools,
- focused on what the frontend controls, not a mirror of backend responses.
7. Testing Redux state for web3 flows
The nice part about pushing wallet and tx logic into Redux slices is that you can test them without a browser or wallet.
For the wallet slice:
-
feed in sequences of actions representing realistic provider events:
connect→accountChanged→chainChanged→disconnect;
-
assert that the resulting state matches what the UI expects:
- unsupported network flags,
- cleared errors after reconnect,
- persisted choice of wallet type.
For the transactions slice:
-
simulate full lifecycles:
startTx→signSuccess→broadcast→confirmed;startTx→signError(user rejected);
-
check behaviour of timeouts and explicit cancels;
-
assert that optimistic overlays are added and removed correctly.
Selectors are pure functions, so you can test them against contrived state snapshots:
- given a set of confirmed balances + a set of pending txs
-> displayed balances match expectations
- after marking a tx as failed
-> optimistic overlay disappears, base state remains intact
Redux fundamentals emphasise treating reducers as pure and selectors as the primary way to read derived state; that applies cleanly here.² ²⁴ ²⁹
8. Production considerations
A few sharp edges that matter once this runs against real wallets and real money.
8.1 Serialisability (and what not to store)
Core Redux Toolkit guidance: state and actions should be serialisable.³ ⁴ ²⁰ ³⁰
That means:
- no
ethers.jsproviders, signers, sockets, or DOM nodes in Redux; - no big Maps, Sets, or class instances if you care about devtools/time‑travel;
- no circular references.
If you need to pass non‑serialisable things around:
- keep them outside Redux (context, module‑level singletons),
- or mark specific middleware checks as ignored only for selected fields/actions.
Wallet connectors and providers stay outside Redux. Redux only sees addresses, chain IDs, and status codes.
8.2 Persistence
redux-persist and similar tools make it trivial to persist state to localStorage. In a dApp this is both useful and dangerous.
I usually persist only:
- last used wallet type (if your UX reconnects automatically)
- selected network (when multiple networks are supported)
- non-sensitive preferences (theme, language, layout options)
and do not persist:
- raw transaction lifecycles (they go stale quickly),
- optimistic overlays (they make no sense after a reload),
- anything that depends on a live wallet connection.
On load, I reconcile:
- persisted
sessionanduistate, - a fresh
walletstate from the actual wallet connector, - a fresh
transactionssnapshot from server (if I need historical local activity at all).
8.3 Integrating with multi‑chain dApps
For multi‑chain dApps, I treat chainId as a first‑class piece of session + wallet state:
session.chainId // what the app is pointed at
wallet.chainId // what the wallet is connected to (may differ)
That gives you three obvious states:
- no wallet, session.chainId set -> "connect on this network"
- wallet on same chain -> "ready"
- wallet on different chain -> "network mismatch" UI
All of that lives cleanly in Redux slices and selectors. It’s also where state‑management articles for Web3 dApps tend to focus: keep chain and wallet status globally consistent so your UI doesn’t drift.¹⁵ ¹⁹ ³²
Conclusion
Redux is not mandatory for dApps. Plenty of small projects get by with React context and a couple of hooks.
But Redux (via Redux Toolkit) maps very cleanly onto the hard problems serious dApps have:
- wallet status that affects the entire UI,
- transactions that span multiple pages and sessions,
- optimistic state that must stay consistent across views,
- multi‑chain and multi‑wallet workflows.
If you:
- treat wallet and transactions as first-class Redux slices,
- keep server data in a dedicated query/cache layer,
- model optimistic effects as overlays, not blind mutations,
- respect serialisability and avoid stuffing providers into state,
- test the slices and selectors as pure logic,
you end up with a dApp whose behaviour you can actually reason about:
- users see consistent status across the app,
- optimistic flows feel snappy without lying,
- and you have a single, inspectable source of truth for “what did we think just happened on-chain?”.
That’s a much better place to be when the chain, the node, or the wallet inevitably misbehave.
References & further reading
- Redux docs on state design, organising state, and the official style guide.(redux.js.org)
- Modern overviews of React state management and when you actually need an external library.(developerway.com)
- Web3/dApp architecture guides emphasising wallet integration, transaction feedback, and state libraries like Redux.(rapidinnovation.io)
- Articles on optimistic UI and optimistic updates with Redux/Redux Toolkit and RTK Query.(SurveySparrow Engineering)