Tutorial 4: Build a zkApp UI in the Browser with React
You're making excellent progress in your zkApp journey:
- In the Hello World tutorial, you built a basic zkApp smart contract with o1js.
- In Tutorial 3: Deploy to a Live Network, you used the
zk
commands to deploy your zkApp.
In this tutorial, you are going to implement a browser UI using Next.js
that interacts with a smart contract.
Prerequisites
Make sure you have the latest version of the zkApp CLI installed:
$ npm install -g zkapp-cli
Ensure your environment meets the Prerequisites for zkApp Developer Tutorials.
The Auro Wallet browser extension wallet that supports interactions with zkApps. See Install a Wallet and create a MINA account.
This tutorial has been tested with:
- zkApp CLI version
0.21.6
- o1js version
1.8.0
- Auro Wallet version
2.2.15
High-Level Overview
In this tutorial, you create a new GitHub repository so you can deploy the UI to GitHub Pages.
You use example code and the zkApp CLI to build an application that:
- Loads a public key from an extension-based wallet.
- Checks if the public key has funds and if not, directs the user to the Faucet.
- Connects to the example zkApp
Add
smart contract that is already deployed on Devnet (or other network) at a fixed address. - Implements a button that sends a transaction.
- Implements a button that requests the latest state of the smart contract.
- Deploys the zkApp to GitHub Pages.
Like previous tutorials, you use the provided example files so you can focus on the React implementation itself.
Create a project
You can have the zk project
command scaffold the UI for your project.
Create or change to the directory where you have write privileges.
Create a project by using the
zk project
command:$ zk project 04-zkapp-browser-ui
To scaffold the UI for your project with the
Next.js
React framework, selectnext
:? Create an accompanying UI project too? …
> next
svelte
nuxt
empty
noneIf you are prompted to install the required Next packages, press y to proceed.
Select yes at the
? Do you want to set up your project for deployment to Github Pages? …
prompt.If you are prompted to install the required Next packages, press y to proceed.
Select No at the
? Would you like to use ESLint with this project?
prompt.Select No at the
? Would you like to use Tailwind CSS with this project?
prompt.Your UI is created in the project directory:
04-zkapp-browser-ui/ui
with two directories:contracts
: The smart contract codeui
: Where you write the UI code
For this tutorial, you run commands from the root of the 04-zkapp-browser-ui/ui
directory. You work in the ui/app
directory on TypeScript files that contain the UI code.
Each time you make updates, then build or deploy, the TypeScript code is compiled into JavaScript in the build
directory.
Install the dependencies
When you ran the zk project
command, your UI was created in the project directory: 04-zkapp-browser-ui/ui
. The project has two sub-directories:
contracts
: The smart contract codeui
: The UI application code
The dependencies in each sub-directory are installed automatically by the zkApp CLI.
Create a repository
To interact with a deployed zkApp UI on GitHub pages, you must create a GitHub repository.
Go ahead and create your repository now. For other projects, you can name your GitHub repository anything you want. For this tutorial, use 04-zkapp-browser-ui
.
- Go to https://github.com/new.
- For the Repository name, enter
04-zkapp-browser-ui
. - Optionally, add a description and a README.
Your project repository is ready to use.
Preparing the project
Start by deleting the default page.tsx
file that comes with a new project so that you have a clean project to work with.
In the
04-zkapp-browser-ui/ui
directory:$ rm app/page.tsx
Install UI dependencies
This tutorial uses the comlink
package to integrate web workers into the React application. Comlink simplifies
communication between the main thread and web workers by abstracting the postMessage API, allowing you to call functions in the worker as if they were local.
In the 04-zkapp-browser-ui/ui
directory, install comlink
by running the following command:
$ npm install comlink
To learn more about comlink
, read the documentation.
Build the default contract
This tutorial uses the default contract Add
that is always scaffolded with the zk project
command.
To build the default contract so that it can be used with UI application, run this command from the 04-zkapp-browser-ui/contracts
directory:
$ npm run build
Outside of this tutorial, the workflow for building your own zkApp is to edit files in the contracts
folder, rebuild the contract, and then access it from your UI application code.
Implement the UI
The UI application has several components: the React page itself and the code that uses o1js.
Download helper files
Because o1js code is computationally intensive, it's helpful to use web workers. A web worker handles requests from users to ensure the UI thread isn't blocked during long computations like compiling a smart contract or proving a transaction.
Download the helper files from the
examples/zkapps/04-zkapp-browser-ui
directory on GitHub:Move the files to your local
04-zkapp-browser-ui/ui/app
directory.Review each helper file to see how they work and how you can extend them for your own zkApp.
zkappWorker.ts
is the web worker codezkappWorkerClient.ts
is the client code that is run from React to interact with the web worker
Download the main browser UI logic file
The example project has a completed app. The page.tsx
file is the entry file for your application and contains the main logic for the browser UI that is ready to deploy to GitHub Pages.
Download the page.tsx example file.
Move the
page.tsx
file to your local04-zkapp-browser-ui/ui/app
directory.
Setup web workers
The web worker code resides in the 04-zkapp-browser-ui/ui/app/zkappWorker.ts
file. Here, you define the functions that will be executed in the worker thread.
Defining State
The state object holds references to the zkApp Instance, Add contract instance, and the transactions.
const state = {
Add: null as null | typeof Add,
zkapp: null as null | Add,
transaction: null as null | Transaction,
};
Functions using o1js
that will be executed in the worker thread
export const api = {
async setActiveInstanceToDevnet() {
const Network = Mina.Network('https://api.minascan.io/node/devnet/v1/graphql');
console.log('Devnet network instance configured');
Mina.setActiveInstance(Network);
},
async loadContract() {
const { Add } = await import('../../contracts/build/src/Add.js');
state.Add = Add;
},
async compileContract() {
await state.Add!.compile();
},
async fetchAccount(publicKey58: string) {
const publicKey = PublicKey.fromBase58(publicKey58);
return fetchAccount({ publicKey });
},
async initZkappInstance(publicKey58: string) {
const publicKey = PublicKey.fromBase58(publicKey58);
state.zkapp = new state.Add!(publicKey);
},
async getNum() {
const currentNum = await state.zkapp!.num.get();
return JSON.stringify(currentNum.toJSON());
},
async createUpdateTransaction() {
state.transaction = await Mina.transaction(async () => {
await state.zkapp!.update();
});
},
async proveUpdateTransaction() {
await state.transaction!.prove();
},
async getTransactionJSON() {
return state.transaction!.toJSON();
},
};
Expose functions to the main thread with comlink
// Expose the API to be used by the main thread
Comlink.expose(api);
Creating the worker client
The web worker client code resides in the 04-zkapp-browser-ui/ui/app/zkappWorkerClient.ts
file. Here you create a client in the main thread that interacts with the web worker.
Worker
is a reference to the Worker instance andremoteApi
is a reference to a proxy object that provides typesafe access to the worker's API methods.export default class ZkappWorkerClient {
worker: Worker;
// Proxy to interact with the worker's methods as if they were local
remoteApi: Comlink.Remote<typeof import('./zkappWorker').api>;In the constructor create a new Worker instance pointing to the
zkappWorker.ts
file.constructor() {
// Initialize the worker from the zkappWorker module
const worker = new Worker(new URL('./zkappWorker.ts', import.meta.url), { type: 'module' });With
Comlink.wrap
, create a proxy objectremoteApi
that provides typesafe access the worker's API methods.Define methods in the ZkappWorkerClient class that call the corresponding method on
remoteApi
, effectively forwarding the calls to the worker.async setActiveInstanceToDevnet() {
return this.remoteApi.setActiveInstanceToDevnet();
}
async loadContract() {
return this.remoteApi.loadContract();
}
async compileContract() {
return this.remoteApi.compileContract();
}
async fetchAccount(publicKeyBase58: string) {
return this.remoteApi.fetchAccount(publicKeyBase58);
}
async initZkappInstance(publicKeyBase58: string) {
return this.remoteApi.initZkappInstance(publicKeyBase58);
}
async getNum(): Promise<Field> {
const result = await this.remoteApi.getNum();
return Field.fromJSON(JSON.parse(result as string));
}
async createUpdateTransaction() {
return this.remoteApi.createUpdateTransaction();
}
async proveUpdateTransaction() {
return this.remoteApi.proveUpdateTransaction();
}
async getTransactionJSON() {
return this.remoteApi.getTransactionJSON();
}
// Wrap the worker with Comlink to enable direct method invocation
this.remoteApi = Comlink.wrap(this.worker);
Environment configuration
In
04-zkapp-browser-ui/ui/app/page.tsx
let transactionFee = 0.1;
const ZKAPP_ADDRESS = 'B62qpXPvmKDf4SaFJynPsT6DyvuxMS9H1pT4TGonDT26m599m7dS9gP';The smart contract that the UI interacts with in this tutorial has been deployed to the Devnet and the public key is stored in the
ZKAPP_ADDRESS
variable. If you experience problems with the deployed contract, you can deploy theAdd
contract included in thecontracts
folder yourself to any other network. When deployed, replaceZKAPP_ADDRESS
variable with the public key of your own deployed zkApp.In
04-zkapp-browser-ui/ui/app/zkappWorker.ts
async setActiveInstanceToDevnet() {
const Network = Mina.Network('https://api.minascan.io/node/devnet/v1/graphql');
console.log('Devnet network instance configured');
Mina.setActiveInstance(Network);
},Depending on the network you are going to work with you might want to consider changing the GraphQL endpoint in the
setActiveInstanceToDevnet
function. Mind the supported networks byAuro Wallet
though.
Add state
These 04-zkapp-browser-ui/ui/app/page.tsx
statements creates mutable state that you can reference in the UI. The state updates as the application runs:
...
const [zkappWorkerClient, setZkappWorkerClient] = useState<null | ZkappWorkerClient>(null);
const [hasWallet, setHasWallet] = useState<null | boolean>(null);
const [hasBeenSetup, setHasBeenSetup] = useState(false);
const [accountExists, setAccountExists] = useState(false);
const [currentNum, setCurrentNum] = useState<null | Field>(null);
const [publicKeyBase58, setPublicKeyBase58] = useState('');
const [creatingTransaction, setCreatingTransaction] = useState(false);
const [displayText, setDisplayText] = useState('');
const [transactionlink, setTransactionLink] = useState('');
...
To learn more about useState
hooks, see built-in React hooks in the React API reference documentation.
zkApp setting up
This 04-zkapp-browser-ui/ui/app/page.tsx
code adds a functions to set up zkApp:
The Boolean
hasBeenSetup
ensures that the react featureuseEffect
is run only once. To learn more aboutuseEffect
hooks, see useEffect in the React API reference documentation.This code also sets up your web worker client that interacts with the web worker running o1js code to ensure the computationally heavy o1js code doesn't block the UI thread.
Load web worker and setup Mina active instance
...
displayStep('Loading web worker...')
const zkappWorkerClient = new ZkappWorkerClient();
setZkappWorkerClient(zkappWorkerClient);
await new Promise((resolve) => setTimeout(resolve, 5000));
displayStep('Done loading web worker')
await zkappWorkerClient.setActiveInstanceToDevnet();
...
Connect Auro Wallet and setup fee payer account
...
const mina = (window as any).mina;
if (mina == null) {
setHasWallet(false);
displayStep('Wallet not found.');
return;
}
const publicKeyBase58: string = (await mina.requestAccounts())[0];
setPublicKeyBase58(publicKeyBase58);
displayStep(`Using key:${publicKeyBase58}`);
displayStep('Checking if fee payer account exists...');
const res = await zkappWorkerClient.fetchAccount(
publicKeyBase58,
);
const accountExists = res.error === null;
setAccountExists(accountExists);
...
Import the contract code, instantiate zkApp instance, compile the contract and fetch zkApp state
...
await zkappWorkerClient.loadContract();
displayStep('Compiling zkApp...');
await zkappWorkerClient.compileContract();
displayStep('zkApp compiled');
await zkappWorkerClient.initZkappInstance(ZKAPP_ADDRESS);
displayStep('Getting zkApp state...');
await zkappWorkerClient.fetchAccount(ZKAPP_ADDRESS);
const currentNum = await zkappWorkerClient.getNum();
setCurrentNum(currentNum);
console.log(`Current state in zkApp: ${currentNum}`);
...
Update the state of the React application
...
setHasBeenSetup(true);
setHasWallet(true);
setDisplayText('');
...
Run the React app
Execute the following commands being within the 04-zkapp-browser-ui/ui/
directory.
To start the development server and serve your UI application at the URL
localhost:3000
:$ npm run dev
You can also change the default port by starting the dev server with the
--port
CLI argument. For example, to start the dev server on port8001
, run:$ npm run dev -- --port 8001
The zkApp UI in the web browser shows the current state of the zkApp and has buttons to send a transaction and get the latest zkApps on-chain state.
Your browser refreshes automatically when you update the source code.
If prompted, request the funds from the Testnet Faucet service to fund your fee payer account.
And in the second terminal window:
$ npm run ts-watch
This command starts the installed TypeScript compiler (
tsc
) with--watch
parameter, with the ability to react to compilation status.
Wait for the fee payer account to be funded
Now that the UI setup is finished, a new useEffect waits for the fee payer account to be funded if it didn't before by checking the account presence in ledger.
Don't forget that if the account has been newly created, it must be funded from the Faucet.
...
useEffect(() => {
const checkAccountExists = async () => {
if (hasBeenSetup && !accountExists) {
try {
for (;;) {
displayStep('Checking if fee payer account exists...');
const res = await zkappWorkerClient!.fetchAccount(publicKeyBase58);
const accountExists = res.error == null;
if (accountExists) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 5000));
}
} catch (error: any) {
displayStep(`Error checking account: ${error.message}`);
}
}
setAccountExists(true);
};
checkAccountExists();
}, [zkappWorkerClient, hasBeenSetup, accountExists]);
...
Let UI buttons do some useful work
These functions will be triggered on buttons press.
...
const onSendTransaction = async () => {
setCreatingTransaction(true);
displayStep('Creating a transaction...');
console.log('publicKeyBase58 sending to worker', publicKeyBase58);
await zkappWorkerClient!.fetchAccount(publicKeyBase58);
await zkappWorkerClient!.createUpdateTransaction();
displayStep('Creating proof...');
await zkappWorkerClient!.proveUpdateTransaction();
displayStep('Requesting send transaction...');
const transactionJSON = await zkappWorkerClient!.getTransactionJSON();
displayStep('Getting transaction JSON...');
const { hash } = await (window as any).mina.sendTransaction({
transaction: transactionJSON,
feePayer: {
fee: transactionFee,
memo: '',
},
});
const transactionLink = `https://minascan.io/devnet/tx/${hash}`;
setTransactionLink(transactionLink);
setDisplayText(transactionLink);
setCreatingTransaction(true);
};
const onRefreshCurrentNum = async () => {
try {
displayStep('Getting zkApp state...');
await zkappWorkerClient!.fetchAccount(ZKAPP_ADDRESS);
const currentNum = await zkappWorkerClient!.getNum();
setCurrentNum(currentNum);
console.log(`Current state in zkApp: ${currentNum}`);
setDisplayText('');
} catch (error: any) {
displayStep(`Error refreshing state: ${error.message}`);
}
};
Take care of the page markup
...
let auroLinkElem;
if (hasWallet === false) {
const auroLink = 'https://www.aurowallet.com/';
auroLinkElem = (
<div>
Could not find a wallet.{' '}
<a href="https://www.aurowallet.com/" target="_blank" rel="noreferrer">
Install Auro wallet here
</a>
</div>
);
}
const stepDisplay = transactionlink ? (
<a
href={transactionlink}
target="_blank"
rel="noreferrer"
style={{ textDecoration: 'underline' }}
>
View transaction
</a>
) : (
displayText
);
let setup = (
<div
className={styles.start}
style={{ fontWeight: 'bold', fontSize: '1.5rem', paddingBottom: '5rem' }}
>
{stepDisplay}
{auroLinkElem}
</div>
);
let accountDoesNotExist;
if (hasBeenSetup && !accountExists) {
const faucetLink =
`https://faucet.minaprotocol.com/?address='${publicKeyBase58}`;
accountDoesNotExist = (
<div>
<span style={{ paddingRight: '1rem' }}>Account does not exist.</span>
<a href={faucetLink} target="_blank" rel="noreferrer">
Visit the faucet to fund this fee payer account
</a>
</div>
);
}
let mainContent;
if (hasBeenSetup && accountExists) {
mainContent = (
<div style={{ justifyContent: 'center', alignItems: 'center' }}>
<div className={styles.center} style={{ padding: 0 }}>
Current state in zkApp: {currentNum?.toString()}{' '}
</div>
<button
className={styles.card}
onClick={onSendTransaction}
disabled={creatingTransaction}
>
Send Transaction
</button>
<button className={styles.card} onClick={onRefreshCurrentNum}>
Get Latest State
</button>
</div>
);
}
return (
<GradientBG>
<div className={styles.main} style={{ padding: 0 }}>
<div className={styles.center} style={{ padding: 0 }}>
{setup}
{accountDoesNotExist}
{mainContent}
</div>
</div>
</GradientBG>
);
The UI has three sections:
setup
lets the user know when the zkApp has finished loading.accountDoesNotExist
gives the user a link to the Faucet if their account hasn't been funded.mainContent
shows the current zkApp on-chain state and buttons to let users interact with zkApp. The buttons allow the user to create transaction in order to update on-chain zkApp state and refresh the current on-chain zkApp state.
That's it for the code review!
If you've been using npm run dev
, you can now interact with the UI application on localhost:3000
.
Deploying the application to GitHub Pages
Before you can deploy your project to GitHub Pages, you must push it to a new GitHub repository that you've created at the beginning of this tutorial.
- The GitHub repo must have the same name as the project name.
- In this tutorial, the project name is
04-zkapp-browser-ui
. - The
zk project
command created the correct project name strings in thenext.config.js
andsrc/pages/reactCOIServiceWorker.ts
files.
To deploy the UI:
Change to the
04-zkapp-browser-ui/ui/
directory.Run the
deploy
script by executing the following command:npm run deploy
Scripts defined in the 04-zkapp-browser-ui/ui/package.json
file do the work to build your application and publish it to the GitHub Pages.
After the command completion your zkApp UI will be available at:
https://<username>.github.io/04-zkapp-browser-ui/
where <username>
is your GitHub username.
Conclusion
Congratulations! You built a React UI for your zkApp that allows users to interact with deployed smart contract.
You can build UI for your zkApps using other frameworks like SvelteKit
and NuxtJS
.
You are ready to continue with Tutorial 5: Common Types and Functions to learn about different o1js types you can use in your zkApps.