diff --git a/docs/whats-new.md b/docs/whats-new.md index 65f24c692c5..1c8919ce993 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -11,6 +11,9 @@ of the [MetaMask developer page](https://metamask.io/developer/). ## June 2024 +- Updated [React dapp with global state tutorial](/wallet/tutorials/react-dapp-global-state) with + instructions for EIP-6963. + ([#1330](https://github.com/MetaMask/metamask-docs/pull/1330)) - Documented that the Gas API can be [called without an API key secret](/services/gas-api/api-reference). ([#1346](https://github.com/MetaMask/metamask-docs/pull/1346)) - Updated [Snaps resources](/snaps/learn/resources) and added a new section "Snaps for developers." ([#1329](https://github.com/MetaMask/metamask-docs/pull/1329)) - Documented [how to allow automatic connections to a Snap](/snaps/how-to/allow-automatic-connections). @@ -22,6 +25,9 @@ of the [MetaMask developer page](https://metamask.io/developer/). ([#1276](https://github.com/MetaMask/metamask-docs/pull/1276)) - Discontinued support for [`eth_sign`](/wallet/concepts/signing-methods/#eth_sign). ([#1319](https://github.com/MetaMask/metamask-docs/pull/1319/)) +- Updated [React dapp with local state tutorial](/wallet/tutorials/react-dapp-local-state) with + instructions for EIP-6963. + ([#1299](https://github.com/MetaMask/metamask-docs/pull/1299)) - Documented [Snaps initial connections](/snaps/reference/permissions/#initial-connections). ([#1318](https://github.com/MetaMask/metamask-docs/pull/1318/)) - Updated [Snaps allowlisting guide](/snaps/how-to/get-allowlisted) with open permissions. diff --git a/wallet-sidebar.js b/wallet-sidebar.js index 4d750504443..d43e6a215bf 100644 --- a/wallet-sidebar.js +++ b/wallet-sidebar.js @@ -21,16 +21,7 @@ const sidebar = { type: "category", label: "Tutorials", link: { type: "generated-index", slug: "/tutorials" }, - items: [ - { - type: "doc", - id: "tutorials/react-dapp-local-state", - }, - { - type: "doc", - id: "tutorials/javascript-dapp-simple", - }, - ], + items: [{ type: "autogenerated", dirName: "tutorials" }], }, { type: "category", diff --git a/wallet/assets/tutorials/react-dapp/react-tutorial-02-final-preview.png b/wallet/assets/tutorials/react-dapp/react-tutorial-02-final-preview.png new file mode 100644 index 00000000000..ed0f5c2c343 Binary files /dev/null and b/wallet/assets/tutorials/react-dapp/react-tutorial-02-final-preview.png differ diff --git a/wallet/assets/tutorials/react-dapp/react-tutorial-02-selected-wallet.png b/wallet/assets/tutorials/react-dapp/react-tutorial-02-selected-wallet.png new file mode 100644 index 00000000000..c505559bb8b Binary files /dev/null and b/wallet/assets/tutorials/react-dapp/react-tutorial-02-selected-wallet.png differ diff --git a/wallet/assets/tutorials/react-dapp/react-tutorial-02-wallet-error.png b/wallet/assets/tutorials/react-dapp/react-tutorial-02-wallet-error.png new file mode 100644 index 00000000000..faef95049c4 Binary files /dev/null and b/wallet/assets/tutorials/react-dapp/react-tutorial-02-wallet-error.png differ diff --git a/wallet/assets/tutorials/react-dapp/react-tutorial-02-wallet-list.png b/wallet/assets/tutorials/react-dapp/react-tutorial-02-wallet-list.png new file mode 100644 index 00000000000..f88e4b88bfa Binary files /dev/null and b/wallet/assets/tutorials/react-dapp/react-tutorial-02-wallet-list.png differ diff --git a/wallet/tutorials/javascript-dapp-simple.md b/wallet/tutorials/javascript-dapp-simple.md index 9c6bc7881fa..550fe9edbdf 100644 --- a/wallet/tutorials/javascript-dapp-simple.md +++ b/wallet/tutorials/javascript-dapp-simple.md @@ -86,10 +86,8 @@ Update `index.html` to include the script: ### 3. Detect MetaMask :::caution - The `@metamask/detect-provider` module is deprecated, and is only used here for educational purposes. In production environments, we recommend [connecting to MetaMask using EIP-6963](../how-to/connect/index.md). - ::: Install the `@metamask/detect-provider` module in your project directory: diff --git a/wallet/tutorials/react-dapp-global-state.md b/wallet/tutorials/react-dapp-global-state.md index 5fbb4d8930e..f4f3d2f0933 100644 --- a/wallet/tutorials/react-dapp-global-state.md +++ b/wallet/tutorials/react-dapp-global-state.md @@ -1,37 +1,38 @@ --- -description: Create a multi-component React dapp with global state. +description: Create a multi-component React dapp with global state using EIP-6963. toc_max_heading_level: 4 sidebar_position: 2 --- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + # Create a React dapp with global state This tutorial walks you through integrating a React dapp with MetaMask. -The dapp has multiple components, so requires managing global state. +The dapp has multiple components and requires managing the state globally, which can be helpful for +real-world use cases. You'll use the [Vite](https://v3.vitejs.dev/guide) build tool with React and TypeScript to create the dapp. -:::tip -We recommend first [creating a React dapp with local state](react-dapp-local-state.md). -This tutorial is a follow-up to that tutorial. -::: +The final state of the dapp will look like the following: -The [previous tutorial](react-dapp-local-state.md) walks you through creating a dapp that connects -to MetaMask and handles account, balance, and network changes with a single component. -In real world use cases, a dapp might need to respond to state changes in different components. +![React dapp with global state](../assets/tutorials/react-dapp/react-tutorial-02-final-preview.png) -In this tutorial, you'll move that state and its relevant functions into -[React context](https://react.dev/reference/react/useContext), creating a -[global state](https://react.dev/learn/reusing-logic-with-custom-hooks#custom-hooks-sharing-logic-between-components) -so other components and UI can affect it and get MetaMask wallet updates. +In this tutorial, you'll put the state into a [React +Context](https://react.dev/reference/react/useContext) component, creating a [global +state](https://react.dev/learn/reusing-logic-with-custom-hooks#custom-hooks-sharing-logic-between-components) +that allows other components and UI elements to benefit from its data and functions. +You'll use `localStorage` to persist the selected wallet, ensuring the last connected wallet state +remains intact even after a page refresh. -This tutorial also provides a few best practices for a clean code base, since you'll have multiple -components and a slightly more complex file structure. +This tutorial addresses the edge case where a browser wallet might be disabled or uninstalled +between refreshes or visits to the dapp. +You'll add a disconnect function to reset the state, and use +[`wallet_revokePermissions`](/wallet/reference/wallet_revokePermissions) to properly disconnect from MetaMask. :::info Project source code -You can see the source code for the -[starting point](https://github.com/MetaMask/react-dapp-tutorial/tree/global-state-start) and -[final state](https://github.com/MetaMask/react-dapp-tutorial/tree/global-state-final) of this dapp. +You can view the [dapp source code on GitHub](https://github.com/MetaMask/vite-react-global-tutorial). ::: ## Prerequisites @@ -40,556 +41,736 @@ You can see the source code for the - [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) version 9+ - A text editor (for example, [VS Code](https://code.visualstudio.com/)) - The [MetaMask extension](https://metamask.io/download) installed -- Basic knowledge of TypeScript, React and React Hooks +- Basic knowledge of TypeScript, React, React Context, and React Hooks + +:::tip +We recommend following the [Create a React dapp with local state](react-dapp-local-state.md) +tutorial first, which introduces [EIP-6963](https://eips.ethereum.org/EIPS/eip-6963). +The tutorial demonstrates how to iterate over all discovered providers, connect to the selected +wallet, and remember the selection within a single component. + +If you skip the tutorial, consider reviewing [wallet +interoperability](../concepts/wallet-interoperability.md) to understand how multiple injected wallet +providers work. +::: ## Steps ### 1. Set up the project -Clone the [`react-dapp-tutorial`](https://github.com/MetaMask/react-dapp-tutorial) GitHub repository -on GitHub by running the following command: +This project introduces a new structure, independent of previous tutorials. +Instead of reusing code or states, this tutorial guides you through breaking down the +single-component structure into multiple components. + +Set up a new project using Vite, React, and TypeScript by running the following command: ```bash -git clone https://github.com/MetaMask/react-dapp-tutorial.git +npm create vite@latest vite-react-global-state -- --template react-ts ``` -Checkout the `global-state-start` branch: +Install the node module dependencies: ```bash -cd react-dapp-tutorial && git checkout global-state-start +cd vite-react-global-state && npm install ``` -Install the node module dependencies: +Launch the development server: ```bash -npm install +npm run dev ``` -Open the project in a text editor. +This displays a `localhost` URL in your terminal, where you can view the dapp in your browser. -:::note tip +:::note If you use VS Code, you can run the command `code .` to open the project. +If the development server has stopped, you can run the command `npx vite` or `npm run dev` to +restart your project. ::: -This is a working React dapp, but it's wiped out the code from the previous tutorial's -[`App.tsx`](https://github.com/MetaMask/react-dapp-tutorial/blob/local-state-final/src/App.tsx) file. +Open the project in your editor. +Create three directories, `src/components`, `src/hooks`, and `src/utils`, in the root of the project +using the following commands: + +```bash +mkdir src/components && mkdir src/hooks && mkdir src/utils +``` + +Create the following files in `src/components`, which will be used to create components for listing +installed wallets, displaying connected wallet information, and handling errors: + +- `WalletList.tsx` +- `WalletList.module.css` +- `SelectedWallet.tsx` +- `SelectedWallet.module.css` +- `WalletError.tsx` +- `WalletError.module.css` + +Create the following files in `src/hooks`: + +- `Eip6963Provider.tsx` +- `useEip6963Provider.tsx` -Run the dapp using the command `npx vite`. -The starting point looks like the following: +Create the following file in `src/utils`: -![](../assets/tutorials/react-dapp/pt2-01.png) +- `index.ts` -There are three components, each with static text: navigation (with a logo area and connect button), -display (main content area), and footer. -You'll use the footer to show any MetaMask errors. +#### 1.1. Style the components -Before you start, comment out or remove the `border` CSS selector, as it's only used as a visual aid. -Remove the following line from each component style sheet: +Add the following CSS code to `SelectedWallet.module.css`: -```css title="Display.module.css | MetaMaskError.module.css | Navigation.module.css" -// border: 1px solid rgb(...); +```css title="SelectedWallet.module.css" +.selectedWallet { + display: flex; + flex-flow: row nowrap; + justify-content: flex-start; + + padding: 0.6em 1.2em; + margin-bottom: 0.5em; + + font-family: inherit; + font-size: 1em; + font-weight: 500; +} +.selectedWallet > img { + width: 2em; + height: 1.5em; + margin-right: 1em; +} + +.providers { + display: flex; + flex-flow: column wrap; + justify-content: center; + align-items: center; + align-content: center; + + padding: 0.6em 1.2em; +} ``` -#### Styling - -This dapp has Vite's typical `App.css` and `index.css` files removed, and uses a modular approach to CSS. - -In the `/src` directory, `App.global.css` contains styles for the entire dapp (not specific to a -single component), and styles you might want to reuse (such as buttons). - -In the `/src` directory, `App.module.css` contains styles specific to `App.tsx`, your dapp's -container component. -It uses the `appContainer` class, which sets up a -[Flexbox](https://css-tricks.com/snippets/css/a-guide-to-flexbox) to define the `display` type -(`flex`) and the `flex-direction` (`column`). - -Using Flexbox here ensures that any child `div`s are laid out in a single-column layout (vertically). - -Finally, the `/src/components` directory has subdirectories for `Display`, `Navigation`, and `MetaMaskError`. -Each subdirectory contains a corresponding component file and CSS file. -Each component is a -[flex-items](https://css-tricks.com/snippets/css/a-guide-to-flexbox/#aa-basics-and-terminology) -within a -[flex-container](https://css-tricks.com/snippets/css/a-guide-to-flexbox/#aa-flexbox-properties), -stacked in a vertical column with the navigation and footer (`MetaMaskError`) being of fixed height -and the middle component (`Display`) taking up the remaining vertical space. - -#### Optional: Linting with ESLint - -This dapp uses a standard ESLint configuration to keep the code consistent. -There are two ways to use ESLint: - -1. Run `npm run lint` or `npm run lint:fix` from the command line. - The former displays all the linting errors, and the latter updates your code to fix linting - errors where possible. -2. Set up your IDE to show linting errors and automatically fix them on save. - For example, in VS Code, you can create or update the file at `.vscode/settings.json` in the - root of the project with the following settings: - - ```json title="settings.json" - { - "eslint.format.enable": true, - "eslint.packageManager": "npm", - "editor.codeActionsOnSave": { - "source.fixAll.eslint": true - }, - "eslint.codeActionsOnSave.mode": "all" - } - ``` +Add the following CSS code to `WalletError.module.css`: + +```css title="WalletError.module.css" +.walletError { + margin-top: 1em; + border-radius: 0.5em; + height: 36px; + padding: 16px; + color: #EFEFEF; + background-color: transparent; + user-select: none; +} +``` + +Add the following CSS code to `WalletList.module.css`: + +```css title="WalletList.module.css" +.walletList { + display: flex; + flex-direction: column; + align-items: center; +} +``` + +Append the following code to the end of `src/index.css`: + +```css title="index.css" +/* Added CSS */ +:root { + text-align: left; +} + +hr { + margin-top: 2em; + height: 1px; +} + +button { + min-width: 12em; + display: flex; + flex-flow: row nowrap; + justify-content: flex-start; + + align-items: center; + border-radius: 0.5em; + margin-bottom: 0.5em; + border: 1px solid transparent; +} + +button > img { + width: 1.5em; + height: 1.5em; + margin-right: 1em; +} -#### Project structure +button:hover { + border-color: #75079d; +} + +button:first-child { + margin-top: 0.5em; +} +button:last-child { + margin-bottom: 0; +} +``` + +#### 1.2. Project structure -The following is a tree representation of the dapp's `/src` directory: +You now have some basic global and component-level styling for your dapp. +The directory structure in the dapp's `/src` directory should look like the following: ```text ├── src │ ├── assets │ ├── components -│ │ └── Display -│ │ | └── index.tsx -│ │ | └── Display.module.css -│ │ | └── Display.tsx -│ │ ├── MetaMaskError -│ │ | └── index.tsx -│ │ | └── MetaMaskError.module.css -│ │ | └── MetaMaskError.tsx -│ │ ├─── Navigation -│ │ | └── index.tsx -│ │ | └── Navigation.module.css -│ │ | └── Navigation.tsx +│ │ ├── SelectedWallet.module.css +│ │ ├── SelectedWallet.tsx +│ │ ├── WalletError.module.css +│ │ ├── WalletError.tsx +│ │ ├── WalletList.module.css +│ │ └── WalletList.tsx │ ├── hooks -│ │ ├── useMetaMask.tsx +│ │ ├── WalletProvider.tsx +│ │ └── useWalletProvider.tsx │ ├── utils │ │ └── index.tsx -├── App.global.css -├── App.module.css +├── App.css ├── App.tsx +├── index.css ├── main.tsx ├── vite-env.d.ts ``` -Instead of a single component, there's a `src/components` directory with UI and functionality -distributed into multiple components. -You'll modify the dapp's state in this directory and make it available to the rest of the dapp using -a [context provider](https://react.dev/reference/react/useContext). -This provider will sit in the `src/App.tsx` file and wrap the three child components. +### 2. Import EIP-6963 interfaces + +The dapp will connect to MetaMask using the mechanism introduced by +[EIP-6963](https://eips.ethereum.org/EIPS/eip-6963). + +:::info Why EIP-6963? +[EIP-6963](https://eips.ethereum.org/EIPS/eip-6963) introduces an alternative wallet detection +mechanism to the `window.ethereum` injected provider. +This alternative mechanism enables dapps to support +[wallet interoperability](../concepts/wallet-interoperability.md) by discovering multiple injected +wallet providers in a user's browser. +::: -The child components will have access to the global state and the functions that modify the global state. -This ensures that any change to the `wallet` (`address`, `balance`, and `chainId`), or the global -state's properties and functions (`hasProvider`, `error`, `errorMessage`, and `isConnecting`) will -be accessible by re-rendering those child components. +Update the Vite environment variable file, `src/vite-env.d.ts`, with the types and interfaces +needed for [EIP-6963](https://eips.ethereum.org/EIPS/eip-6963) and +[EIP-1193](https://eips.ethereum.org/EIPS/eip-1193): -The following graphic shows how the context provider wraps its child components, providing access to -the state modifier functions and the actual state itself. -Since React uses a one-way data flow, any change to the data gets re-rendered in those components automatically. +```tsx title="vite-env.d.ts" +/// -![](../assets/tutorials/react-dapp/pt2-02.png) +// Describes metadata related to a provider based on EIP-6963. +interface EIP6963ProviderInfo { + rdns: string + uuid: string + name: string + icon: string +} -### 2. Build the context provider +// Represents the structure of a provider based on EIP-1193. +interface EIP1193Provider { + isStatus?: boolean + host?: string + path?: string + sendAsync?: (request: { method: string, params?: Array }, callback: (error: Error | null, response: unknown) => void) => void + send?: (request: { method: string, params?: Array }, callback: (error: Error | null, response: unknown) => void) => void + request: (request: { method: string, params?: Array }) => Promise +} -In this step, you'll create a context called `MetaMaskContext` and a provider component called -`MetaMaskContextProvider` in the `/src/hooks/useMetaMask.tsx` file. +// Combines the provider's metadata with an actual provider object, creating a complete picture of a +// wallet provider at a glance. +interface EIP6963ProviderDetail { + info: EIP6963ProviderInfo + provider: EIP1193Provider +} -This provider component will use similar `useState` and `useEffect` hooks with some changes from -the previous tutorial's local state component to make it more DRY (don't repeat yourself). +// Represents the structure of an event dispatched by a wallet to announce its presence based on EIP-6963. +type EIP6963AnnounceProviderEvent = { + detail:{ + info: EIP6963ProviderInfo, + provider: Readonly + } +} -It will also have similar `updateWallet`, `connectMetaMask`, and `clearError` functions, all of -which do their part to connect to MetaMask or update the MetaMask state. +// An error object with optional properties, commonly encountered when handling eth_requestAccounts errors. +interface WalletError { + code?: string + message?: string +} +``` -`MetaMaskContext` will return a `MetaMaskContext.Provider`, which takes a value of type -`MetaMaskContextData`, and supplies that to its children. +### 3. Build the context provider -You'll export a React hook called `useMetaMask`, which uses your `MetaMaskContext`. +In this step, you'll create the React Context component, which wraps the dapp and provides all +components access to the state and functions required to modify the state and manage connections to +discovered wallets. -Update `/src/hooks/useMetaMask.tsx` with the following: +Add the following code to `src/hooks/WalletProvider.tsx` to import the context, define the +type alias, and define the context interface for the EIP-6963 provider: -:::caution Read the comments -The following code contains comments describing advanced React patterns and how MetaMask state is managed. -::: +```tsx title="WalletProvider.tsx" +import { PropsWithChildren, createContext, useCallback, useEffect, useState } from "react" -```tsx title="useMetaMask.tsx" -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { - useState, - useEffect, - createContext, - PropsWithChildren, - useContext, - useCallback, -} from "react"; - -import detectEthereumProvider from "@metamask/detect-provider"; -import { formatBalance } from "~/utils"; - -interface WalletState { - accounts: any[]; - balance: string; - chainId: string; -} +// Type alias for a record where the keys are wallet identifiers and the values are account +// addresses or null. +type SelectedAccountByWallet = Record -interface MetaMaskContextData { - wallet: WalletState; - hasProvider: boolean | null; - error: boolean; - errorMessage: string; - isConnecting: boolean; - connectMetaMask: () => void; - clearError: () => void; +// Context interface for the EIP-6963 provider. +interface WalletProviderContext { + wallets: Record // A list of wallets. + selectedWallet: EIP6963ProviderDetail | null // The selected wallet. + selectedAccount: string | null // The selected account address. + errorMessage: string | null // An error message. + connectWallet: (walletUuid: string) => Promise // Function to connect wallets. + disconnectWallet: () => void // Function to disconnect wallets. + clearError: () => void } +``` -const disconnectedState: WalletState = { - accounts: [], - balance: "", - chainId: "", -}; +Add the following code to `src/hooks/WalletProvider.tsx` to extend the global `WindowEventMap` +interface with the custom `eip6963:announceProvider` event: -const MetaMaskContext = createContext( - {} as MetaMaskContextData -); +```tsx title="WalletProvider.tsx" +declare global{ + interface WindowEventMap { + "eip6963:announceProvider": CustomEvent + } +} +``` -export const MetaMaskContextProvider = ({ children }: PropsWithChildren) => { - const [hasProvider, setHasProvider] = useState(null); +Explicitly declaring the custom `eip6963:announceProvider` event prevents type errors, enables +proper type checking, and supports autocompletion in TypeScript. - const [isConnecting, setIsConnecting] = useState(false); +Add the following code to `src/hooks/WalletProvider.tsx` to create the React Context for the +EIP-6963 provider with the defined interface `WalletProviderContext`, and define the +`WalletProvider` component: - const [errorMessage, setErrorMessage] = useState(""); - const clearError = () => setErrorMessage(""); +```tsx title="WalletProvider.tsx" showLineNumbers {6-12,14} +export const WalletProviderContext = createContext(null) - const [wallet, setWallet] = useState(disconnectedState); - // useCallback ensures that you don't uselessly recreate the _updateWallet function on every render. - const _updateWallet = useCallback(async (providedAccounts?: any) => { - const accounts = - providedAccounts || - (await window.ethereum.request({ method: "eth_accounts" })); +// The WalletProvider component wraps all other components in the dapp, providing them with the +// necessary data and functions related to wallets. +export const WalletProvider: React.FC = ({ children }) => { + const [wallets, setWallets] = useState>({}) + const [selectedWalletRdns, setSelectedWalletRdns] = useState(null) + const [selectedAccountByWalletRdns, setSelectedAccountByWalletRdns] = useState({}) - if (accounts.length === 0) { - // If there are no accounts, then the user is disconnected. - setWallet(disconnectedState); - return; - } + const [errorMessage, setErrorMessage] = useState("") + const clearError = () => setErrorMessage("") + const setError = (error: string) => setErrorMessage(error) - const balance = formatBalance( - await window.ethereum.request({ - method: "eth_getBalance", - params: [accounts[0], "latest"], - }) - ); - const chainId = await window.ethereum.request({ - method: "eth_chainId", - }); - - setWallet({ accounts, balance, chainId }); - }, []); - - const updateWalletAndAccounts = useCallback( - () => _updateWallet(), - [_updateWallet] - ); - const updateWallet = useCallback( - (accounts: any) => _updateWallet(accounts), - [_updateWallet] - ); - - /** - * This logic checks if MetaMask is installed. If it is, some event handlers are set up to update - * the wallet state when MetaMask changes. The function returned by useEffect is used as a - * "cleanup"; it removes the event handlers whenever the MetaMaskProvider is unmounted. - */ useEffect(() => { - const getProvider = async () => { - const provider = await detectEthereumProvider({ silent: true }); - setHasProvider(Boolean(provider)); - - if (provider) { - updateWalletAndAccounts(); - window.ethereum.on("accountsChanged", updateWallet); - window.ethereum.on("chainChanged", updateWalletAndAccounts); - } - }; - - getProvider(); + const savedSelectedWalletRdns = localStorage.getItem("selectedWalletRdns") + const savedSelectedAccountByWalletRdns = localStorage.getItem("selectedAccountByWalletRdns") - return () => { - window.ethereum?.removeListener("accountsChanged", updateWallet); - window.ethereum?.removeListener( - "chainChanged", - updateWalletAndAccounts - ); - }; - }, [updateWallet, updateWalletAndAccounts]); + if (savedSelectedAccountByWalletRdns) { + setSelectedAccountByWalletRdns(JSON.parse(savedSelectedAccountByWalletRdns)) + } - const connectMetaMask = async () => { - setIsConnecting(true); + function onAnnouncement(event: EIP6963AnnounceProviderEvent){ + setWallets(currentWallets => ({ + ...currentWallets, + [event.detail.info.rdns]: event.detail + })) - try { - const accounts = await window.ethereum.request({ - method: "eth_requestAccounts", - }); - clearError(); - updateWallet(accounts); - } catch (err: any) { - setErrorMessage(err.message); + if (savedSelectedWalletRdns && event.detail.info.rdns === savedSelectedWalletRdns) { + setSelectedWalletRdns(savedSelectedWalletRdns) + } } - setIsConnecting(false); - }; - return ( - - {children} - - ); -}; - -export const useMetaMask = () => { - const context = useContext(MetaMaskContext); - if (context === undefined) { - throw new Error( - "useMetaMask must be used within a MetaMaskContextProvider" - ); + window.addEventListener("eip6963:announceProvider", onAnnouncement) + window.dispatchEvent(new Event("eip6963:requestProvider")) + + return () => window.removeEventListener("eip6963:announceProvider", onAnnouncement) + }, []) +``` + +In this code sample, lines 6–12 are state definitions: + +- `wallets` - State to hold detected wallets. +- `selectedWalletRdns` - State to hold the Reverse Domain Name System (RDNS) of the selected wallet. +- `selectedAccountByWalletRdns` - State to hold accounts associated with each wallet. +- `errorMessage` - State to hold the error message when a wallet throws an error on connection. +- `clearError` - Function to clear the state in `errorMessage`. +- `setError` - Function to set the state in `errorMessage`. + +Line 14 is the `useEffect` hook and it handles the following: + +- Local storage retrieval - On mount, it retrieves the saved selected wallet and accounts from local storage. +- Event listener - It adds an event listener for the custom `eip6963:announceProvider` event. +- State update - When the provider announces itself, it updates the state. +- Provider request - It dispatches an event to request existing providers. +- Cleanup - It removes the event listener on unmount. + +Add the following code to `src/hooks/WalletProvider.tsx` to connect a wallet and update the component's state: + +```tsx title="WalletProvider.tsx" +const connectWallet = useCallback(async (walletRdns: string) => { + try { + const wallet = wallets[walletRdns] + const accounts = await wallet.provider.request({method:"eth_requestAccounts"}) as string[] + + if(accounts?.[0]) { + setSelectedWalletRdns(wallet.info.rdns) + setSelectedAccountByWalletRdns((currentAccounts) => ({ + ...currentAccounts, + [wallet.info.rdns]: accounts[0], + })) + + localStorage.setItem("selectedWalletRdns", wallet.info.rdns) + localStorage.setItem("selectedAccountByWalletRdns", JSON.stringify({ + ...selectedAccountByWalletRdns, + [wallet.info.rdns]: accounts[0], + })) + } + } catch (error) { + console.error("Failed to connect to provider:", error) + const walletError: WalletError = error as WalletError + setError(`Code: ${walletError.code} \nError Message: ${walletError.message}`) } - return context; -}; +}, [wallets, selectedAccountByWalletRdns]) ``` -With this context provider in place, you can update `/src/App.tsx` to include the provider and wrap -it around the three components. +This code uses the `walletRdns` parameter to identify the wallet's RDNS for connecting. +It performs an asynchronous operation to request accounts from the wallet provider using the +[`eth_requestAccounts`](/wallet/reference/eth_requestaccounts) RPC method. -Notice the use of `~/utils` to import the utility functions. +Add the following code to `src/hooks/WalletProvider.tsx` to disconnect from a wallet: -:::note vite-tsconfig-paths -This dapp is configured to use `vite-tsconfig-paths`, allowing it to load modules with locations -specified by the `compilerOptions.paths` object in `tsconfig.json`. -The path corresponding to the `./src/*` directory is represented by the `~/*` symbol. -There's also a reference to `./tsconfig.node.json` in the `reference`'s array objects that correspond -to `path`. +```tsx title="WalletProvider.tsx" +const disconnectWallet = useCallback(async () => { + if (selectedWalletRdns) { + setSelectedAccountByWalletRdns((currentAccounts) => ({ + ...currentAccounts, + [selectedWalletRdns]: null, + })) -`vite.config.ts` imports `tsconfigPaths` from `vite-tsconfig-paths` and adds it to the `plugins` array. + const wallet = wallets[selectedWalletRdns]; + setSelectedWalletRdns(null) + localStorage.removeItem("selectedWalletRdns") -See more information about [`vite-tsconfig-paths`](https://github.com/aleclarson/vite-tsconfig-paths). + try { + await wallet.provider.request({ + method: "wallet_revokePermissions", + params: [{ "eth_accounts": {} }] + }); + } catch (error) { + console.error("Failed to revoke permissions:", error); + } + } +}, [selectedWalletRdns, wallets]) +``` + +:::caution important +[`wallet_revokePermission`](/wallet/reference/wallet_revokePermissions) is an experimental RPC +method that might only work with MetaMask. +Configuring the revocation in a try/catch block and separating it from the rest of the cleanup +ensures that if a wallet does not support this feature, the rest of the disconnect functionality +will still execute. ::: -### 3. Wrap components with the context provider +
+Use of `useCallback` +

+Both of the previous functions use `useCallback`. +It is used to memoize the `connectWallet` function, optimize performance, and prevent unnecessary re-renders. +It ensures the function instance remains consistent between renders if its dependencies are changed. + +For example, when using `disconnectWallet`, each time the `WalletProvider` component re-renders +without `useCallback`, a new instance of `disconnectWallet` is created. +This can cause unnecessary re-renders of child components that depend on this function. +By memoizing it with `useCallback`, React keeps the function instance consistent between renders, as +long as its dependencies (wallets and `selectedWalletRdns`) haven't changed, preventing unnecessary +re-renders of child components. + +Although `useCallback` is not strictly necessary, it demonstrates best practices. +Predicting how a context provider will be used or how the dapp might change or scale is difficult. +Using `useCallback` can improve performance in some cases by reducing unnecessary re-renders. +

+
+ +Add the following code to `src/hooks/WalletProvider.tsx` to bundle the state and functions using `contextValue`: + +```tsx title="WalletProvider.tsx" +const contextValue: WalletProviderContext = { + wallets, + selectedWallet: selectedWalletRdns === null ? null : wallets[selectedWalletRdns], + selectedAccount: selectedWalletRdns === null ? null : selectedAccountByWalletRdns[selectedWalletRdns], + errorMessage, + connectWallet, + disconnectWallet, + clearError, +} -In this step, you'll import the `MetaMaskContextProvider` in `/src/App.tsx` and wrap that component -around the existing `Display`, `Navigation`, and `MetaMaskError` components. +return ( + + {children} + +) +``` -Update `/src/App.tsx` to the following: +In the return statement, the `contextValue` object is constructed with all necessary state and +functions related to wallet management. +It is passed to the `WalletProviderContext.Provider`, making wallet-related data and functions +available to all descendant components. +The context provider wraps the children components, allowing them to access the context values. -```tsx title="App.tsx" -import "./App.global.css"; -import styles from "./App.module.css"; +Add the following code to `src/hooks/useWalletProvider.tsx` to provide a custom hook that simplifies +the process of consuming the `WalletProviderContext`: -import { Navigation } from "./components/Navigation"; -import { Display } from "./components/Display"; -import { MetaMaskError } from "./components/MetaMaskError"; -import { MetaMaskContextProvider } from "./hooks/useMetaMask"; +```tsx title="useWalletProvider.tsx" +import { useContext } from "react" +import { WalletProviderContext } from "./WalletProvider" -export const App = () => { - return ( - -
- - - -
-
- ); -}; +export const useWalletProvider = () => useContext(WalletProviderContext) ``` -With `App.tsx` updated, you can update the `Display`, `Navigation`, and `MetaMaskError` components, -each of which will use the `useMetaMask` hook to display the state or invoke functions that modify state. - -### 4. Connect to MetaMask in the navigation +The benefit of this separate file exporting the hook is that components can directly call +`useWalletProvider()` instead of `useContext(WalletProviderContext)`, making the code cleaner and +more readable. -The `Navigation` component will connect to MetaMask using conditional rendering to show an -**Install MetaMask** or **Connect MetaMask** button or, once connected, display your wallet address -in a hypertext link that connects to [Etherscan](https://etherscan.io). +### 4. Update the utility file -Update `/src/components/Navigation/Navigation.tsx` to the following: +Add the following code to `src/utils/index.ts`: -```tsx title="Navigation.tsx" -import { useMetaMask } from "~/hooks/useMetaMask"; -import { formatAddress } from "~/utils"; -import styles from "./Navigation.module.css"; +```ts title="index.ts" +export const formatBalance = (rawBalance: string) => { + const balance = (parseInt(rawBalance) / 1000000000000000000).toFixed(2) + return balance +} -export const Navigation = () => { - const { wallet, hasProvider, isConnecting, connectMetaMask } = - useMetaMask(); +export const formatChainAsNum = (chainIdHex: string) => { + const chainIdNum = parseInt(chainIdHex) + return chainIdNum +} - return ( -
-
-
Vite + React & MetaMask
-
- {!hasProvider && ( - Install MetaMask - )} - {window.ethereum?.isMetaMask && - wallet.accounts.length < 1 && ( - - )} - {hasProvider && wallet.accounts.length > 0 && ( - - {formatAddress(wallet.accounts[0])} - - )} -
-
-
- ); -}; +export const formatAddress = (addr: string) => { + const upperAfterLastTwo = addr.slice(0,2) + addr.slice(2) + return `${upperAfterLastTwo.substring(0, 5)}...${upperAfterLastTwo.substring(39)}` +} ``` -Notice how `useMetaMask` de-structures its return value to get the items within `MetaMaskContextData`: +Although `formatAddress` is the only function used, `formatBalance` and `formatChainAsNum` are +added as useful utility functions. +Explore [Viem formatters](https://viem.sh/docs/chains/formatters) or other libraries for additional +formatting options. -```ts -const { wallet, hasProvider, isConnecting, connectMetaMask } = useMetaMask(); -``` +### 5. Wrap components with the context provider -Also, the `formatAddress` function formats the wallet address for display purposes: +With `WalletProvider.tsx` and `useWalletProvider.tsx`, the dapp can manage and access wallet-related +state and functionality across various components. +You can now wrap the entire dapp (the part that requires wallet connection and data) with a +`WalletProvider` component. -```ts -{ - formatAddress(wallet.accounts[0]); +Replace the code in `src/App.tsx` with the following: + +```tsx title="App.tsx" +import "./App.css" +import { WalletProvider } from "~/hooks/WalletProvider" +// import { WalletList } from "./components/WalletList" +// import { SelectedWallet } from "./components/SelectedWallet" +// import { WalletError } from "./components/WalletError" + +function App() { + return ( + + {/* + +
+ + + */} +
+ ) } + +export default App ``` -This function doesn't exist in the `@utils` file yet, so you'll need to add it. -Update `/src/utils/index.tsx` to the following: +The child components are currently commented out, but as you create each of these components, you'll +uncomment the specific lines. -```ts title="utils/index.ts" -export const formatBalance = (rawBalance: string) => { - const balance = (parseInt(rawBalance) / 1000000000000000000).toFixed(2); - return balance; -}; +### 6. Display detected wallets -export const formatChainAsNum = (chainIdHex: string) => { - const chainIdNum = parseInt(chainIdHex); - return chainIdNum; -}; +Add the following code to `src/components/WalletList.tsx` to display detected wallets: -export const formatAddress = (addr: string) => { - return `${addr.substring(0, 8)}...`; -}; +```tsx title="WalletList.tsx" +import { useWalletProvider } from "~/hooks/useWalletProvider" +import styles from "./WalletList.module.css" + +export const WalletList = () => { + const { wallets, connectWallet } = useWalletProvider() + return ( + <> +

Wallets Detected:

+
+ { + Object.keys(wallets).length > 0 + ? Object.values(wallets).map((provider: EIP6963ProviderDetail) => ( + + )) + :
there are no Announced Providers
+ } +
+ + ) +} ``` -This should address any build errors in your `Navigation` component. +This component checks if there are any detected wallets. +If wallets are detected, it iterates over them and renders a button for each one. -Other than using the new styling, the only thing this dapp has done differently than the local-state -tutorial is display the user's `address` formatted inside a link once they're connected. -Now that you have a place for connecting and showing the address, you could build out an entire -profile component (side quest). +- `Object.keys(wallets)` returns an array of the wallet keys (`rdns` values). + It is used to check the length. +- `Object.values(wallets)` returns an array of the wallet objects. + It is used to map and render. +- `wallet.info.rdns` is used as the key to ensure that each wallet button is uniquely identified. -![](../assets/tutorials/react-dapp/pt2-03.png) +Uncomment the `WalletList` component in `src/App.tsx` and run the dapp. +Something like the following displays: -### 5. Display MetaMask data +![View of WalletList component](../assets/tutorials/react-dapp/react-tutorial-02-wallet-list.png) -In the `Display` component, you won't call any functions that modify state; you'll read from -`MetaMaskData`, a simple update. +### 7. Display wallet data -Update `/src/components/Display/Display.tsx` to the following: +Add the following code to `src/components/SelectedWallet.tsx` to display data for the selected wallet: -```tsx title="Display.tsx" -import { useMetaMask } from "~/hooks/useMetaMask"; -import { formatChainAsNum } from "~/utils"; -import styles from "./Display.module.css"; +```tsx title="SelectedWallet.tsx" showLineNumbers {11-22} +import { useWalletProvider } from "~/hooks/useWalletProvider" +import { formatAddress } from "~/utils" +import styles from "./SelectedWallet.module.css" -export const Display = () => { - const { wallet } = useMetaMask(); +export const SelectedWallet = () => { + const { selectedWallet, selectedAccount, disconnectWallet } = useWalletProvider() return ( -
- {wallet.accounts.length > 0 && ( + <> +

{selectedAccount ? "" : "No "}Wallet Selected

+ {selectedAccount && <> -
Wallet Accounts: {wallet.accounts[0]}
-
Wallet Balance: {wallet.balance}
-
Hex ChainId: {wallet.chainId}
-
Numeric ChainId: {formatChainAsNum(wallet.chainId)}
+
+ {selectedWallet.info.name} +
{selectedWallet.info.name}
+
({formatAddress(selectedAccount)})
+
uuid: {selectedWallet.info.uuid}
+
rdns: {selectedWallet.info.rdns}
+
+ - )} -
- ); -}; + } + + ) +} ``` -Notice how `useMetaMask` de-structures its return value to get only the `wallet` data: +The code in lines 11-22 have conditional rendering, ensuring that the content inside is only +displayed if `selectedAccount` is true. This ensures that detailed information about the selected wallet is only displayed when an active +wallet is connected. -```ts -const { wallet } = useMetaMask(); -``` +You can display information about the wallet, and conditionally render anything related to the following: + +- Wallet address +- Wallet balance +- Chain ID or name +- Other components that first need a connected wallet to work -At this point, you can display `account`, `balance`, and `chainId` in the `Display` component: +Uncomment the `SelectedWallet` component in `src/App.tsx` and run the dapp. +When you connect to MetaMask, something like the following displays: -![](../assets/tutorials/react-dapp/pt2-04.png) +![View of SelectedWallet component](../assets/tutorials/react-dapp/react-tutorial-02-selected-wallet.png) -### 6. Show MetaMask errors in the footer +### 8. Display wallet connection errors -If MetaMask errors or the user rejects a connection, you can display that error in the footer, or -`MetaMaskError` component. +Add the following code to `src/components/WalletError.tsx` to handle wallet connection errors: -Update `/src/components/MetaMaskError/MetaMaskError.tsx` to the following: +```tsx title="WalletError.tsx" +import { useWalletProvider } from "~/hooks/useWalletProvider" +import styles from "./WalletError.module.css" -```tsx title="MetaMaskError.tsx" -import { useMetaMask } from "~/hooks/useMetaMask"; -import styles from "./MetaMaskError.module.css"; +export const WalletError = () => { + const { errorMessage, clearError } = useWalletProvider() + const isError = !!errorMessage -export const MetaMaskError = () => { - const { error, errorMessage, clearError } = useMetaMask(); return ( -
- {error && ( +
+ {isError &&
Error: {errorMessage}
- )} + }
- ); -}; + ) +} ``` -Notice how `useMetaMask` de-structures its return value to get only the `error`, `errorMessage`, and -`clearError` data: +An error message renders only if `errorMessage` contains data. +After the error is selected, `errorMessage` resets to an empty string, which hides the content. + +This method demonstrates how to display specific content, such as a modal or notification, in +response to connection errors when connecting to a wallet. + +Uncomment the `WalletError` component in `src/App.tsx` and run the dapp. +Disconnect from MetaMask, reconnect, and reject or cancel the connection. +Something like the following displays: + +![View of WalletError component](../assets/tutorials/react-dapp/react-tutorial-02-wallet-error.png) + +### 9. Run the final state of the dapp -```ts -const { error, errorMessage, clearError } = useMetaMask(); +Make sure all code in `App.tsx` is uncommented: + +```tsx title="App.tsx" +import "./App.css" +import { WalletProvider } from "~/hooks/WalletProvider" +import { WalletList } from "./components/WalletList" +import { SelectedWallet } from "./components/SelectedWallet" +import { WalletError } from "./components/WalletError" + +function App() { + return ( + + +
+ + +
+ ) +} + +export default App ``` -When you generate an error by cancelling the connection to MetaMask, this shows up in the footer. -The background temporarily turns a dark red color: +Run the dapp to view the wallet list and select a wallet to connect to. +The final state of the dapp when connected to MetaMask looks like the following: + +![Final view of dapp](../assets/tutorials/react-dapp/react-tutorial-02-final-preview.png) + +### 10. Test the dapp features -![](../assets/tutorials/react-dapp/pt2-05.png) +You can conduct user tests to evaluate the functionality and features demonstrated in this tutorial: -In this tutorial's dapp, you can dismiss any MetaMask error displayed in the footer by selecting it. -In a real-world dapp, the best UI/UX for error dismissing would be a component that displays in a -modal or overlay and provides an obvious dismiss button. +1. Test the ability to connect and disconnect from multiple wallets installed in your browser. +2. After selecting a wallet, refresh the page and ensure that the selected wallet persists without + reverting to **No Wallet Selected**. +3. Select a wallet, disable it, refresh the page, then re-enable the wallet and refresh the page again. + Observe the behavior of the dapp. +4. When connecting to a wallet, deliberately cancel the connection or close the wallet prompt. + This action should trigger the `WalletError` component, which you can dismiss by selecting it. ## Conclusion -You've successfully converted a single component dapp with local state to a multiple component dapp -with global state, using React context and provider. -You can modify the dapp's global state using functions and data that, when used anywhere in the dapp, -will show up-to-date data associated with your MetaMask wallet. +This tutorial guided you through applying EIP-6963 to connect to MetaMask. +This method also works with any wallet that [complies with +EIP-6963](https://github.com/WalletConnect/EIP6963/blob/master/src/utils/constants.ts) and supports +multi-injected provider discovery. -You can see the [source code](https://github.com/MetaMask/react-dapp-tutorial/tree/global-state-final) -for the final state of this dapp tutorial. +In this tutorial, you addressed edge cases and created a context provider that facilitates data +sharing, manages functions for connecting and disconnecting from wallets, and handles errors. +You can view the [project source code on GitHub](https://github.com/MetaMask/vite-react-global-tutorial). diff --git a/wallet/tutorials/react-dapp-local-state.md b/wallet/tutorials/react-dapp-local-state.md index 944b433ec3b..53fe0587ae0 100644 --- a/wallet/tutorials/react-dapp-local-state.md +++ b/wallet/tutorials/react-dapp-local-state.md @@ -26,7 +26,7 @@ You can view the [dapp source code on GitHub](https://github.com/MetaMask/vite-r - [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) version 9+ - A text editor (for example, [VS Code](https://code.visualstudio.com/)) - The [MetaMask extension](https://metamask.io/download) installed -- Basic knowledge of JavaScript and React +- Basic knowledge of TypeScript and React ## Steps @@ -35,13 +35,13 @@ You can view the [dapp source code on GitHub](https://github.com/MetaMask/vite-r Set up a new project using Vite, React, and TypeScript by running the following command: ```bash -npm create vite@latest mm-dapp-react -- --template react-ts +npm create vite@latest vite-react-local-state -- --template react-ts ``` Install the node module dependencies: ```bash -cd mm-dapp-react && npm install +cd vite-react-local-state && npm install ``` Launch the development server: @@ -53,8 +53,9 @@ npm run dev This displays a `localhost` URL in your terminal, where you can view the dapp in your browser. :::note -If the development server has been stopped, you can re-run your project using the `npx vite` or -`npm run dev` command. +If you use VS Code, you can run the command `code .` to open the project. +If the development server has stopped, you can run the command `npx vite` or `npm run dev` to +restart your project. ::: Open the project in your editor. @@ -76,7 +77,7 @@ export default App ### 2. Import EIP-6963 interfaces -Your dapp will connect to MetaMask using the mechanism introduced by +The dapp will connect to MetaMask using the mechanism introduced by [EIP-6963](https://eips.ethereum.org/EIPS/eip-6963). :::info Why EIP-6963? @@ -94,7 +95,7 @@ needed for [EIP-6963](https://eips.ethereum.org/EIPS/eip-6963) and ```tsx title="vite-env.d.ts" /// -// Describes metadata related to a provider according to EIP-6963. +// Describes metadata related to a provider based on EIP-6963. interface EIP6963ProviderInfo { walletId: string uuid: string @@ -102,7 +103,7 @@ interface EIP6963ProviderInfo { icon: string } -// Represents the structure of an Ethereum provider based on the EIP-1193 standard. +// Represents the structure of a provider based on EIP-1193. interface EIP1193Provider { isStatus?: boolean host?: string @@ -425,7 +426,7 @@ providers using EIP-6963, and managing the state in React locally. You can view the [project source code on GitHub](https://github.com/MetaMask/vite-react-local-tutorial). As a next step, you can [create a React dapp with global state](react-dapp-global-state.md). -This follow-up tutorial walks you through adding more than one component and working with global state. +This follow-up tutorial walks you through adding multiple components that use a global state. You'll use [React's Context API](https://react.dev/reference/react/useContext) to manage the state -globally and ensure that any component in your dapp can be aware and conditionally render or display -information about your MetaMask wallet. +globally and move away from using the `useSyncExternalStore`. +This is a more realistic (but also more complex) approach for building a real-world dapp.