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 milestones

  • fund_campaign – Lets users fund campaigns

  • start_milestone_voting – Starts milestone voting

  • approve-milestone – Funders vote on milestone completion

  • withdraw-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 JS

  • Handle 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 conversion

  • Use Cl.some() and Cl.none() for optional values

  • Wrap 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 and cvToValue() for reading

  • Wallet 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.

0
Subscribe to my newsletter

Read articles from Muritadhor Arowolo directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Muritadhor Arowolo
Muritadhor Arowolo