Building dapps with Starknet React

Toothpaste on a toothbrush

Parts of this article were originally written for The Starknet Book. Check it out for more content on Starknet development.

The good news

If you've been following Starknet's progress, you're probably aware that there has been a lot of change recently. Cairo 1 was released, and that means frontend tools need updating to keep up.

If you have experience building dapps but are new to Starknet, the good news is that you can apply plenty of that knowledge here! If you're familiar with Starknet, the good news (in my opinion) is that the changes are moving in the right direction.

Getting started

The basic tools you can use to get started are Starknet.js and get-starknet. Starknet.js is an essential low level lib, equivalent to ethers.js, whereas get-starknet handles connecting to wallets. You should definitely read Starknet.js' docs for a more in-depth understanding.

With these tools, there are basically 3 main concepts to know on the frontend:

Account

We can generally think of the account as the "end user" of a dapp, and some user interaction will be involved to gain access to it.

Think of a dapp where the user connects their browser extension wallet (such as ArgentX or Braavos) - if the user accepts the connection, that gives us access to the account and signer, which can sign transactions and messages.

Unlike Ethereum, where user accounts are Externally Owned Accounts, Starknet accounts are contracts. This might not impact your dapp's frontend, but you should definitely be aware of this difference.

async function connectWallet() { const starknet = await connect(); console.log(starknet.account); starknet.account.signMessage(...) }

The snippet above uses the connect function provided by get-starknet to establish a connection to the user wallet.

Provider

This one is pretty simple. The provider allows you to interact with the Starknet network. You can think of it as a "read" connection to the blockchain, with methods such as getBlock or getChainId.

Just like in Ethereum, you can use a default provider, or use services like Infura or Alchemy, both of which support Starknet.

export const provider = new Provider({ sequencer: { network: "goerli-alpha", }, /* rpc: { nodeUrl: INFURA_ENDPOINT } */ }); const block = await provider.getBlock("latest"); // <- Get latest block console.log(block.block_number);

Contracts

Of course, your frontend will likely be interacting with deployed contracts. For each contract, there should be a counterpart on the frontend. To create these instances, you will need the contract's address and ABI, and either a provider or signer.

const contract = new Contract( abi_erc20, contractAddress, starknet.account ); contract.balanceOf(starknet.account.address)

If you create a contract instance with a provider, you'll be limited to calling read functions on the contract - only with a signer can you change the state of the blockchain.

Units

If you have previous experience with web3, you know dealing with units can be tricky, and Starknet is no exception. Once again, the docs are very useful here, especially this section on data transformation.

Very often you will need to convert Cairo structs (such as Uint256) that are returned from contracts into numbers:

// Uint256 shape: // { // type: 'struct', // low: Uint256.low, // high: Uint256.high // // } const balance = await contract.balanceOf(address); // <- uint256 const asBN = uint256.uint256ToBN(uint256); // <- uint256 into BN const asString = asBN.toString() //<- BN into string

And vice versa:

const amount = 1; const amountFormatted = { type: "struct", ...uint256.bnToUint256(amount), };

If we put it all together, calling a transfer function on a contract looks like this:

const tx = await contract.transfer(recipientAddress, amountFormatted); //or: const tx = await contract.invoke("transfer", [recipientAddress, amountFormatted]); console.log(`Tx hash: ${tx.transaction_hash}`);

There are other helpful utils, besides bnToUint256 and uint256ToBN, provided by Starknet.js.

We now have a solid foundation to build a Starknet dapp! If you have a background in regular Ethereum apps, you'll notice how similar the experience is, but you're probably also wondering if there are more high-level tools out there that we can use. Well, there are!

Starknet React

Starknet React is an open-source collection of React providers and hooks for Starknet, inspired by wagmi. If you've used wagmi before, you should have a pretty good idea of what it looks like, as it aims to have a very similar API.

To explore an example project showcasing a dapp built with Starknet React, check out the starknet-demo-dapp repo.

To use Starknet React, start by installing the necessary dependencies:

yarn add @starknet-react/core starknet get-starknet

Starknet.js is an SDK for interacting with Starknet, whereas get-starknet is a package for handling wallet connections.

Then, wrap your app in a StarknetConfig component. This allows for some configuration and provides a React Context for the underlying app to be able to use the shared data and hooks. StarknetConfig receives a connectors prop that defines which wallet connection options will be available to the user.

const connectors = [ new InjectedConnector({ options: { id: "braavos" } }), new InjectedConnector({ options: { id: "argentX" } }), ]; return ( <StarknetConfig connectors={connectors} autoConnect > <App /> </StarknetConfig> )

Connection and account

You can now use a hook to access the connectors you defined in the config, allowing the user to connect their wallet:

