Why React dApp development breaks on transactions
A classic failure in React dApp development is when the interface and wallet show different states. The user signed a transaction, the screen switched to “success,” but on-chain it was rejected due to nonce or replaced by a competing send.
The app trusts its own node response and does not reconcile with the actual status, so the UI state desync does not surface immediately — usually through a support request.
The second group is dApp transaction errors that reproduce inconsistently. A repeated wallet signature on double-click creates a second action with the same meaning but a different hash: duplicate detection by hash does not work because the hash does not exist yet at send time.
Stuck transactions and waiting screens remain where confirmation is awaited via the subscription event, and it is lost when switching networks or reloading the page.
The third failure point is request cache. After confirmation, data is not invalidated, the interface shows the old balance and old allowance, and the user sends a repeated action.
Transaction replacement with the same nonce and a chain reorg of a couple of blocks are not handled at all. A stack like wagmi and viem removes some routine work, but does not eliminate synchronization logic — it is written on top and tested separately.
The cost of such an error is a growing number of manual investigations and loss of trust in the product out of nowhere, even though the business logic works. Let's break down how this is addressed.
What a predictable frontend gives: states, network, confirmation
While an operation lives in the state of a single component, any list remount, account switch, or network switch in the wallet turns the interface into a set of guesses.
The user sees “sent” even though the signature did not go through, or vice versa — the operation already exists on-chain, but the screen prompts them to press the button a second time. Each such case is a support request and manual log investigation.
We move operation status into a single source: a state machine with states from draft and signature to confirmation by a specified number of blocks and finalization.
Components subscribe to it instead of each polling the node on its own — the number of network requests drops, and UI parts stop showing contradictory data.
We handle network switching without reload: the switch request goes to the wallet, and if declined, the interface remains in its previous state and explains what is missing.
The session is restored on top of WalletConnect — the draft and operation history are stored locally; after reconnection, the status is reconciled with the network event log, so no signature is lost.
The effect is measurable: the share of operations requiring manual intervention drops to isolated cases, and support requests phrased as “transaction stuck” disappear along with the class of errors that generated them.
Incident investigation takes minutes — the log shows the state at which the operation was interrupted.
Wallet connection methods: what to choose for the task
In a React app frontend, wallet connection is the user's first action and the most common failure point: wrong provider, network switch, disconnection in the middle of signing. We compare working options by when each is appropriate and what trade-offs each entails.
The choice comes down to three questions: how many wallets need to be supported, whether there is a mobile scenario, and where the signature is verified — in the browser or on the service side.
Options and their boundaries
| Option | When it fits | Limitations |
|---|---|---|
| Ready-made connection components | Need quick coverage of desktop and mobile wallets | The library dictates state structure; non-standard login has to be worked around |
| Own implementation using direct provider calls | One or two wallets, non-standard flow, strict requirements for interface weight | Network switching, disconnection, and conflicts between multiple providers are handled by us |
| Service-side signing | Login without a transaction, linking a wallet to an account | Requires one-time messages, signature lifetime control, and replay protection |
| Built-in wallet in the app | Audience without wallets, need smooth login | Keys remain with us — higher security and access recovery requirements |
In practice, we take a combination: ready-made components cover wallet coverage, direct provider calls cover non-standard scenarios, and server-side signing covers user identification. We fix the choice in the specification so it does not resurface when adding a new wallet.
Work order: from auditing current code to handover
A dApp frontend audit starts not with the interface, but with an analysis of the current state: which networks are connected, how wallet state is stored, and where send failures are handled. We identify weak points and estimate the scope of work. Then everything proceeds in stages, and each ends with a verifiable result, not a progress report.
- dApp frontend audit. We analyze components, the network layer, signature and confirmation handling, and test coverage. The output is a prioritized map of problems: what breaks the user journey and what hinders development.
- Transaction flow diagram. We describe the operation lifecycle: initialization, wallet signature, sending, waiting for confirmation, cancellation or replacement, chain reorganization. The diagram becomes a contract between the interface and the logic.
- State design. We separate “pending,” “confirmed,” and “rejected”; each has its own text and next step for the user. Signature rejection and network rejection are handled differently.
- Implementation. We work in small changes, testing each in a testnet on real scenarios: insufficient funds, repeated send, connection loss.
- Scenario verification. We run the full path from wallet connection to confirmation, including network switching and re-login.
- Scenario acceptance. It follows a pre-agreed list: you see each step and confirm the behavior before release.
- Handover. Repository, transaction flow description, deployment instructions, and code walkthrough with your team.
What remains in the repository after handover
Project handover is not an archive of source code, but a reproducible set of decisions by which a new team spins up an environment and ships a change without archaeology through chat logs.
We define the set of artifacts as a readiness criterion: until each item below is in the repository and verified by a second engineer, the work is not considered delivered.
- Network configuration: chain IDs, contract addresses, primary and fallback read nodes, switching between production and testnet via environment variables without code changes.
- Description of application states: a list of screen states — wallet not connected, connected to the wrong network, transaction sent and awaiting confirmation, confirmed, rejected by the user — and exactly what the person sees in each case.
- Transaction error handling: failures are broken down by class (wallet rejection, insufficient funds for fee, contract refund, expired receipt wait), each class has its own UI text and a clear next step.
- Data refresh policy: when balances and statuses are re-read after confirmation so that numbers on the screen do not diverge from chain state.
- Team documentation: local setup, purpose of environment variables, where to change addresses when connecting a new environment, mandatory checks before release.
- Release and rollback procedure: build order, testnet run, conditions for reverting to the previous version.
- Decision log: why the project structure is this way and which options were rejected — so the next team does not redesign it from scratch.
Case: how we reduced the share of stuck operations
The client came with a React app where some transactions were lost between the wallet signature and on-chain confirmation.
Status was checked by polling on a timer: if the connection dropped, the request simply was not retried, and the user saw “sent” even though the operation did not go through.
A separate problem was double-clicking the button: the second signature created a duplicate, and it had to be investigated manually using node logs.
We built a unified transaction state layer on wagmi: subscription to confirmation events instead of polling, an idempotent key for each operation, and a retry queue with a step limit.
After page reload, the app reconciles unfinished hashes with the network and restores the current status — the “stuck” operation either completes or is honestly marked as an error.
Metrics before and after: the share of operations without confirmation fell from 9% to 0.4%. The number of repeated signatures on double-click went from 30 to zero, and the button is locked until the final status.
Recovery time after a connection failure dropped from manual reconciliation taking 10–15 minutes to a few seconds.
The main thing is that an entire class of failures disappeared: “the user signed, but the operation did not go through.” Confirmation became idempotent, state is restored from the network rather than from tab memory, so re-entering the app no longer creates duplicates.
How does sending a transaction differ from confirming it in React?
A return from writeContract means exactly one thing: the wallet signed the call and sent it to the network, returning a hash. The operation is not in a block yet — it is in the pending queue, and it can be dropped or replaced by a transaction with the same nonce. An interface that unlocks on this event shows “success” where the contract state has not changed.
Confirmation is a separate state, provided by useWaitForTransactionReceipt: it waits for a receipt with a status field and only then considers the operation executed.
Between these points lies the entire lifecycle: call simulation, signature, publication, inclusion in a block, accumulation of confirmations. Each transition deserves its own status, not a single boolean loading flag.
Call simulation before signing filters out reverts due to incorrect arguments or changed contract state: the user does not sign an obviously failing operation and does not see an error only after confirming in the wallet.
Wait time for inclusion is unpredictable — it depends on network load and the offered fee, so waiting for it with a fixed timer is pointless.
State is held on events, the button is unlocked by receipt rather than by hash, and replacement is tracked separately: a replaced operation will never get a receipt for the old hash.
A separate branch is user rejection in the wallet: this is not a network failure, and it must not be mixed with a send error, otherwise the React app interface scares people with red text where they simply changed their mind.
Technical doubts worth resolving before starting
We start addressing objections with a common case: a React frontend already exists, written by a previous team or in-house.
Before changing anything, we look at three points — how state is stored, where the transaction signing layer is isolated, and how network failures are handled. They show what to improve and what to rebuild.
Below is what we usually discuss before signing the plan.
We already have code — will you rewrite it?
No by default. If the transaction signing layer is isolated from the interface, we keep it and change only what gets in the way.
We rewrite when wallet logic is spread across components: then each new network breaks the layout, and maintenance becomes more burdensome than a careful rebuild.
Where does your responsibility end and ours begin?
We are responsible for the client side: wallet connection, sending and confirming transactions, interface states, and error handling. Contracts, keys, and access rights remain on your side — we treat them as an external interface and do not store secrets ourselves.
How will you add a new wallet or network?
We keep the connection layer separate from screens, so a new provider is configuration, not an interface rewrite. We test connection, signing, and network switching with it, and only then expose the button to users.
How do you test failures?
We separately reproduce user rejection and network failure: rejected signature, connection loss, long response, repeated send. Each case has a clear interface state; without it, the user presses the button a second time and gets a stuck transaction.
Let's discuss the task and come back with an estimate and plan
To make the estimate engineering-based rather than “by feel,” we need project inputs — the more specific the data, the fewer assumptions in the work plan and the more accurate the timelines.
What is already in the project is enough: repository, documentation, access to the test environment.
- addresses and networks of the contracts the interface will work with, and descriptions of their public methods;
- list of user scenarios: wallet connection, signature, transaction sending, waiting for confirmation, rejection;
- which wallets and connection methods must be supported — extension, mobile, hardware;
- design mockups or a link to the current interface, even drafts;
- access to environments: testnet, node or provider, read-only key;
- failure handling requirements: what we show for a rejected signature, a contract-side revert, and transaction replacement;
- current interface stack — React version, contract interaction libraries, build constraints.
Then it's our turn. Within two business days, we analyze the inputs, reconcile scenarios with the contracts, and come back with a work plan: stages, scope, dependencies, points where your decisions are required, and acceptance criteria for each scenario.
Describe the task in any convenient form — we will come back with an estimate and plan.







