I've been developing web2 development for about 10 years, and I've been developing web3 frontend for 1.5 years.
This is my current stacks/libs for web3 frontend. If you have any other recommendations, please let me know with comment.
Index
1. System architecture
2. Stacks, Libs
Folder structure : Turborepo
I prefer monorepo (multiple apps in one repo)
structure. Most of the code is in a common folder under packages
, and each app has only a minimal page component for routing.
You can easily extend apps in the following cases:
- DApps that has different pages or functions for each chain
- Lite version with limited function
- Middleware or tools using same function
.
├── apps
│ ├── dapp
│ │ └── pages (or app) # each app has only pages dir
│ ├── sub-dapp
│ └── ...
├── packages
│ ├── assets
│ │ ├── img
│ │ └── styles
│ ├── config
│ ├── lib
│ │ ├── constants
│ │ │ └── abis # abi json
│ │ ├── queries # thegraph queries
│ │ ├── store # state
│ │ ├── types
│ │ └── utils
│ ├── react-lib
│ │ ├── components # components
│ │ └── hooks # hooks
│ └── tsconfig
└── test
Frontend Framework & hosting : Next.js + TypeScript + Vercel
React is first choice, because of it's many web3 libs.
Vercel is the easiest place deploying Next.js with Turborepo. However, by using vercel's useful features (Edge functions, ISR, etc.), it is more difficult to deploy to IPFS and make dApp fully decentralized.
Vite or esbuild are also suitable for smaller applications and tools.
Connecting ethereum : wagmi + ethers.js
wagmi
is React Hooks built on top of ethers.js. There are many such libs, but I think this is the best. -> wagmi's Comparison To Other Libraries
wagmi
provide TypeScript types for Ethereum ABIs and this works with zod
schema. This brings more strict typecheck for dApp. -> ABIType
Also wagmi’s hooks are very useful, such as useContractReads
. It's wrapping Multicall3
for multiple read-calls.
Write hook pattern example:
// react-lib/hooks/useApprove.ts
import { usePrepareContractWrite, useContractWrite, useWaitForTransaction, erc20ABI } from 'wagmi'
....
export default function useApprove(targetAddress: AddressType, owner: AddressType, spender: AddressType) {
....
const args: [AddressType, BigNumber] = useDebounce([spender, BigNumber.from(amount)])
const enabled: boolean = useDebounce(!!targetAddress && !!owner && !!spender)
const prepareFn = usePrepareContractWrite({
address: targetAddress,
abi: erc20ABI,
functionName: 'approve',
args,
enabled
})
const writeFn = useContractWrite({
...prepare.config,
onSuccess(data) {
....
}
})
const waitFn = useWaitForTransaction({
chainId,
hash: write.data?.hash,
wait: write.data?.wait
})
return {
prepareFn,
writeFn,
waitFn
}
}
// in component
import useApprove from 'react-lib/hooks/contracts/useApprove'
....
export default function SomeComponent() {
const { prepareFn, writeFn, waitFn } = useApprove(targetAddress, account, spenderAddress)
// handle view with each hook's return value -> https://wagmi.sh/react/hooks/useContractWrite#return-value
return (
<div>
{approve.prepare.isSuccess &&
<button
onClick={() => writeFn?.write}
disabled={writeFn.isLoading || prepareFn.isError}
>
Approve
</button>
)}
{writeFn.isLoading && <p>Loading...</p>}
{writeFn.isSuccess && <p>Tx submitted!</p>}
{waitFn.isSuccess && <p>Approved!</p>}
</div>
)
}
Indexing : The Graph + urql
The Graph is an indexing protocol for querying networks like Ethereum and IPFS. Anyone can build and publish open APIs, called subgraphs, making data easily accessible.
Since contract calls are slow and can only be called one at a time, an indexing layer is needed to list multiple contract call results and calculate aggregate values.
Aggregation and indexing can be specified in subgraphs triggered by contract events, and the results are stored in thegraph’s db.
On the client side, we can query data graphql format.
urql
is a lightweight graphql client with a simple caching strategy.
It was a bit difficult to run thegraph node locally and debug subgraph's code. Since subsquid recently added EVM chain support, I will try this next.
// define query
import { gql } from 'urql'
export const GET_MY_LIQUIDITIES = gql`
query getMyLiquidities($account: String) {
${FRAGMENT}
user(id: $account) {
id
liquidities {
id
protocols {
...Fragment
}
}
}
}
`
// call in component
import { useQuery } from 'urql'
....
export function SomeComponent() {
....
const [{ data, fetching }] = useQuery({
query: GET_MY_LIQUIDITIES,
variables: {
account: account.toLowerCase()
},
pause: !account
})
....
}
UI, CSS : tailwindcss + PostCSS + Radix UI + UI components by shadcn
I prefer plain & customizable style-less frameworks. Radix UI has more features than Headless UI, but adding style is more difficult. So components by shadcn is good starting point.
I use this CSS variables with Tailwind CSS setup.
Current postcss setup is below.
// postcss.config.js
module.exports = {
plugins: {
"postcss-import": {}, // inline importing for external lib's css
"tailwindcss/nesting": {}, // enable nesting
tailwindcss: {},
autoprefixer: {},
},
};
State management : jotai
jotai
is a state management library that is easy to use and lightweight.
It can use simply like useState
+ ContextAPI
, prevent extra-rerender and has many utilities.
Similar libraries are recoil, zustand, valtio. You can choose the one you like.
// packages/store/index.ts
import { atom } from "jotai";
type SessionType = {
chainId?: number;
account?: string;
};
export const sessionsAtom = atom<SessionType>({});
// in component or hook
import { sessionAtom } from 'lib/store'
import { useAtom } from 'jotai'
....
const [session, setSession] = useAtom(sessionAtom)
....
setSession({ chainId: res.chainId, account: res.address })
Form library : jotai-form & zod
This setup can be validate form inputs more react-like way. -> jotai-form with zod example
If you aren't using jotai or need more complex validation, you can use react-hook-form with zod.
3. Web3 frontend tips
Handling transactions async
Blockchain response (especially in Mainnet) is very slower than web2's.
After a user submit transaction, there should be appropriate feedback to the user, such as display loading and showing the status of the transaction.
There are some cases where a user actions are not reflected in user's browser because of delays in updating data from thegraph, even though the user's trancsaction has been confirmed. In such case, I use thegraph's data for initial rendering, and overwrite it's value with contract call result later.
In addition, in order to handle cases where a user has left a page or site before the transaction confirmed, a user's uncompleted transactions are stored once in state, persisted in localStorage
, and held until confirmation.
// watching uncompleted transaction
import { useEffect } from 'react'
import { useBlockNumber, useProvider } from 'wagmi'
....
export default function useTxHandler() {
const { chainId, account } = useWallet()
const transactions = useTransactions() // get user's transactions from state
const blockNumber = useBlockNumber({
chainId,
scopeKey: 'useTxHandler',
watch: true
})
const provider = useProvider<any>()
useEffect(() => {
transactions.map(async (tx: Transaction) => {
await provider
.getTransactionReceipt(tx.hash)
.then(async (transactionReceipt: any) => {
if (transactionReceipt) {
.... // update user's transaction status here
})
}
})
.catch((e: any) => {
log(`failed to check transaction hash: ${tx.hash}`, e)
})
})
}, [account, chainId, PROVIDER_URL, transactions, blockNumber.data])
}
Handling BigNumber
ERC20 has decimals
fields and must be handled with digit awareness.
I wanted to use only one library, but I used both ether.js's BigNumber and bignumber.js (for display purpose).
Uniswap tokenlist format
This package includes a JSON schema for token lists, and TypeScript utilities for working with token lists.
OnChain token informations are limited and must be kept somewhere in OffChain in list format.
It is convenient to follow this format published by uniswap when handling.
4. Things that I would try
Permit2
Permit2 introduces a low-overhead, next generation token approval/meta-tx system to make token approvals easier, more secure, and more consistent across applications.
Instead of approve each dApp's each contract, once you approve Permit2
, you can approve allowance to any ERC20 contracts with time-bounded(during the transaction).
This can improve UX (no more approves in my wallet!) & security (Problems caused by large amounts of allowances remaining against each dapp's contract) greatly.
Aztec Connect
Aztec Connect allows transactions running on Aztec L2 with MainNet's security & liquidity.
There are many advantages such as improved security without redeploying, lower gas fee, etc.
Synpress
Synpress is e2e testing framework based on Cypress.io and playwright with support for metamask.
I have only written component and library's tests now, but I would like to try E2E testing as well.
Top comments (0)