export default function Connect() { const { connect, connectors, disconnect } = useConnectors(); return ( <div> {connectors.map((connector) => ( <button key={connector.id()} onClick={() => connect(connector)} disabled={!connector.available()} > Connect with {connector.id()} </button> ))} </div> ); }

Note that there is also a disconnect function provided, in order to end the connection. Once the user has connected their wallet, you have access to the connected account through the useAccount hook. This also provides the current state of connection:

const { address, isConnected, isReconnecting, account } = useAccount(); return ( <div> {isConnected ? ( <p>Hello, {address}</p> ) : ( <Connect /> )} </div> );

Note that state values such as isConnected and isReconnecting are updated automatically, making it easier to update the UI conditionally. This is a very useful and common pattern when dealing with asynchronous processes, as it means you don't have to manually keep track of that state locally in your components.

Once the user is connected, signing messages may be done through the account value returned from the useAccount hook, or more simply through the useSignTypedData hook.

const { data, signTypedData } = useSignTypedData(typedMessage) return ( <> <p> <button onClick={signTypedData}>Sign</button> </p> {data && <p>Signed: {JSON.stringify(data)}</p>} </> )

You can sign an array of BigNumberish, or an object. In the event of signing an object, the data must be correctly typed, in accordance with EIP712. You can find a more in depth explanation here.

Network

Starknet React also provides hooks for interacting with the provider. For instance, useBlock fetches the latest block:

const { data, isError, isFetching } = useBlock({ refetchInterval: 10_000, blockIdentifier: "latest", }); if (isError) { return ( <p>Something went wrong</p> ) } return ( <p>Current block: {isFetching ? "Loading..." : data?.block_number}<p> )

The refetchInterval specifies how often the hook will attempt to refetch the data. Under the hood, Starknet React uses react-query for state and query management. Besides useBlock, there are other hooks that can be configured to periodically update, such as useContractRead and useWaitForTransaction.

With the useStarknet hook, it's also possible to directly access the ProviderInterface:

const { library } = useStarknet(); // library.getClassByHash(...) // library.getTransaction(...)

Interacting with contracts

Read functions

Just like with wagmi, there is a useContractRead hook provided specifically for calling read functions on contracts. Note that this may be used even with without a connected user, as read functions don't require a signer.

const { data: balance, isLoading, isError, isSuccess } = useContractRead({ abi: abi_erc20, address: CONTRACT_ADDRESS, functionName: "allowance", args: [owner, spender], // watch: true <- refresh at every block });

For working with ERC20s, there is also a convenience hook - useBalance. This hook doesn't require passing in an ABI, and will return a correctly formatted balance value.

const { data, isLoading } = useBalance({ address, token: CONTRACT_ADDRESS, // <- defaults to the ETH token // watch: true <- refresh at every block }); return ( <p>Balance: {data?.formatted} {data?.symbol}</p> )

Write functions

For write functions, the useContractWrite hook is slightly different to wagmi. Due to Starknet's architecture, accounts can natively support multicall transactions. In practice, this means an improved user experience when executing multiple transactions, as you won't have to individually approve each transaction. Starknet React makes the most of this feature through the useContractWrite hook. It can be used in the following manner:

const calls = useMemo(() => { // compile the calldata to send const calldata = stark.compileCalldata({ argName: argValue, }); // return a single object for single transaction, // or an array of objects for multicall** return { contractAddress: CONTRACT_ADDRESS, entrypoint: functionName, calldata, }; }, [argValue]); // Returns a function to trigger the transaction // and state of tx after being sent const { write, isLoading, data } = useContractWrite({ calls, }); function execute() { // trigger the transaction write(); } return ( <button type="button" onClick={execute}> Make a transaction </button> )

In the example above, we first compile the calldata to be executed using Starknet.js's compileCalldata util. We pass the contract address, entrypoint, and calldata to the useContractWrite hook, which returns a write function we can use to actually trigger the transaction. The hook also returns the hash and state of the contract call.

Single instance of a contract

Using useContractRead and useContractWrite might not be a good fit for your use case - you might want to work with a single instance of the contract, instead of continually specifying its address and ABI in individual hooks. This is also possible throught the useContract hook:

const { contract } = useContract({ address: CONTRACT_ADDRESS, abi: abi_erc20, }); // Call functions directly on contract // contract.transfer(...); // contract.balanceOf(...);

Transactions

Once you have a transaction hash, you may track it's state through the useTransaction hook. This keeps a cache of all transactions, reducing duplicated network requests.

const { data, isLoading, error } = useTransaction({ hash: txHash }); return ( <pre> {JSON.stringify(data?.calldata)} </pre> )

Wrapping up

You can find a working example with any of these hooks implemented in this repo.

Expect more changes in the next few months, as the Starknet ecosystem continues to evolve!

Happy hacking :wave: