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 web site 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 out of date. 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 will be 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 for a 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
If necessary, add the Holesky blockchain(opens in a new tab) to your wallet and get test ETH(opens in a new tab).
Clone the github repository.
1git clone https://github.com/qbzzt/20230801-modern-ui.gitInstall the necessary packages.
1cd 20230801-modern-ui2pnpm installStart the application.
1pnpm devBrowse 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 slightly modified version of Hardhat's Greeter, on a blockchain explorer(opens in a new tab).
File walk through
index.html
This file is 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 tells us that this file 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 into JavaScript, so we can use it for client-side execution.
1import '@rainbow-me/rainbowkit/styles.css'2import { RainbowKitProvider } from '@rainbow-me/rainbowkit'3import * as React from 'react'4import * as ReactDOM from 'react-dom/client'5import { WagmiConfig } from 'wagmi'6import { chains, config } from './wagmi'
Import the library code we need.
1import { App } from './App'
Import the React component that implements the application (see below).
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 <WagmiConfig config={config}>
The application is also inside a WagmiConfig
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 <RainbowKitProvider chains={chains}>
And finally, a RainbowKitProvider
component(opens in a new tab). This component handles logging on and the communication between the wallet and the application.
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 </RainbowKitProvider>2 </WagmiConfig>3 </React.StrictMode>,4)
Of course, we have to close off the other components.
src/App.tsx
1import { ConnectButton } from '@rainbow-me/rainbowkit'2import { useAccount } from 'wagmi'3import { Greeter } from './components/Greeter'45export function App() {
This is the standard way to create a React component - define a function that is called every time it needs to be rendered. This function typically has some TypeScript or JavaScript code on top, followed by a return
statement that returns the JSX code.
1 const { isConnected } = useAccount()
Here we use useAccount
(opens in a new tab) to check if we are connected to a blockchain through a wallet or not.
By convention, in React functions called use...
are hooks(opens in a new tab) that return some kind of data. When you use such hooks, not only does your component get the data, but when that data changes the component is re-rendered with the updated information.
1 return (2 <>
The JSX of a React component has to return one component. When we have multiple components and we don't have anything that wraps up "naturally" we use an empty component (<> ... </>
) to make them into a single component.
1 <h1>Greeter</h1>2 <ConnectButton />
We get the ConnectButton
component(opens in a new tab) from RainbowKit. When we are not connected, it gives us a Connect Wallet
button that opens a modal that explains wallets and lets you choose which one you use. When we are connected, it displays the blockchain we use, our account address, and our ETH balance. We can use these displays to switch network or to disconnect.
1 {isConnected && (
When we need to insert actual JavaScript (or TypeScript that will be compiled to JavaScript) into a JSX, we use brackets ({}
).
The syntax a && b
is short for a ? b : a
(opens in a new tab). That is, if a
is true it evaluates to b
and otherwise it evaluates to a
(which can be false
, 0
, etc). This is an easy way to tell React that a component should only be displayed if a certain condition is fulfilled.
In this case, we only want to show the user Greeter
if the user is connected to a blockchain.
1 <Greeter />2 )}3 </>4 )5}
src/components/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 { useState, ChangeEventHandler } from 'react'2import { useNetwork,3 useContractRead,4 usePrepareContractWrite,5 useContractWrite,6 useContractEvent7 } from 'wagmi'
We 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 .3 .4 .5] as const // greeterABI
The 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 not going to change.
1type AddressPerBlockchainType = {2 [key: number]: AddressType3}
TypeScript is strongly typed. We use this definition to specify the address in which the Greeter
contract is deployed on different chains. The key is a number (the chainId), and the value is an AddressType
(an address).
1const contractAddrs: AddressPerBlockchainType = {2 // Holesky3 17000: '0x432d810484AdD7454ddb3b5311f0Ac2E95CeceA8',45 // Sepolia6 11155111: '0x7143d5c190F048C8d19fe325b748b081903E3BF0'7}
The address of the contract on the two supported networks: Holesky(opens in a new tab) and Sepolia(opens in a new tab).
Note: There is actually a third definition, for Redstone Holesky, it will be explained below.
1type ShowObjectAttrsType = {2 name: string,3 object: any4}
This type is used as a parameter to the ShowObject
component (explained later). It includes the name of the object and its value, which are displayed for debugging purposes.
1type ShowGreetingAttrsType = {2 greeting: string | undefined3}
At any moment in time we may either know what the greeting is (because we read it from the blockchain) or not know (because we haven't received it yet). So it is useful to have a type that can be either a string or nothing.
Greeter
component
1const Greeter = () => {
Finally, we get the define the component.
1 const { chain } = useNetwork()
Information about the chain we are using, courtesy of wagmi(opens in a new tab).
Because this is a hook (use...
), every time this information changes the component gets redrawn.
1 const greeterAddr = chain && contractAddrs[chain.id]
The address of the Greeter contract, which varies by chain (and which is undefined
if we don't have chain information or we are on a chain without that contract).
1 const readResults = useContractRead({2 address: greeterAddr,3 abi: greeterABI,4 functionName: "greet" , // No arguments5 watch: true6 })
The useContractRead
hook(opens in a new tab) reads information from a contract. You can see exactly what information it returns expand readResults
in the UI. In this case we want it to keep looking so we'll be informed when the greeting changes.
Note: We could listen to setGreeting
events(opens in a new tab) to know when the greeting changes and update that way. However, while it may be more efficient, it will not apply in all cases. When the user switches to a different chain the greeting also changes, but that change is not accompanied by an event. We could have one part of the code listening for events and another to identify chain changes, but that would be more complicated than just setting the watch
parameter(opens in a new tab).
1 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 greetingChange : ChangeEventHandler<HTMLInputElement> = (evt) =>2 setNewGreeting(evt.target.value)
This is the event handler for when the new greeting input field changes. The type, ChangeEventHandler<HTMLInputElement>
(opens in a new tab), specifies that this is handler for a value change of an HTML input element. The <HTMLInputElement>
part is used because this is a generic type(opens in a new tab).
1 const preparedTx = usePrepareContractWrite({2 address: greeterAddr,3 abi: greeterABI,4 functionName: 'setGreeting',5 args: [ newGreeting ]6 })7 const workingTx = useContractWrite(preparedTx.config)
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 for 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 would wonder if their command was really received by the user interface and why they aren't being asked to sign the transaction already. That makes for bad user experience (UX).
The solution is to use prepare hooks(opens in a new tab). Every time that a parameter changes, immediately send the node the eth_estimateGas
request. 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 {4 !readResults.isError && !readResults.isLoading &&5 <ShowGreeting greeting={readResults.data} />6 }7 <hr />
Create a ShowGreeting
component (explained below), but only if the greeting was read successfully from the blockchain.
1 <input type="text"2 value={newGreeting}3 onChange={greetingChange}4 />
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
. As setNewGreeting
comes from the useState
hook, it causes the Greeter
component to be rendered again. This means that:
- We need to specify
value
to keep the value of the new greeting, because otherwise it would turn back into the default, the empty string. usePrepareContractWrite
is called every timenewGreeting
changes, which means it is always going to have the latestnewGreeting
in the prepared transaction.
1 <button disabled={!workingTx.write}2 onClick={workingTx.write}3 >4 Update greeting5 </button>
If there is no workingTx.write
then we are still waiting for information necessary for sending the greeting update, so the button is disabled. If there is a workingTx.write
value then that is the function to call to send the transaction.
1 <hr />2 <ShowObject name="readResults" object={readResults} />3 <ShowObject name="preparedTx" object={preparedTx} />4 <ShowObject name="workingTx" object={workingTx} />5 </>6 )7}
Finally, to help you see what we're doing, show the three objects we use:
readResults
preparedTx
workingTx
ShowGreeting
component
This component shows
1const ShowGreeting = (attrs : ShowGreetingAttrsType) => {
A component function receives a parameter with all the attributes of the component.
1 return <b>{attrs.greeting}</b>2}
ShowObject
component
For information purposes, we use the ShowObject
component to show the important objects (readResults
for reading the greeting and preparedTx
and workingTx
for transactions we create).
1const ShowObject = (attrs: ShowObjectAttrsType ) => {2 const keys = Object.keys(attrs.object)3 const funs = keys.filter(k => typeof attrs.object[k] == "function")4 return <>5 <details>
We don't want to clutter the UI with all the information, so to make it possible to view them or close them, we use a details
(opens in a new tab) tag.
1 <summary>{attrs.name}</summary>2 <pre>3 {JSON.stringify(attrs.object, null, 2)}
Most of the fields are displayed using JSON.stringify
(opens in a new tab).
1 </pre>2 { funs.length > 0 &&3 <>4 Functions:5 <ul>
The exception is functions, which aren't part of the JSON standard(opens in a new tab), so they have to be displayed separately.
1 {funs.map((f, i) =>
Within JSX, code inside {
curly brackets }
is interpreted as JavaScript. Then, the code inside the (
regular brackets )
, is interpreted again as JSX.
1 (<li key={i}>{f}</li>)2 )}
React requires tags in the DOM Tree(opens in a new tab) to have distinct identifiers. This means that children of the same tag (in this case, the unordered list(opens in a new tab)), need different key
attributes.
1 </ul>2 </>3 }4 </details>5 </>6}
End the various HTML tags.
The final export
1export { Greeter }
The Greeter
component is the one we need to export for the application.
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.
The code here isn't exactly the same as on github(opens in a new tab) because later in the article we add another chain (Redstone Holesky(opens in a new tab)).
1import { getDefaultWallets } from '@rainbow-me/rainbowkit'2import { configureChains, createConfig } from 'wagmi'3import { holesky, sepolia } from 'wagmi/chains'
Import the blockchains the application supports. You can see the list of supported chains in the viem github(opens in a new tab).
1import { publicProvider } from 'wagmi/providers/public'23const walletConnectProjectId = 'c96e690bb92b6311e8e9b2a6a22df575'
To be able to use WalletConnect(opens in a new tab) you need a project ID for your application. You can get it on cloud.walletconnect.com(opens in a new tab).
1const { chains, publicClient, webSocketPublicClient } = configureChains(2 [ holesky, sepolia ],3 [4 publicProvider(),5 ],6)78const { connectors } = getDefaultWallets({9 appName: 'My wagmi + RainbowKit App',10 chains,11 projectId: walletConnectProjectId,12})1314export const config = createConfig({15 autoConnect: true,16 connectors,17 publicClient,18 webSocketPublicClient,19})2021export { chains }Show all
Adding another blockchain
These days there are a lot of L2 scaling solution(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 Redstone Holesky(opens in a new tab).
Import the
defineChain
type from viem.1import { defineChain } from 'viem'Add the network definition.
1const redstoneHolesky = defineChain({2 id: 17_001,3 name: 'Redstone Holesky',4 network: 'redstone-holesky',5 nativeCurrency: {6 decimals: 18,7 name: 'Ether',8 symbol: 'ETH',9 },10 rpcUrls: {11 default: {12 http: ['https://rpc.holesky.redstone.xyz'],13 webSocket: ['wss://rpc.holesky.redstone.xyz/ws'],14 },15 public: {16 http: ['https://rpc.holesky.redstone.xyz'],17 webSocket: ['wss://rpc.holesky.redstone.xyz/ws'],18 },19 },20 blockExplorers: {21 default: { name: 'Explorer', url: 'https://explorer.holesky.redstone.xyz' },22 },23})Show allAdd the new chain to the
configureChains
call.1 const { chains, publicClient, webSocketPublicClient } = configureChains(2 [ holesky, sepolia, redstoneHolesky ],3 [ publicProvider(), ],4 )Ensure that the application knows the address for your contracts on the new network. In this case, we modify
src/components/Greeter.tsx
:1const contractAddrs : AddressPerBlockchainType = {2 // Holesky3 17000: '0x432d810484AdD7454ddb3b5311f0Ac2E95CeceA8',45 // Redstone Holesky6 17001: '0x4919517f82a1B89a32392E1BF72ec827ba9986D3',78 // Sepolia9 11155111: '0x7143d5c190F048C8d19fe325b748b081903E3BF0'10}Show all
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.
1pnpm create wagmiName the application.
Select React framework.
Select the Vite variant.
You can add Rainbow kit(opens in a new tab).
Now go and make your contracts usable for the wide world.
Last edit: @wackerow(opens in a new tab), March 13, 2024