docs/content/guides/developer/app-examples/e2e-counter.mdx
This example walks you through building a basic distributed counter app, covering the full end-to-end flow of building your Sui Move module and connecting it to your React Sui app. The app allows users to create counters that anyone can increment, but only the owner can reset.
The guide is split into two parts:
Counter structure and logic.Counter objects.:::tip Additional resources
:::
Counter objects.To begin, create a new folder on your system titled react-e2e-counter to hold all your project files. You can name this directory differently, but the rest of the guide references this file structure. Inside that folder, create two more folders: move and src. Inside the move folder, create a counter directory. Finally, create a sources folder inside counter. Different projects have their own directory structure, but it's common to split code into functional groups to help with maintenance. See "Hello, World!" to learn more about package structure and how to use the Sui CLI to scaffold a new project.
:::checkpoint
sui --version in your terminal or console, it responds with the currently installed version.sui client active-env to make sure. If you receive a warning about a client and server API version mismatch, update Sui using the version in the relevant branch (mainnet, testnet, devent) of the Sui repo.sui client balance in your terminal or console. If there is no balance, acquire SUI from the faucet (not available in Mainnet).react-e2e-counter if you want to match the directory structure in this guide.:::
:::tip
<ImportContent source="faucet-online.mdx" mode="snippet" />:::
In this part of the guide, you write the Move contracts that create, increment, and reset counters.
Move.tomlTo begin writing your smart contracts, create a file inside react-e2e-counter/move/counter named Move.toml and copy the following code into it. This is the package manifest file. If you want to learn more about the structure of the file, see Package Manifest in The Move Book.
:::info
If you are targeting a network other than Testnet, be sure to update the rev value for the Sui dependency.
:::
<ImportContent source="packages/create-dapp/templates/react-e2e-counter/move/counter/Move.toml" mode="code" org="MystenLabs" repo="ts-sdks" />Counter structTo begin creating the smart contract that defines the on-chain counter, create a counter.move file inside your react-e2e-counter/move/counter/sources folder. Define the module that holds your smart contract logic.
module counter::counter {
// Code goes here
}
Add the Counter struct and elements described in the following sections to the module.
Counter type stores the address of its owner, its current value, and its own id.CounterIn the create function, a new Counter object is created and shared.
CounterThe increment function accepts a mutable reference to any shared Counter object and increments its value field.
The set_value function accepts a mutable reference to any shared Counter object, the value to set its value field, and the ctx which contains the sender of the transaction. The Counter owner is the only one that can run this function.
:::tip Additional resources
Learn more about taking object references as input
:::
The final module should look like this
<ImportContent source="packages/create-dapp/templates/react-e2e-counter/move/counter/sources/counter.move" mode="code" org="MystenLabs" repo="ts-sdks" noComments />:::checkpoint
Your smart contract is complete. You should be able to run the sui move build command from react-e2e-counter/move/counter and receive a response similar to the following:
UPDATING GIT DEPENDENCY https://github.com/MystenLabs/sui.git
INCLUDING DEPENDENCY Sui
INCLUDING DEPENDENCY MoveStdlib
BUILDING counter
You always run sui move build at the same level as your Move.toml file. After a successful build, you now have a build folder inside react-e2e-counter/move/counter.
:::
Next, configure the Sui CLI to use testnet as the active environment, as well. If you haven't already set up a testnet environment, do so by running the following command in a terminal or console:
$ sui client new-env --alias testnet --rpc https://fullnode.testnet.sui.io:443
Run the following command to activate the testnet environment:
$ sui client switch --env testnet
The output of this command contains a packageID value that you need to save to use the package.
Partial snippet of CLI deployment output.
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Object Changes │
├──────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Created Objects: │
│ ┌── │
│ │ ObjectID: 0x7530c33e4cf3345236601d69303e3fab84efc294194a810dc1cfea13c009e77f │
│ │ Sender: 0x8e8cae7791a93778800b88b6a274de5c32a86484593568d38619c7ea71999654 │
│ │ Owner: Account Address ( 0x8e8cae7791a93778800b88b6a274de5c32a86484593568d38619c7ea71999654 ) │
│ │ ObjectType: 0x2::package::UpgradeCap │
│ │ Version: 47482286 │
│ │ Digest: 5aEez7HkJ82Xs5ZArPHJF6Ty38UtprsCvEiyy22hBVRE │
│ └── │
│ Mutated Objects: │
│ ┌── │
│ │ ObjectID: 0x0fcc6d770d80aa409a9645e78ac4810be6400919ac7f507bddd2f9d279da509f │
│ │ Sender: 0x8e8cae7791a93778800b88b6a274de5c32a86484593568d38619c7ea71999654 │
│ │ Owner: Account Address ( 0x8e8cae7791a93778800b88b6a274de5c32a86484593568d38619c7ea71999654 ) │
│ │ ObjectType: 0x2::coin::Coin<0x2::sui::SUI> │
│ │ Version: 47482286 │
│ │ Digest: A6TH6ja5TM4S6nZBwB14AB17ZgixCijYX1aNMGHF3syv │
│ └── │
│ Published Objects: │
│ ┌── │
│ │ PackageID: 0x7b6a8f5782e57cd948dc75ee098b73046a79282183d51eefb83d31ec95c312aa │
│ │ Version: 1 │
│ │ Digest: FKAZc1cmQ9FUYudDQBjZPTb1uXDnekKRUbAALuVnwURC │
│ │ Modules: counter │
│ └── │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Store the PackageID value you receive in your own response to connect to your frontend.
Well done. You have written and deployed the Move package. 🎉
To turn this into a complete app, you need to create a frontend.
In this final part of the app example, you build a frontend (UI) that allows end users to create, increment, and reset Counter objects.
:::info
To skip building the frontend and test out your newly deployed package, create this example using the following template and follow the instructions in the template's README.md file:
$ pnpm create @mysten/dapp --template react-e2e-counter
$ yarn create @mysten/dapp --template react-e2e-counter
:::
<Tabs className="tabsHeadingCentered--small"> <TabItem value="prereq" label="Prerequisites"> Deploy the complete counter Move module and understand its design.
:::tip Additional resources
@mysten/dapp, used within this project to quickly scaffold a React-based Sui app.:::
The UI design consists of two parts:
Counter objectsCounter UI for users to view the value, and to increment and reset the Counter object.The first step is to set up the client app. Run the following command to scaffold a new app.
<Tabs groupId="packagemanager"> <TabItem label="PNPM" value="pnpm">$ pnpm create @mysten/dapp --template react-client-dapp
$ yarn create @mysten/dapp --template react-client-dapp
This app uses the react-spinners package for icons. Install it by running the following command:
$ pnpm add react-spinners
$ yarn add react-spinners
Add the packageId value you saved from deploying your package to a new src/constants.ts file in your project:
export const DEVNET_COUNTER_PACKAGE_ID = "0xTODO";
export const TESTNET_COUNTER_PACKAGE_ID = "0x7b6a8f5782e57cd948dc75ee098b73046a79282183d51eefb83d31ec95c312aa";
export const MAINNET_COUNTER_PACKAGE_ID = "0xTODO";
Update the src/networkConfig.ts file to include the packageID constants.
CounterYou need a way to create a new Counter object.
Create src/CreateCounter.tsx and add the following code:
import { Button, Container } from "@radix-ui/themes";
import { useState } from "react";
import ClipLoader from "react-spinners/ClipLoader";
export function CreateCounter({
onCreated,
}: {
onCreated: (id: string) => void;
}) {
const [waitingForTxn, setWaitingForTxn] = useState(false);
function create() {
// TODO
}
return (
<Container>
<Button
size="3"
onClick={() => {
create();
}}
disabled={waitingForTxn}
>
{waitingForTxn ? <ClipLoader size={20} /> : "Create Counter"}
</Button>
</Container>
);
}
This component renders a button that enables the user to create a counter. Now, update your create function so that it calls the create function from your Move module.
Update the create function in the src/CreateCounter.tsx file:
The create function now creates a new Sui Transaction and calls the create function from your Move module. The PTB is then signed and executed via the useSignAndExecuteTransaction hook. The onCreated callback is called with the new counter's ID when the transaction is successful.
Now that your users can create counters, you need a way to route to them. Routing in a React app can be complex, but this example keeps it basic.
<details> <summary>Set up your src/App.tsx file so that you render the CreateCounter component by default, and if you want to display a specific counter you can put its ID into the hash portion of the URL.
import { ConnectButton, useCurrentAccount } from "@mysten/dapp-kit-react";
import { isValidSuiObjectId } from "@mysten/sui/utils";
import { Box, Container, Flex, Heading } from "@radix-ui/themes";
import { useState } from "react";
import { CreateCounter } from "./CreateCounter";
function App() {
const currentAccount = useCurrentAccount();
const [counterId, setCounter] = useState(() => {
const hash = window.location.hash.slice(1);
return isValidSuiObjectId(hash) ? hash : null;
});
return (
<>
<Flex
position="sticky"
px="4"
py="2"
justify="between"
style={{
borderBottom: "1px solid var(--gray-a2)",
}}
>
<Box>
<Heading>App Starter Template</Heading>
</Box>
<Box>
<ConnectButton />
</Box>
</Flex>
<Container>
<Container
mt="5"
pt="2"
px="4"
style={{ background: "var(--gray-a2)", minHeight: 500 }}
>
{currentAccount ? (
counterId ? (
null
) : (
<CreateCounter
onCreated={(id) => {
window.location.hash = id;
setCounter(id);
}}
/>
)
) : (
<Heading>Please connect your wallet</Heading>
)}
</Container>
</Container>
</>
);
}
export default App;
This sets up your app to read the hash from the URL, and get the counter's ID if the hash is a valid object ID. Then, if you have a counter ID, it renders a Counter (which you define in the next step). If you don't have a counter ID, then it renders the CreateCounter button from the previous step. When a counter is created, you update the URL, and set the counter ID.
Currently, the Counter component doesn't exist, so the app displays an empty page if you navigate to a counter ID.
:::checkpoint
At this point, you have a basic routing setup. Run your app and ensure you can:
The create counter button should look like this:
:::
Create a new file: src/Counter.tsx.
For your counter, you want to display three elements:
getObject RPC method.increment Move function.set_value Move function with 0. This is only shown if the current user owns the counter.Add the following code to your src/Counter.tsx file:
import { bcs } from '@mysten/sui/bcs';
import { useCurrentAccount, useCurrentClient, useDAppKit } from '@mysten/dapp-kit-react';
import { Transaction } from '@mysten/sui/transactions';
import { useMutation, useQuery } from '@tanstack/react-query';
const CounterStruct = bcs.struct('Counter', {
id: bcs.Address,
owner: bcs.Address,
value: bcs.u64(),
});
export function Counter({
id,
packageId,
}: {
id: string;
packageId: string;
}) {
const client = useCurrentClient();
const dAppKit = useDAppKit();
const account = useCurrentAccount();
const { data: counter, refetch } = useQuery({
queryKey: ['counter', id],
queryFn: async () => {
const object = await client.core.getObject({
objectId: id,
});
const parsed = CounterStruct.parse(object.content);
return {
value: Number(parsed.value),
owner: parsed.owner,
};
},
});
const { mutate: increment } = useMutation({
mutationFn: async () => {
const tx = new Transaction();
tx.moveCall({
target: `${packageId}::counter::increment`,
arguments: [tx.object(id)],
});
const result = await dAppKit.signAndExecuteTransaction({
transaction: tx,
});
if (result.$kind === 'FailedTransaction') {
throw new Error('Transaction failed');
}
},
onSuccess: () => refetch(),
});
const { mutate: reset } = useMutation({
mutationFn: async () => {
const tx = new Transaction();
tx.moveCall({
target: `${packageId}::counter::set_value`,
arguments: [tx.object(id), tx.pure.u64(0)],
});
const result = await dAppKit.signAndExecuteTransaction({
transaction: tx,
});
if (result.$kind === 'FailedTransaction') {
throw new Error('Transaction failed');
}
},
onSuccess: () => refetch(),
});
return (
<div>
<div>Count: {counter?.value ?? '--'}</div>
<button onClick={() => increment()}>Increment</button>
{account?.address === counter?.owner && (
<button onClick={() => reset()}>Reset</button>
)}
</div>
);
}
This snippet shows the key concepts. It uses the useCurrentClient hook to get the Sui client, combined with TanStack Query's useQuery hook to fetch the object. The useDAppKit hook provides access to transaction signing.
Note that the gRPC API returns object content as BCS-encoded bytes. The snippet above includes inline BCS type definitions to parse the counter data. For production use, the codegen package can generate these types automatically from your Move modules. The full template (shown below) uses the codegen package for BCS parsing.
<details> <summary>The full template version of src/Counter.tsx (using the codegen package for BCS parsing):
Now that you have a Counter component, you need to update your App component to render it when you have a counter ID.
Update the src/App.tsx file to render the Counter component when you have a counter ID:
:::checkpoint
At this point, you have the complete app. 🎉 Run it and ensure you can:
The Counter component should look like this:
:::