Part 3: Building a Web App
In this final part of the onboarding series, you'll build a React web application that provides a user-friendly interface for interacting with your smart contract. You'll learn modern Web3 frontend patterns and Neutron-specific integrations.
What You'll Build
A modern React app with:
- Wallet connection (Keplr integration)
- Real-time contract state display
- Transaction execution with user feedback
- Price monitoring from Oracle module
- Responsive, beautiful UI
Prerequisites
- Completed Part 2: Modules & Contracts
- Node.js and npm installed
- Basic React knowledge
- Deployed contract from previous tutorials
Project Setup
1. Initialize React App
# Create React app with TypeScript
npx create-react-app neutron-counter-app --template typescript
cd neutron-counter-app
# Install Neutron and Cosmos dependencies
npm install @cosmjs/stargate @cosmjs/proto-signing @keplr-wallet/types
npm install @neutron-org/neutronjs @chakra-ui/react @emotion/react @emotion/styled framer-motion
# Install development dependencies
npm install --save-dev @types/node
2. Configure Environment
Create .env file:
REACT_APP_CHAIN_ID=neutron-testing-1
REACT_APP_RPC_URL=http://localhost:26657
REACT_APP_CONTRACT_ADDRESS=your_contract_address_here
3. Set Up Wallet Integration
Create src/hooks/useWallet.ts:
import { useState, useEffect } from 'react';
import { Window as KeplrWindow } from '@keplr-wallet/types';
declare global {
interface Window extends KeplrWindow {}
}
export interface WalletState {
address: string | null;
isConnected: boolean;
isConnecting: boolean;
}
export const useWallet = () => {
const [wallet, setWallet] = useState<WalletState>({
address: null,
isConnected: false,
isConnecting: false,
});
const connectWallet = async () => {
if (!window.keplr) {
alert('Please install Keplr extension');
return;
}
setWallet(prev => ({ ...prev, isConnecting: true }));
try {
// Enable Neutron chain
await window.keplr.enable(process.env.REACT_APP_CHAIN_ID!);
// Get the offline signer
const offlineSigner = window.keplr.getOfflineSigner(process.env.REACT_APP_CHAIN_ID!);
const accounts = await offlineSigner.getAccounts();
setWallet({
address: accounts[0].address,
isConnected: true,
isConnecting: false,
});
} catch (error) {
console.error('Failed to connect wallet:', error);
setWallet(prev => ({ ...prev, isConnecting: false }));
}
};
const disconnectWallet = () => {
setWallet({
address: null,
isConnected: false,
isConnecting: false,
});
};
// Check if wallet is already connected on page load
useEffect(() => {
const checkConnection = async () => {
if (window.keplr) {
try {
const key = await window.keplr.getKey(process.env.REACT_APP_CHAIN_ID!);
setWallet({
address: key.bech32Address,
isConnected: true,
isConnecting: false,
});
} catch (error) {
// Not connected, do nothing
}
}
};
checkConnection();
}, []);
return {
...wallet,
connectWallet,
disconnectWallet,
};
};
4. Create Contract Interface
Create src/hooks/useContract.ts:
import { useState, useEffect, useCallback } from 'react';
import { SigningStargateClient } from '@cosmjs/stargate';
import { MsgExecuteContract } from 'cosmjs-types/cosmwasm/wasm/v1/tx';
import { Window as KeplrWindow } from '@keplr-wallet/types';
declare global {
interface Window extends KeplrWindow {}
}
interface ContractState {
count: string;
lastPrice: string;
threshold: string;
}
interface UseContractReturn {
contractState: ContractState | null;
isLoading: boolean;
increment: (amount: string) => Promise<void>;
checkPriceAndIncrement: (amount: string) => Promise<void>;
refreshState: () => Promise<void>;
}
export const useContract = (address: string | null): UseContractReturn => {
const [contractState, setContractState] = useState<ContractState | null>(null);
const [isLoading, setIsLoading] = useState(false);
const queryContract = useCallback(async () => {
if (!address) return;
try {
const client = await SigningStargateClient.connectWithSigner(
process.env.REACT_APP_RPC_URL!,
window.keplr!.getOfflineSigner(process.env.REACT_APP_CHAIN_ID!)
);
// Query contract state
const countQuery = await client.queryContractSmart(
process.env.REACT_APP_CONTRACT_ADDRESS!,
{ get_count: {} }
);
const priceQuery = await client.queryContractSmart(
process.env.REACT_APP_CONTRACT_ADDRESS!,
{ get_last_price: {} }
);
const configQuery = await client.queryContractSmart(
process.env.REACT_APP_CONTRACT_ADDRESS!,
{ get_config: {} }
);
setContractState({
count: countQuery.count,
lastPrice: priceQuery.price,
threshold: configQuery.price_threshold,
});
} catch (error) {
console.error('Failed to query contract:', error);
}
}, [address]);
const executeContract = useCallback(async (msg: any) => {
if (!address || !window.keplr) return;
setIsLoading(true);
try {
const client = await SigningStargateClient.connectWithSigner(
process.env.REACT_APP_RPC_URL!,
window.keplr.getOfflineSigner(process.env.REACT_APP_CHAIN_ID!)
);
const executeMsg = {
typeUrl: '/cosmwasm.wasm.v1.MsgExecuteContract',
value: MsgExecuteContract.fromPartial({
sender: address,
contract: process.env.REACT_APP_CONTRACT_ADDRESS!,
msg: new TextEncoder().encode(JSON.stringify(msg)),
funds: [],
}),
};
const fee = {
amount: [{ denom: 'untrn', amount: '2000' }],
gas: '200000',
};
const result = await client.signAndBroadcast(address, [executeMsg], fee);
if (result.code === 0) {
console.log('Transaction successful:', result.transactionHash);
await queryContract(); // Refresh state
} else {
throw new Error(`Transaction failed: ${result.rawLog}`);
}
} catch (error) {
console.error('Transaction failed:', error);
throw error;
} finally {
setIsLoading(false);
}
}, [address, queryContract]);
const increment = useCallback(async (amount: string) => {
await executeContract({ increment: { amount } });
}, [executeContract]);
const checkPriceAndIncrement = useCallback(async (amount: string) => {
await executeContract({ check_price_and_increment: { amount } });
}, [executeContract]);
const refreshState = useCallback(async () => {
await queryContract();
}, [queryContract]);
// Query contract state when address changes
useEffect(() => {
if (address) {
queryContract();
}
}, [address, queryContract]);
return {
contractState,
isLoading,
increment,
checkPriceAndIncrement,
refreshState,
};
};
5. Build the UI Components
Create src/components/WalletConnect.tsx:
import React from 'react';
import { Button, Box, Text } from '@chakra-ui/react';
import { useWallet } from '../hooks/useWallet';
export const WalletConnect: React.FC = () => {
const { address, isConnected, isConnecting, connectWallet, disconnectWallet } = useWallet();
return (
<Box p={4} borderWidth={1} borderRadius="md" mb={4}>
<Text fontSize="lg" mb={2}>Wallet Connection</Text>
{isConnected ? (
<Box>
<Text fontSize="sm" color="gray.600" mb={2}>
Connected: {address?.slice(0, 10)}...{address?.slice(-8)}
</Text>
<Button colorScheme="red" size="sm" onClick={disconnectWallet}>
Disconnect
</Button>
</Box>
) : (
<Button
colorScheme="blue"
onClick={connectWallet}
isLoading={isConnecting}
loadingText="Connecting..."
>
Connect Keplr Wallet
</Button>
)}
</Box>
);
};
Create src/components/ContractInterface.tsx:
import React, { useState } from 'react';
import {
Box,
Text,
Button,
Input,
VStack,
HStack,
useToast,
Badge,
Divider,
} from '@chakra-ui/react';
import { useWallet } from '../hooks/useWallet';
import { useContract } from '../hooks/useContract';
export const ContractInterface: React.FC = () => {
const { address, isConnected } = useWallet();
const { contractState, isLoading, increment, checkPriceAndIncrement, refreshState } = useContract(address);
const [incrementAmount, setIncrementAmount] = useState('1');
const toast = useToast();
const handleIncrement = async () => {
try {
await increment(incrementAmount);
toast({
title: 'Success!',
description: `Incremented counter by ${incrementAmount}`,
status: 'success',
duration: 5000,
isClosable: true,
});
} catch (error) {
toast({
title: 'Error',
description: 'Failed to increment counter',
status: 'error',
duration: 5000,
isClosable: true,
});
}
};
const handlePriceIncrement = async () => {
try {
await checkPriceAndIncrement(incrementAmount);
toast({
title: 'Success!',
description: 'Price check passed, counter incremented',
status: 'success',
duration: 5000,
isClosable: true,
});
} catch (error) {
toast({
title: 'Error',
description: 'Price check failed or increment failed',
status: 'error',
duration: 5000,
isClosable: true,
});
}
};
if (!isConnected) {
return (
<Box p={4} borderWidth={1} borderRadius="md" textAlign="center">
<Text>Please connect your wallet to interact with the contract</Text>
</Box>
);
}
return (
<Box p={6} borderWidth={1} borderRadius="lg" shadow="md">
<Text fontSize="xl" mb={4} fontWeight="bold">Smart Contract Interface</Text>
{contractState && (
<VStack align="stretch" spacing={4}>
{/* Contract State Display */}
<Box p={4} bg="gray.50" borderRadius="md">
<Text fontSize="lg" mb={2}>Contract State</Text>
<HStack justify="space-between">
<Text>Current Count:</Text>
<Badge colorScheme="blue" fontSize="md">{contractState.count}</Badge>
</HStack>
<HStack justify="space-between" mt={2}>
<Text>Last ATOM Price:</Text>
<Badge colorScheme="green" fontSize="md">${contractState.lastPrice}</Badge>
</HStack>
<HStack justify="space-between" mt={2}>
<Text>Price Threshold:</Text>
<Badge colorScheme="orange" fontSize="md">${contractState.threshold}</Badge>
</HStack>
</Box>
<Divider />
{/* Controls */}
<VStack spacing={3}>
<HStack width="100%">
<Text>Amount to increment:</Text>
<Input
value={incrementAmount}
onChange={(e) => setIncrementAmount(e.target.value)}
placeholder="Enter amount"
size="sm"
width="100px"
type="number"
min="1"
max="100"
/>
</HStack>
<VStack width="100%" spacing={2}>
<Button
colorScheme="blue"
onClick={handleIncrement}
isLoading={isLoading}
loadingText="Processing..."
width="100%"
>
Simple Increment
</Button>
<Button
colorScheme="green"
onClick={handlePriceIncrement}
isLoading={isLoading}
loadingText="Checking Price..."
width="100%"
>
Price-Checked Increment
</Button>
<Button
variant="outline"
onClick={refreshState}
width="100%"
size="sm"
>
Refresh State
</Button>
</VStack>
</VStack>
</VStack>
)}
</Box>
);
};
6. Main App Component
Update src/App.tsx:
import React from 'react';
import { ChakraProvider, Container, VStack, Heading, Text } from '@chakra-ui/react';
import { WalletConnect } from './components/WalletConnect';
import { ContractInterface } from './components/ContractInterface';
function App() {
return (
<ChakraProvider>
<Container maxW="md" py={8}>
<VStack spacing={6}>
<VStack textAlign="center">
<Heading size="lg">Neutron Counter DApp</Heading>
<Text color="gray.600">
A decentralized counter with Oracle price integration
</Text>
</VStack>
<WalletConnect />
<ContractInterface />
<Text fontSize="sm" color="gray.500" textAlign="center">
Built with React, CosmJS, and Neutron
</Text>
</VStack>
</Container>
</ChakraProvider>
);
}
export default App;
Advanced Features
1. Transaction History
Add transaction monitoring:
// In useContract hook
const [transactions, setTransactions] = useState<Array<{
hash: string;
type: string;
timestamp: number;
status: 'pending' | 'success' | 'failed';
}>>([]);
const executeContract = useCallback(async (msg: any, type: string) => {
// ... existing code ...
const txId = Date.now().toString();
setTransactions(prev => [...prev, {
hash: txId,
type,
timestamp: Date.now(),
status: 'pending'
}]);
try {
const result = await client.signAndBroadcast(address, [executeMsg], fee);
setTransactions(prev =>
prev.map(tx =>
tx.hash === txId
? { ...tx, hash: result.transactionHash, status: 'success' }
: tx
)
);
} catch (error) {
setTransactions(prev =>
prev.map(tx =>
tx.hash === txId
? { ...tx, status: 'failed' }
: tx
)
);
throw error;
}
}, [address, queryContract]);
2. Real-time Updates
Add WebSocket connection for real-time updates:
useEffect(() => {
const ws = new WebSocket('ws://localhost:26657/websocket');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.result?.events?.some((e: any) =>
e.type === 'wasm' &&
e.attributes?.some((a: any) =>
a.key === 'contract_address' &&
a.value === process.env.REACT_APP_CONTRACT_ADDRESS
)
)) {
queryContract(); // Refresh on contract events
}
};
return () => ws.close();
}, [queryContract]);
3. Error Boundaries
Create src/components/ErrorBoundary.tsx:
import React, { Component, ReactNode } from 'react';
import { Box, Text, Button } from '@chakra-ui/react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
<Box p={6} textAlign="center">
<Text fontSize="xl" mb={4}>Something went wrong</Text>
<Text fontSize="sm" color="gray.600" mb={4}>
{this.state.error?.message}
</Text>
<Button onClick={() => this.setState({ hasError: false })}>
Try Again
</Button>
</Box>
);
}
return this.props.children;
}
}
Deployment
1. Build for Production
# Build the app
npm run build
# Serve locally to test
npx serve -s build
2. Deploy to IPFS
# Install IPFS CLI
npm install -g ipfs-http-client
# Add build folder to IPFS
ipfs add -r build/
# Pin the hash for persistence
ipfs pin add YOUR_HASH_HERE
3. Configure for Mainnet
Update .env for mainnet:
REACT_APP_CHAIN_ID=neutron-1
REACT_APP_RPC_URL=https://rpc-neutron.cosmos.directory
REACT_APP_CONTRACT_ADDRESS=your_mainnet_contract_address
Key Features Implemented
- Wallet Integration: Seamless Keplr wallet connection
- Contract Interaction: Execute and query smart contract functions
- Real-time Updates: Live contract state monitoring
- Error Handling: Comprehensive error boundaries and user feedback
- Responsive Design: Modern, mobile-friendly interface
- Oracle Integration: Display live price data from Neutron's Oracle module
Security Best Practices
- Input Validation: Always validate user inputs
- Error Handling: Never expose sensitive error details
- State Management: Keep sensitive data out of global state
- Environment Variables: Use environment variables for configuration
- HTTPS: Always serve over HTTPS in production
Next Steps
Congratulations! You've built a complete DApp on Neutron. Consider exploring:
- Advanced UI: Add charts, animations, and better UX
- Multi-contract Integration: Interact with multiple contracts
- Cross-chain Features: Use ICQ to display data from other chains
- Testing: Add comprehensive unit and integration tests
- Mobile App: Build a React Native version
Troubleshooting
Keplr Not Detected
Ensure Keplr extension is installed and enabled. Handle the case where Keplr is not available.
Transaction Fails
Check gas limits, fee amounts, and contract message format. Use browser dev tools to debug.
State Not Updating
Ensure you're calling refreshState() after successful transactions and handling async operations properly.
You've now completed the full Neutron development journey from smart contract to web app! 🎉