Building a user interface for your contract
You found a feature we need in the Ethereum ecosystem. You wrote the smart contracts to implement it, and maybe even some related code that runs offchain. This is great! Unfortunately, without a user interface you aren't going to have any users, and the last time you wrote a website people used dial-up modems and JavaScript was new.
This article is for you. I assume you know programming, and maybe a bit of JavaScript and HTML, but that your user interface skills are rusty and outdated. Together we will go over a simple modern application so you'll see how it's done these days.
Why is this important
In theory, you could just have people use Etherscan (opens in a new tab) or Blockscout (opens in a new tab) to interact with your contracts. That is great for the experienced Ethereans. But we are trying to serve another billion people (opens in a new tab). This won't happen without a great user experience, and a friendly user interface is a big part of that.
Greeter application
There is a lot of theory behind how modern UI works, and a lot of good sites (opens in a new tab) that explain it (opens in a new tab). Instead of repeating the fine work done by those sites, I'm going to assume you prefer to learn by doing and start with an application you can play with. You still need the theory to get things done, and we'll get to it - we'll just go source file by source file, and discuss things as we get to them.
Installation
-
The application uses the Sepolia (opens in a new tab) test network. If necessary, get Sepolia test ETH and add Sepolia to your wallet (opens in a new tab).
-
Clone the GitHub repository and install the necessary packages.
1git clone https://github.com/qbzzt/260301-modern-ui-web3.git2cd 260301-modern-ui-web33npm install -
The application uses free access points, which have performance limitations. If you want to use a Node as a service provider, replace the URLs in
src/wagmi.ts. -
Start the application.
1npm run dev -
Browse to the URL shown by the application. In most cases, that is http://localhost:5173/ (opens in a new tab).
-
You can see the contract source code, a modified version of Hardhat's Greeter, on a blockchain explorer (opens in a new tab).
File walk through
index.html
This file is a standard HTML boilerplate except for this line, which imports the script file.
1<script type="module" src="/src/main.tsx"></script>src/main.tsx
The file extension indicates that this is a React component (opens in a new tab) written in TypeScript (opens in a new tab), an extension of JavaScript that supports type checking (opens in a new tab). TypeScript is compiled to JavaScript, so we can use it on the client side.
This file is mostly explained in case you are interested. Usually you do not modify this file, but src/App.tsx and the files it imports.
1import { QueryClient, QueryClientProvider } from '@tanstack/react-query'2import React from 'react'3import ReactDOM from 'react-dom/client'4import { WagmiProvider } from 'wagmi'Import the library code we need.
1import App from './App.tsx'Import the React component that implements the application (see below).
1import { config } from './wagmi.ts'Import the wagmi (opens in a new tab) configuration, which includes the blockchain configuration.
1const queryClient = new QueryClient()Creates a new instance of React Query’s (opens in a new tab) cache manager. This object will store:
- Cached RPC calls
- Contract reads
- Background refetching state
We need the cache manager because wagmi v3 uses React Query internally.
1ReactDOM.createRoot(document.getElementById('root')!).render(Create the root React component. The parameter to render is JSX (opens in a new tab), an extension language that uses both HTML and JavaScript/TypeScript. The exclamation point here tells the TypeScript component: "you don't know that document.getElementById('root') will be a valid parameter to ReactDOM.createRoot, but don't worry - I'm the developer and I'm telling you there will be".
1 <React.StrictMode>The application is going inside a React.StrictMode component (opens in a new tab). This component tells the React library to insert additional debugging checks, which is useful during development.
1 <WagmiProvider config={config}>The application is also inside a WagmiProvider component (opens in a new tab). The wagmi (we are going to make it) library (opens in a new tab) connects the React UI definitions with the viem library (opens in a new tab) for writing an Ethereum decentralized application.
1 <QueryClientProvider client={queryClient}>And finally, add a React Query provider so any application component can use cached queries.
1 <App />Now we can have the component for the application, which actually implements the UI. The /> at the end of the component tells React that this component doesn't have any definitions inside it, as per the XML standard.
1 </QueryClientProvider>2 </WagmiProvider>3 </React.StrictMode>,4)Of course, we have to close off the other components.
src/App.tsx
1import {2 useConnect,3 useConnection,4 useDisconnect,5 useSwitchChain6} from 'wagmi'78import { useEffect } from 'react'9import { Greeter } from './Greeter'Show allImport the libraries we need, as well as the Greeter component.
1const SEPOLIA_CHAIN_ID = 11155111The Sepolia chain ID.
1function App() {This is the standard way to create a React component: define a function that is called whenever it needs to be rendered. This function typically contains TypeScript or JavaScript code, followed by a return statement that returns the JSX code.
1 const connection = useConnection()Use useConnection (opens in a new tab) to get information related to the current connection, such as the address and chainId.
By convention, in React functions called use... are hooks (opens in a new tab). These functions don't just return data to the component; they also ensure it is re-rendered (the component function is executed again, and its output replaces the previous one in the HTML) when that data changes.
1 const { connectors, connect, status, error } = useConnect()Use useConnect (opens in a new tab) to get information about the wallet connection.
1 const { disconnect } = useDisconnect()This hook (opens in a new tab) gives us the function to disconnect from the wallet.
1 const { switchChain } = useSwitchChain()This hook (opens in a new tab) lets us switch chains.
1 useEffect(() => {The React hook useEffect (opens in a new tab) lets you run a function whenever the value of a variable changes to synchronize an external system.
1 if (connection.status === 'connected' &&2 connection.chainId !== SEPOLIA_CHAIN_ID3 ) {4 switchChain({ chainId: SEPOLIA_CHAIN_ID })5 }If we are connected, but not to the Sepolia blockchain, switch to Sepolia.
1 }, [connection.status, connection.chainId])Rerun the function every time either the connection status or the connection chainId changes.
1 return (2 <>The JSX of a React component must return a single HTML component. When we have multiple components and don't need a container to wrap them all, we use an empty component (<> ... </>) to combine them into a single component.
1 <h2>Connection</h2>2 <div>3 status: {connection.status}4 <br />5 addresses: {JSON.stringify(connection.addresses)}6 <br />7 chainId: {connection.chainId}8 </div>Provide information about the current connection. Within JSX, {<expression>} means to evaluate the expression as JavaScript.
1 {connection.status === 'connected' && (The syntax {<condition> && <value>} means "if the condition is true, evaluate to the value; if it isn't, evaluate to false`".
This is the standard way to put if statements inside JSX.
1 <div>2 <Greeter />3 <hr />JSX follows the XML standard, which is stricter than HTML. If a tag does not have a corresponding end tag, it must have a slash (/) at the end to terminate it.
Here we have two such tags, <Greeter /> (which actually contains the HTML code that talks to the contract) and <hr /> for a horizontal line (opens in a new tab).
1 <button type="button" onClick={disconnect}>2 Disconnect3 </button>4 </div>5 )}If the user clicks this button, call the disconnect function.
1 {connection.status !== 'connected' && (If we are not connected, show the necessary options to connect to the wallet.
1 <div>2 <h2>Connect</h2>3 {connectors.map((connector) => (In connectors we have a list of connectors. We use map (opens in a new tab) to turn it into a list of JSX buttons to display.
1 <button2 key={connector.uid}In JSX it is necessary for "sibling" tags (tags that descend from the same parent) to have different identifiers.
1 onClick={() => connect({ connector })}2 type="button"3 >4 {connector.name}5 </button>6 ))}The connector buttons.
1 <div>{status}</div>2 <div>{error?.message}</div>3 </div>4 )}Provide additional information. The expression syntax <variable>?.<field> tells JavaScript that if the variable is defined, evaluate to that field. If the variable is not defined, then this expression evaluates to undefined.
The expression error.message, when there is no error, would raise an exception. Using error?.message lets us avoid this issue.
src/Greeter.tsx
This file contains most of the UI functionality. It includes definitions that would normally be in multiple files, but as this is a tutorial, the program is optimized for being easy to understand the first time, rather than performance or ease of maintenance.
1import {2 useState,3 useEffect,4 } from 'react'5import { useChainId,6 useAccount,7 useReadContract,8 useWriteContract,9 useWatchContractEvent,10 useSimulateContract11 } from 'wagmi'Show allWe use these library functions. Again, they are explained below where they are used.
1import { AddressType } from 'abitype'The abitype library (opens in a new tab) provides us with TypeScript definitions for various Ethereum data types, such as AddressType (opens in a new tab).
1let greeterABI = [2 { "type": "function", "name": "greet", ... },3 { "type": "function", "name": "setGreeting", ... },4 { "type": "event", "name": "SetGreeting", ... },5] as const // greeterABIThe ABI for the Greeter contract.
If you are developing the contracts and UI at the same time, you'd normally put them in the same repository and use the ABI generated by the Solidity compiler as a file in your application. However, this is not necessary here because the contract is already developed and will not change.
We use as const (opens in a new tab) to tell TypeScript that this is a real constant. Normally, when you specify in JavaScript const x = {"a": 1}, you can change the value in x, you just can't assign to it.
1type AddressPerBlockchainType = {2 [key: number]: AddressType3}TypeScript is strongly typed. We use this definition to specify the address where the Greeter contract is deployed across different chains. The key is a number (the chainId), and the value is an AddressType (an address).
1const contractAddrs : AddressPerBlockchainType = {2 // Sepolia3 11155111: '0xC87506C66c7896366b9E988FE0aA5B6dDE77CFfA'4}The address of the contract on Sepolia (opens in a new tab).
Timer component
The Timer component shows the number of seconds since a given time. This is important for usability purposes. When users do something, they expect an immediate reaction. In blockchains, this is often impossible because nothing happens until a transaction is placed in a block. One solution is to show how long it has been since the user performed the action, so the user can decide whether the time required is reasonable.
1type TimerProps = {2 lastUpdate: Date3}The Timer component takes one parameter, lastUpdate, which is the time of the last action.
1const Timer = ({ lastUpdate }: TimerProps) => {2 const [_, setNow] = useState(new Date())We need to have state (a variable tied to the component) and update it for the component to work correctly. But we never need to read it, so don't bother to do a variable.
1 useEffect(() => {2 const id = setInterval(() => setNow(new Date()), 1000)3 return () => clearInterval(id)4 }, [])The setInterval (opens in a new tab) function lets us schedule a function to run periodically. In this case, every second. The function calls setNow to update the state, so the Timer component will be re-rendered. We wrap this inside useEffect (opens in a new tab) with an empty dependency list so it'll happen just once, rather than each time the component is rendered.
1 const secondsSinceUpdate = Math.floor(2 (Date.now() - lastUpdate.getTime()) / 10003 )45 return (6 <span>{secondsSinceUpdate} seconds ago</span>7 )8}Calculate the number of seconds since the last update and return it.
Greeter component
1const Greeter = () => {Finally, we get to define the component.
1 const chainId = useChainId()2 const account = useAccount()Information about the chain and account we are using, courtesy of wagmi (opens in a new tab). Because this is a hook (use...), the component is re-rendered whenever this information changes.
1 const greeterAddr = chainId && contractAddrs[chainId] The address of the Greeter contract, which is undefined if we don't have chain information, or we are on a chain without that contract.
1 const readResults = useReadContract({2 address: greeterAddr,3 abi: greeterABI,4 functionName: "greet", // No arguments5 })The useReadContract hook (opens in a new tab) calls the greet function of the contract (opens in a new tab).
1 const [ currentGreeting, setCurrentGreeting ] = 2 useState("Please wait while we fetch the greeting from the blockchain...")3 const [ newGreeting, setNewGreeting ] = useState("")React's useState hook (opens in a new tab) lets us specify a state variable, whose value persists from one rendering of the component to another. The initial value is the parameter, in this case the empty string.
The useState hook returns a list with two values:
- The current value of the state variable.
- A function to modify the state variable when needed. As this is a hook, every time it is called the component is rendered again.
In this case, we are using a state variable for the new greeting the user wants to set.
1 const [ lastSetterAddress, setLastSetterAddress ] = useState("")If multiple users are using the same contract at the same time, they might overwrite each other's greetings. This would look to the users as if the application is malfunctioning. If the application shows who last set the greeting, the user will know it was someone else and that the application is working correctly.
1 const [ status, setStatus ] = useState("")2 const [ statusTime, setStatusTime ] = useState(new Date())Users like to see that their actions have an immediate effect. However, on a blockchain, this is not the case. These state variables let us at least display something to users so they'll know their action is in progress.
1 useEffect(() => {2 if (readResults.data) {3 setCurrentGreeting(readResults.data)4 setStatus("Greeting fetched from blockchain")5 }6 }, [readResults.data])If readResults above changes the data and it's not set to a false value (undefined, for example), update the current greeting to the one read from the blockchain. Also, update the status.
1 useWatchContractEvent({2 address: greeterAddr,3 abi: greeterABI,4 eventName: 'SetGreeting',5 chainId,Listen to SetGreeting events.
1 enabled: !!greeterAddr,!!<value> means that if the value is false, or a value that evaluates as false, such as undefined, 0, or an empty string, the expression overall is false. For any other value, it is true. It's a way to convert values to booleans, because if there is no greeterAddr, we don't want to listen to events.
1 onLogs: logs => {2 const greetingFromContract = logs[0].args.greeting3 setCurrentGreeting(greetingFromContract)4 setLastSetterAddress(logs[0].args.sender)5 updateStatus("Greeting updated by event")6 },7 })When we see logs (which happens when we see a new event), it means that the greeting has been modified. In that case, we can update currentGreeting and lastSetterAddress to the new values. Also, we want to update the status display.
1 const updateStatus = (newStatus: string) => {2 setStatus(newStatus)3 setStatusTime(new Date())4 }When we update the status we want to do two things:
- Update the status string (
status) - Update the time of last status update (
statusTime) to now.
1 const greetingChange = (evt) =>2 setNewGreeting(evt.target.value)This is the event handler for changes to the new greeting input field. We could specify the type of the evt parameter, but TypeScript is a type optional language. As this function is called only once, in an HTML event handler, I don't think it is necessary.
1 const { writeContractAsync } = useWriteContract()The function to write to a contract. It is similar to writeContracts (opens in a new tab), but enables better status updates.
1 const simulation = useSimulateContract({2 address: greeterAddr,3 abi: greeterABI,4 functionName: 'setGreeting',5 args: [newGreeting],6 account: account.address 7 })This is the process to submit a blockchain transaction from the client perspective:
- Send the transaction to a node in the blockchain using
eth_estimateGas(opens in a new tab). - Wait for a response from the node.
- When the response is received, ask the user to sign the transaction through the wallet. This step has to happen after the node response is received because the user is shown the gas cost of the transaction before signing it.
- Wait for the user to approve.
- Send the transaction again, this time using
eth_sendRawTransaction(opens in a new tab).
Step 2 is likely to take a perceptible amount of time, during which users may wonder whether their command was received by the user interface and why they aren't being asked to sign the transaction yet. That creates a poor user experience (UX).
One solution is to send out eth_estimateGas every time that a parameter changes. Then, when the user actually wants to send the transaction (in this case by pressing Update greeting), the gas cost is known, and the user can see the wallet page immediately.
1 return (Now we can finally create the actual HTML to return.
1 <>2 <h2>Greeter</h2>3 {currentGreeting}Show the current greeting.
1 {lastSetterAddress && (2 <p>Last updated by {3 lastSetterAddress === account.address ? "you" : lastSetterAddress4 }</p>5 )}If we know who set the greeting last, display that information. Greeter does not keep track of this information, and we don't want to look back for SetGreeting events, so we only get it once the greeting is changed while we are running.
1 <hr /> 2 <input type="text"3 value={newGreeting}4 onChange={greetingChange}5 /> 6 <br />This is the input text field where the user can set a new greeting. Every time the user presses a key, we call greetingChange, which calls setNewGreeting. Since setNewGreeting comes from useState, it causes the Greeter component to be re-rendered. This means that:
- We need to specify
valueto keep the value of the new greeting, because otherwise it would turn back into the default, the empty string. simulationis also updated every timenewGreetingchanges, which means that we'll get a simulation with the correct greeting. This could be relevant because the gas cost depends on the size of the call data, which depends on the length of the string.
1 <button disabled={!simulation.data}Only enable the button once we have the information we need to send the transaction.
1 onClick={async () => {2 updateStatus("Please confirm in wallet...")Update the status. At this point, the user needs to confirm in the wallet.
1 await writeContractAsync(simulation.data.request)2 updateStatus("Transaction sent, waiting for greeting to change...")3 }}4 >5 Update greeting6 </button>7writeContractAsync only returns after the transaction is actually sent. This lets us show the user how long the transaction has been waiting to be included in the blockchain.
1 <h4>Status: {status}</h4>2 <p>Updated <Timer lastUpdate={statusTime} /> </p>3 </>4 )5}Show the status and how long it has been since it was updated.
1export {Greeter}Export the component.
src/wagmi.ts
Finally, various definitions related to wagmi are in src/wagmi.ts. I am not going to explain everything here, because most of it is boilerplate you are unlikely to need to change.
1import { http, webSocket, createConfig, fallback } from 'wagmi'2import { sepolia } from 'wagmi/chains'3import { injected } from 'wagmi/connectors'45export const config = createConfig({6 chains: [sepolia],The wagmi configuration includes the chains supported by this application. You can see the list of available chains (opens in a new tab).
1 connectors: [2 injected(),3 ],This connector (opens in a new tab) lets us talk to a wallet installed in the browser.
1 transports: {2 [sepolia.id]: http()The default HTTP endpoint that comes with Viem is good enough. If we want a different URL, we can use http("https:// hostname ") or webSocket("wss:// hostname ").
1 },2 multiInjectedProviderDiscovery: false,3})Adding another blockchain
These days there are a lot of L2 scaling solutions (opens in a new tab), and you might want to support some that viem does not support yet. To do it, you modify src/wagmi.ts. These instructions explain how to add Optimism Sepolia (opens in a new tab).
-
Edit
src/wagmi.tsA. Import the
defineChaintype from viem.1import { defineChain } from 'viem'B. Add the network definition. You don't really need to do this for Optimism Sepolia, it is already in
viem(opens in a new tab), but this way you learn how to add a blockchain that is not inviem.1const optimismSepolia = defineChain({2 id: 11_155_420,3 name: 'OP Sepolia',4 nativeCurrency: { name: 'Sepolia Ether', symbol: 'ETH', decimals: 18 },5 rpcUrls: {6 default: {7 http: ['https://sepolia.optimism.io'],8 webSocket: ['wss://optimism-sepolia.drpc.org'],9 },10 },11 blockExplorers: {12 default: {13 name: 'Blockscout',14 url: 'https://optimism-sepolia.blockscout.com',15 apiUrl: 'https://optimism-sepolia.blockscout.com/api',16 }17 },18})Show allC. Add the new chain to the
createConfigcall.1export const config = createConfig({2 chains: [sepolia, optimismSepolia],3 connectors: [4 injected(),5 ],6 transports: {7 [optimismSepolia.id]: http(),8 [sepolia.id]: http()9 },10 multiInjectedProviderDiscovery: false,11})Show all -
Edit
src/App.tsxto comment out the automatic switch to Sepolia. On a production system, you'd probably show buttons with links to each of the blockchains you support.1/*2useEffect(() => {3 if (connection.status === 'connected' &&4 connection.chainId !== SEPOLIA_CHAIN_ID5 ) {6 switchChain({ chainId: SEPOLIA_CHAIN_ID })7 }8}, [connection.status, connection.chainId])9*/Show all -
Edit
src/Greeter.tsxto ensure that the application knows the address for your contracts on the new network.1const contractAddrs: AddressPerBlockchainType = {2 // Optimism Sepolia3 11155420: "0x4dd85791923E9294E934271522f63875EAe5806f",45 // Sepolia6 11155111: "0x7143d5c190F048C8d19fe325b748b081903E3BF0",7} -
In your browser.
A. Browse to ChainList (opens in a new tab) and click one of buttons on the right side of the table to add the chain to your wallet.
B. In the application, Disconnect and then reconnect to change the blockchain. There are nicer ways to handle this, but they'd require application changes.
Conclusion
Of course, you don't really care about providing a user interface for Greeter. You want to create a user interface for your own contracts. To create your own application, run these steps:
-
Specify to create a wagmi application.
1npm create wagmi -
Type
yto proceed. -
Name the application.
-
Select React framework.
-
Select the Vite variant.
Now go and make your contracts usable for the wide world.
See here for more of my work (opens in a new tab).
Page last update: March 3, 2026