DEV Community

Yuji Yamaguchi
Yuji Yamaguchi

Posted on • Edited on

Web 3.0 frontend stacks in 2023

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
  3. Web3 frontend tips
  4. Things that I would try

1. System architecture

Image description

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
        }
      }
    }
  }
`
Enter fullscreen mode Exit fullscreen mode
// call in component
import { useQuery } from 'urql'
....
export function SomeComponent() {
  ....
  const [{ data, fetching }] = useQuery({
    query: GET_MY_LIQUIDITIES,
    variables: {
      account: account.toLowerCase()
    },
    pause: !account
  })
  ....
}
Enter fullscreen mode Exit fullscreen mode

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: {},
  },
};
Enter fullscreen mode Exit fullscreen mode

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>({});
Enter fullscreen mode Exit fullscreen mode
// 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 })
Enter fullscreen mode Exit fullscreen mode

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])
}
Enter fullscreen mode Exit fullscreen mode

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)