Many existing tutorials about Solana focus on writing programs (commonly known as smart contracts on other blockchains), minting tokens or NFTs - however a decentralized app is incomplete without an interface and needs to be hosted on the web to reach a wider audience. In this tutorial, we will explain how to develop a frontend interface for Solana programs and how to make beautiful web apps with the help of Chakra UI.
The code for this tutorial is available in the repository https://github.com/kanav99/solana-boilerplate. Each part of the tutorial refers to the repository, which is mentioned at the end of the part. If you are comfortable with using git on the commandline, you can
git checkout <commit hash>or just click on the link to the code to refer to the code at that checkpoint.
Have a basic understanding of Solana backend and have gone through the helloworld example here.
A basic understanding of React.js is required.
For this tutorial, we need the following software installed -
- Node.js version 14.18.1 or higher
- The Rust toolchain, which can be found at https://rustup.rs
- The Solana CLI, which can be found at https://docs.solana.com/cli/install-solana-cli-tools
- A Solana Wallet (like Phantom, Solflare, etc.), check out the Wallet guide here.
To get started, we will create an empty Chakra UI app using
create-react-app, which is a program used to set up a React development environment and generate a starting point from templates. Run the following commands in your terminal:
npx create-react-app solana-boilerplate --template @chakra-ui cd solana-boilerplate
- This creates a scaffolding for a simple web app with the main file of the application,
src/App.js, containing a welcome message and a rotating Logo. We don't need that, so let's remove the existing contents of App.js and replace it with the following:
To start the development server, use the command
npm startin your terminal. Once it has loaded you will be able to visit the running app in your browser at
http://localhost:3000- you'll see an empty page with a convenient colour mode switching button with a "Hello world!" floating in the middle.
If this is your first time seeing a Chakra UI app, here is a refresher. All chakra components must be wrapped between
ChakraProvider, which controls the theming of all children components.
Boxis equivalent to
divtag of HTML.
VStackis a vertical stack of elements spaced evenly.
To interact with the Solana Networks (mainnet, devnet, local etc.), we use the Solana JSON RPC API. Instead of making raw jRPC calls, we will use the package
@solana/web3.js, which interacts with the API.
- We begin by installing the package to the app
npm install --save @solana/web3.js
- Import the package in App.js
- We are not using wallets as of now (more on that in subsequent sections), so we will make a makeshift wallet; if a private key does not exist in local storage, generate a new one. So, we add this code to the global scope
Keep in mind - this makeshift wallet is not to be used in production. We will replace this with actual wallets in the coming sections; the current code is just for understanding the structure. Now we have set up a connection with the network and have a wallet ready as well. Let us display some basic properties of the wallet - namely, public key and balance. The public key can be retrieved by
wallet.publicKey.toBase58(), but the balance must be fetched from the network.
In the App.js file, to retrieve balance on-load, we use
useEffectto interact with the network. We maintain a state variable to store the account info. Inside the App function,
We have fetched the account details. This object contains the balance in lamport units, which is equivalent to 1/1000000000 of a SOL. Now to display the public key and balance, we change the rendering code to -
You can also use
connection.getBalanceto get only balance as well.
All the code till now is present in the
d7ecab7commit of the final repository.
To work with the Solana network, we need some SOLs. To get them on the mainnet, we need to buy them from any exchange and transfer them to the public key displayed on the page. However, we are on devnet, so we can just get them for free through airdrops. We can do it from the CLI, but let's make an easy button to get them on click.
toast(we will use toasts for beautiful erroring) from chakra. First, define the callback that gets an airdrop and updates account object.
And add a button in the rendering part -
Get code on commit
Suppose we want to get the ten most recent transactions whenever we load the page. We can create a new react state
transactions, and update the code inside the
and add this loop inside the rendering part.
But we also want to call this
initfunction after we airdrop, so that the transaction that added the SOLs also gets logged. So we can move this init function out of
useEffect, but inside the
Appfunctional component. Hence, just before the function
getAirdropends, we can call
initand update the account balance and transactions.
You might be seeing a problem over here - the transaction, balance, or account status, in general, is not real-time. This is what we will fix in the next section. For the code till now, refer to the commit
Ever wondered how functions like
useWallet(in most web3 frameworks) work? These are all custom hooks. Right now, in our code, we have all of the code for frontend, wallet info, getting airdrop is all in a single function
App. The account info is not real-time. The code in the
Appfunctional component should not have access to
setTransactions; it should just receive account info and a list of transactions and show it as a list. We will fix all of it using React custom hooks. Using hooks, we can localize a part of the application's state inside a separate function that internally manages and updates the value of those states, returning only the value of the real-time state. If you do not understand any part of what is coming, bear with me until the code for the hook is complete.
All custom hooks should start with "use", so let us call our hook
useSolanaAccount. We know for sure that the hook should have the state of account and transactions and should return both.
Now the empty state is not that useful; let us update it. We want the state to be updated every 1 second. So, it would suffice to run the same old
initfunction every 1 second. So, let us move that function in here.
Now, to run
initevery 1 second, we will use the
useEffecthook to set an interval using
setInterval. You may ask why inside
useEffect? That's because we only want to set the interval once - writing it directly inside the function body will call it every time the state updates, causing it to run the
initfunction multiple times (i.e., the number of times state changed) every second (too much math? you can remember it as golden rule - don't run any fetch-y code directly inside the react functional component or hook). The final hook looks like this -
We can now remove the definition, and all calls of
Appand change the state inside it to
Voila! No more
setTransactionsinside the App component. No more manually updating the state after every change. Try sending some SOLs from the CLI to this account and see it update in real-time! As an exercise to the reader, try using
connection.onAccountChangeinstead of setInterval for getting updates :). Code until now is present in the commit
Now, before spending any of their precious SOLs, we want our users to feel safe about how we handle wallets. It is always good to leave the wallet logic and how the private key is stored to existing well-known projects. Solana has many wallet options like Solflare, Sollet, Phantom etc. We will make our application compatible with all of these using
solana/wallet-adapterpackage(s). The goal of this section would be to remove this particular piece of code -
... and replace it with something safer. The current implementation enables the person who hosts this application access to every user's wallet, which is not right.
- Start by installing the required packages
- Add the required imports just after the previous imports end. Note that the last import is done using
requireand would need to be after all the
imports at all the times.
- Inside the
Appcomponent body, add
Here, we define three constants. First is
network, just the string which tells which network we are on. The
endpointis a string containing the selected network's RPC URL (i.e. Devnet). The third constant,
wallets, is an array of wallet descriptors that we want our app to work with. The
useMemois a function that memoizes the value of the function output (passed as the first argument) and only changes when a list of state variables change their value (this dependency array is passed as the second argument). We currently will work with Phantom, Solflare and Sollet.
To make all of the children components of
Appable to access the network and wallet, we have to wrap all sub-components in
WalletProvider. Then, the children elements can use
useConnectionto access the wallet and network. After wrapping the components in these, the rendering of App should look something like this -
Let us now delete our old definition of
pvkey- let a wallet provide that. Also, we will have to refactor our initial code such that we have a new
Homefunctional modal which contains all the design and logic, and
Appshould only have these providers. So, the App becomes -
... and move all the contents to Home. Inside Home, we have to take care of some things. First, the
connectionobject now comes from
useConnection. Second, the
publicKeyshould now come from
useWallet. Third, the
publicKeywe now get might be empty because the user might not have connected the wallet yet. In that case, we need to ask the user to connect their wallet with the application.
So, after the changes in the logic, the Home component should look like:
The third part that needs to be changed before we can conclude our wallet adaptation is the
Notice that we have converted the
useCallback- that is because we pass this function in the
setInterval; at that time, the publicKey is empty. So now, when the user changes the publicKey,
initcallback changes; consequently,
useEffectis called, and a new interval is set then.
There is still one issue in this, which I am leaving as an exercise - the user may change their public key in between. In that case, we have to remove the interval created using
clearIntervaland then create a new interval.
The code till now is present in the commit
Until this point, we only read from the blockchain; we have not written anything on it actively (airdrops happen due to a public program). In this part, we will learn to "write" on the blockchain by interacting with the programs deployed on it. Now, we will make a button component that says hi to a person's account, and the account maintains a counter of the number of accounts that greeted it.
For this, let us use the greeter code from the example-helloworld repository. If you have not gone through this example already, I recommend you to. Generally, in Solana Programs, we generate a "program account" that is "owned" by the program but is "related" to your account. Anyone can create an account, not only the owner; you can pay for the space it needs and create a program account. The public key for this program account is generated using your public key and a constant seed, using the function
PublicKey.createWithSeed. This is basically a one-to-one mapping between your public key and the program account public key, given the seed is fixed. Now, anyone with your public key can generate your program account's public key and tell the program to greet this account (or just increase the counter for this account).
- Clone the repository https://github.com/solana-labs/example-helloworld.
- While being inside the repository, run
npm run build:program-rustto build the program.
- Deploy the program to devnet using
solana program deploy --url https://api.devnet.solana.com dist/program/helloworld.so. This should print out a program ID. Please take a note of it. For me, it was FGbjtxeYmT5jUP7aNavo9k9mQ3rGQ815WdvwWndR7FF9, so I will use this in the following example.
Now, let us not cram all the code into a single file. Create a new file,
Greet.jsx, with this starter code.
And inside the App.js file, add the import -
And just below our airdrop button, add this newly made component -
Now in this Greet component, we want two things to happen:
- Get the current number of greetings sent to you.
- Greet yourself.
We will create an interface to our Rust backend. We will follow what is given in the helloworld example. The code will look very similar to the code present here.
- Store the program id and a fixed seed as a global constant.
- To have a similar interface as in the Rust struct
- Now, accounts in Solana only store raw bytes. As we serialize/deserialize in the Rust code using borsh, we will do the same here using the borsh package. Deserializing using borsh requires a schema that tells the deserializing logic about the size of different fields. We create that schema next, along with the total size of the serialized class object (we need the size when we create a new greeting account and pay only for the size each greeting account needs).
- Whenever we fetch the account info from the blockchain, we get it as a form of
AccountInfo. To directly fetch the counter from it, we define a straightforward function, which gets the data from AccountInfo, deserializes it, and returns the counter subobject.
- We now start writing out
Greetcomponent by adding the wallet and connection descriptors, along with a state variable containing the current counter of greetings and change the text that shows the counter.
- First, we will write the logic that fetches the current number of greetings sent to you. What we need to do is, first on page load, get the current number of greetings sent to you, and then add a listener on changes made to the account for any new greetings (this time, we will use
onAccountChangeinstead of polling). To get the current number of greetings, we will use
connection.getAccountInfoon the generated program account inside
useEffectto do it on load.
If we do not have created the program account yet, we will set the counter to zero. Otherwise, we will use the
counterFromAccountInfoto get the counter.
One time job is done. However, we want to change the counter every time you or someone else greets you. So we use
connection.onAccountChangeto add a listener.
So, the final
useEffectshould look like this -
- Second thing that we want to implement is to greet ourselves when we click the button. For this, we create a new callback and pass it as the onClick for the button.
The logic for this should be pretty straightforward:
- Generate program account public key using
PublicKey.createWithSeed, if the account does not exist, pay for creating the account with the required storage space.
- Send the program the public key to greet (in this case, our own program key).
For the first part, the code is given below. See that we create a Transaction and add an
SystemProgram, which tells the
SystemProgramto create a new account with the correct required space. This should help you realize that creating a new account is a Solana Program in itself!
For the second part of the logic, we send an instruction to our deployed program and send it the key of the generated program key. As our program only accepts a single type of instruction, that is "greet", and it does not need any arguments, we do not need to send any data.
Finally, your callback should look like this -
Yay! You can now greet yourself! ... Doesn't seem exciting? Let us greet others in the next section :). Code till now is available in the commit
Possible improvements - As
PublicKey.createWithSeedmight be an expensive operation, try memoizing using
useMemo? Set the button in loading state while the transaction is being sent?
Now, we might want to greet others as greeting ourselves is not that cool. For greeting others, we will need a public key for that account; then we can send them a greeting. Our goal of the section is to send greetings to others by adding a textbox to our application where you can write the public key of your friend and then click a button to send a greeting their way.
First, as we will reuse the same logic from the previous code, we should move the greeting code into a function that accepts a public key (the one to greet) created by
useCallbackso that we can reuse it. What I did was - Added an argument, which is the base58 of the public key of the recipient, to the old
greetcallback and replaced
PublicKey(<argument>). Now we can create a separate callback called
greetYourselfwhere we send our own
wallet.publicKey.toBase58()and pass this callback to the "Greet Yourself" button.
Now what remains is the ReactJS code used to render the label and input:
Code till here is present in the commit
The logic is good enough now, but the page looks like everything is crammed into a single page. Let us clean this up. We will use Chakra UI tabs on the homepage to split the page into two pages - Home and Transaction History. So, in the Home, we change the rendering code to
See how we have split the code into two panels - the second one contains transaction history, and the first one contains the rest of the code. We shifted the colour mode switcher to the end of the tablist. We can add a wallet disconnect button there as well, if the wallet is connected.
In case the wallet is not connected, instead of just throwing the connect button, let us create a separate component that looks better.
Let us use
SimpleGridto separate the wallet properties and greeting code and use read-only form input to show data beautifully.
After this change, do similar changes to the
The code till here is available in the commit
e56e2d5, or just the current master branch.
Congratulations on completing this tutorial! What we developed and understood was how we can use Chakra UI and Solana to make beautiful and fast dApps. Now, you should be able to use this knowledge to power your big idea and easily use Solana to make blazing fast applications while using the ease of Chakra to develop the frontend with confidence. There is still much that can be improved in this simple application, but I will leave that to the imagination of the reader.