Integrating Stacks Smart Contract with React Frontend: A Complete Guide

Integrating a blockchain smart contract into a frontend can feel like juggling, you’ve got wallet connections, data type conversions, network management, and contract logic all in play. In this guide, I’ll walk you through how I connected my VeriFund Clarity smart contract to a modern React + TypeScript frontend using @stacks/connect and @stacks/transactions.
By the end, you’ll have a clear blueprint for building a fully interactive dApp on the Stacks blockchain from scratch.
Project Overview
VeriFund is a decentralized crowdfunding platform built on the Stacks blockchain that enables milestone-based funding with community governance.
Our stack includes:
Smart Contract: Written in Clarity (Stacks’ smart contract language)
Frontend: React with TypeScript, Vite, and modern UI components
Web3 Integration:
@stacks/connect
for wallet interactions and@stacks/transactions
for contract calls
1. Project Structure
bashCopyEditverifund/
├── contracts/ # Clarity smart contracts
│ └── verifund.clar
├── Verifund-Frontend/ # React frontend
│ └── verifund-frontend/
│ ├── src/
│ │ ├── hooks/ # Custom hooks for contract calls
│ │ ├── lib/ # Wallet provider, network config, types
│ │ └── pages/ # UI pages for reading/writing data
This separation keeps contract code, frontend logic, and UI cleanly organized.
2. Smart Contract Overview
The verifund.clar
contract powers campaign creation, funding, milestone voting, and fund withdrawal.
Core Functions:
create_campaign
– Creates a campaign with milestonesfund_campaign
– Lets users fund campaignsstart_milestone_voting
– Starts milestone votingapprove-milestone
– Funders vote on milestone completionwithdraw-milestone-reward
– Owners withdraw funds after milestone approval
Campaign Structure Example:
(define-map campaigns uint {
name: (string-ascii 100),
description: (string-ascii 500),
goal: uint,
amount_raised: uint,
balance: uint,
owner: principal,
status: (string-ascii 20),
category: (string-ascii 50),
milestones: (list 10 {...})
})
3. Setting Up Web3 Dependencies
We’ll need the Stacks libraries for wallet connection, transactions, and network configuration.
npm install @stacks/connect @stacks/network @stacks/transactions
Key packages:
@stacks/connect
– Handles wallet connections@stacks/transactions
– Creates and sends contract transactions@stacks/network
– Configures mainnet/testnet connections
4. Wallet Integration
We use a HiroWalletProvider
to manage wallet state across the app.
Why?
Keeps track of connection status
Stores addresses for testnet/mainnet
Provides authentication and disconnect functions
const HiroWalletContext = createContext<HiroWallet>(...);
export const HiroWalletProvider: FC<ProviderProps> = ({ children }) => {
const [isWalletConnected, setIsWalletConnected] = useState(false);
const authenticate = useCallback(async () => {
try {
await connect();
setIsWalletConnected(isConnected());
} catch (error) {
console.error('Connection failed:', error);
}
}, []);
return (
<HiroWalletContext.Provider value={{
isWalletConnected,
authenticate,
// other values...
}}>
{children}
</HiroWalletContext.Provider>
);
};
We also store the selected network (testnet
or mainnet
) in localStorage
so it persists across sessions.
5. Contract Interaction Hook
To keep things clean, I created a custom hook useContract
with two methods:
callContract
– For write operations (needs wallet signature)readContract
– For read-only operations (no signature needed)
export function useContract() {
const callContract = useCallback(async ({ functionName, functionArgs }) => {
return await request('stx_callContract', { functionName, functionArgs, network: "testnet" });
}, []);
const readContract = useCallback(async (functionName, functionArgs = []) => {
return await fetchCallReadOnlyFunction({
contractAddress,
contractName,
functionName,
functionArgs,
network
});
}, []);
return { callContract, readContract };
}
This separation makes the code easier to reason about.
6. Reading from the Contract
Example: Fetching all campaigns from get_campaign
useEffect(() => {
const loadCampaigns = async () => {
const countResult = await readContract('get_campaign_count');
const totalCampaigns = cvToValue(countResult);
const campaigns = await Promise.all(
Array.from({ length: totalCampaigns }).map((_, i) =>
readContract('get_campaign', [Cl.uint(i)])
)
);
setCampaigns(campaigns.map(cvToValue));
};
loadCampaigns();
}, [readContract]);
Key points:
Use
cvToValue()
to convert Clarity values to JSHandle microSTX → STX conversion by dividing by 1,000,000
7. Writing to the Contract
Example: Creating a new campaign
const handleSubmit = async () => {
try {
const milestones = Cl.list(campaignData.milestones.map(m =>
Cl.tuple({ name: Cl.stringAscii(m.title), amount: Cl.uint(m.amount) })
));
await callContract({
functionName: 'create_campaign',
functionArgs: [
Cl.stringAscii(campaignData.title),
Cl.stringAscii(campaignData.description),
Cl.uint(parseInt(campaignData.goal)),
Cl.stringAscii(campaignData.category),
milestones
]
});
alert('Campaign created successfully!');
} catch (error) {
alert('Failed to create campaign.');
}
};
Pro tips:
Use
Cl.*
helpers for Clarity type conversionUse
Cl.some()
andCl.none()
for optional valuesWrap calls in
try/catch
for user-friendly error messages
8. Error Handling & Best Practices
Good Practices:
Always show loading states for contract calls
Give meaningful error messages (e.g., insufficient funds, user cancelled)
Validate all inputs before calling the contract
Example:
try {
await callContract({...});
} catch (error) {
if (error.message.includes('insufficient funds')) {
setError('Insufficient funds.');
} else {
setError('Transaction failed.');
}
}
9. Key Learnings & Tips
Data Conversion is Everything – Always use
Cl.*
for sending andcvToValue()
for readingWallet Management Matters – Track connection state and network properly
Testnet First – Deploy and test on testnet before mainnet
Security – Validate user inputs and never expose sensitive data
Performance – Cache contract reads when possible for better UX
Conclusion
Integrating a Clarity smart contract with a modern React frontend is about more than just making function calls, it’s about handling data conversion, wallet state, error management, and keeping the UX smooth.
With this approach, you get:
Clean React architecture using hooks and context
Robust wallet integration via Hiro Wallet
Type-safe, error-handled contract interactions
A foundation you can extend into more complex Stacks dApps
You can check out the complete implementation in the VeriFund repository and start experimenting with your own blockchain-powered applications.
Subscribe to my newsletter
Read articles from Muritadhor Arowolo directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
