Skip to main content

Distributed Counter

This example walks you through building a basic distributed counter app, covering the full end-to-end flow connecting your Move code to your client app. The app allows you to create counters that anyone can increment, but only the owner can reset. This example assumes you already have a React App set with dApp Kit, and it's required Providers as described in Client App with Sui TypeScript SDK.

info

You must use the pnpm or yarn package managers to create Sui project scaffolds. Follow the pnpm install or yarn install instructions, if needed.

If haven't followed Client App with Sui TypeScript SDK, run the following command in a terminal or console to scaffold a new app:

pnpm create @mysten/dapp --template react-client-dapp

or

yarn create @mysten/dapp --template react-client-dapp

To get a head start, you can automatically create this example using the following template value instead:

pnpm create @mysten/dapp --template react-e2e-counter

or

yarn create @mysten/dapp --template react-e2e-counter

Adding a Move module

The first element you need is a Move package to interact with. This example doesn't go in-depth on the Move code itself, but covers how to deploy it, and connect it to your dApp.

First, create a new move directory at the root of your project to place your Move code and then make it the active directory:

mkdir move
cd move

Next, use the Sui Client CLI to generate a new Move package. If you have Sui installed, the Sui CLI is on your system. Run the following command in your terminal or console:

sui move new counter

This creates a new, empty Move package in a new move/counter directory with a Move.toml file, and an empty sources directory.

Add your Move code under sources by creating a new counter.move file:

module counter::counter {
use sui::transfer;
use sui::object::{Self, UID};
use sui::tx_context::{Self, TxContext};

/// A shared counter.
struct Counter has key {
id: UID,
owner: address,
value: u64
}

/// Create and share a Counter object.
public fun create(ctx: &mut TxContext) {
transfer::share_object(Counter {
id: object::new(ctx),
owner: tx_context::sender(ctx),
value: 0
})
}

/// Increment a counter by 1.
public fun increment(counter: &mut Counter) {
counter.value = counter.value + 1;
}

/// Set value (only runnable by the Counter owner)
public fun set_value(counter: &mut Counter, value: u64, ctx: &TxContext) {
assert!(counter.owner == tx_context::sender(ctx), 0);
counter.value = value;
}
}

Now that you have your Move code, you need to publish it. The Client App with Sui TypeScript SDK example and the app template use testnet by default, so configure your code to match the network you want to deploy to.

First, update the Sui dependency in Move.toml by changing the rev from framework/testnet to framework/devnet.

...
[dependencies]
Sui = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "framework/devnet" }
...

Next, configure the Sui CLI to use devnet as the active environment, as well. If you haven't already set up a devnet environment you can do so by running the following command in a terminal or console:

sui client new-env --alias devnet --rpc https://fullnode.devnet.sui.io:443

Run the following command to activate the devnet environment:

sui client switch --env devnet

Now, publish your Move code with the following command:

sui client publish --gas-budget 10000000 counter
info

See Sui Client CLI for more information about client commands in the Sui CLI.

The output of this command contains a packageId value that you need to save to use the package.

----- Object changes ----
Array [
Object {
...
},
Object {
...
},
Object {
"type": String("published"),
"packageId": String("0xcd16d38ec30a4ad609336b51f6859a6b1014c50801b47845ac7a251e436cccf7"),
"version": String("1"),
"digest": String("4bCjupBDiaANmBySAtxuAdXEvGdKW4wrya6sbmRvynEe"),
"modules": Array [
String("counter"),
],
},
]
----- Balance changes ----

Add the packageId value you receive in your own response to a new constants.ts file in your project:

export const COUNTER_PACKAGE_ID =
"0xcd16d38ec30a4ad609336b51f6859a6b1014c50801b47845ac7a251e436cccf7";

Creating a counter

Now that you've published your Move code, you can start building your UI to use your Move package. You need a way to create a new Counter object. Do this by creating a new CreateCounter component:

export function CreateCounter(props: { onCreated: (id: string) => void }) {
return (
<div>
<button
onClick={() => {
create();
}}
>
Create Counter
</button>
</div>
);

function create() {
props.onCreated('TODO');
}
}

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 in your Move module.

To do this, you need to construct a TransactionBlock, with the appropriate moveCall transaction, and then sign and execute the programmable transaction block (PTB).

First, import TransactionBlock from @mysten/sui.js, COUNTER_PACKAGE_ID from your constants.ts file created previously, and useSignAndExecuteTransactionBlock from @mysten/dapp-kit.

import { useSignAndExecuteTransactionBlock } from '@mysten/dapp-kit';
import { TransactionBlock } from '@mysten/sui.js/transactions';

import { COUNTER_PACKAGE_ID } from './constants';

Next, call the useSignAndExecuteTransactionBlock hook in your component, which provides a mutate function you can use in your create function:

export function CreateCounter(props: { onCreated: (id: string) => void }) {
const { mutate: signAndExecute } = useSignAndExecuteTransactionBlock();
return (
<div>
<button
onClick={() => {
create();
}}
>
Create Counter
</button>
</div>
);

function create() {
// TODO
}
}

Finally, construct your TransactionBlock:

function create() {
const txb = new TransactionBlock();
txb.moveCall({
arguments: [],
target: `${COUNTER_PACKAGE_ID}::counter::create`,
});

signAndExecute(
{
transactionBlock: txb,
options: {
// We need the effects to get the objectId of the created counter object
showEffects: true,
},
},
{
onSuccess: (tx) => {
// The first created object in this TransactionBlock should be the new Counter
const objectId = tx.effects?.created?.[0]?.reference?.objectId;
if (objectId) {
props.onCreated(objectId);
}
},
},
);
}
}

You now have a functional component that can create a new Counter object, but if you use it as is, you might run into some consistency issues where you successfully execute the TransactionBlock, but the data isn't yet indexed to read from an RPC node. To ensure the TransactionBlock is available, you can use the waitForTransactionBlock method of SuiClient. To get an instance of SuiClient, you can use the useSuiClient hook from dApp Kit:

import { useSignAndExecuteTransactionBlock, useSuiClient } from '@mysten/dapp-kit';

export function CreateCounter(props: { onCreated: (id: string) => void }) {
const suiClient = useSuiClient();
const { mutate: signAndExecute } = useSignAndExecuteTransactionBlock();

return <button />;
}

Now you can use the suiClient in your create function to wait until the TransactionBlock is indexed:

function create() {
const txb = new TransactionBlock();
txb.moveCall({
arguments: [],
target: `${COUNTER_PACKAGE_ID}::counter::create`,
});

signAndExecute(
{
transactionBlock: txb,
options: {
showEffects: true,
},
},
{
onSuccess: (tx) => {
suiClient
.waitForTransactionBlock({
digest: tx.digest,
})
.then(() => {
const objectId = tx.effects?.created?.[0]?.reference?.objectId;
if (objectId) {
props.onCreated(objectId);
}
});
},
},
);
}

Set up routing

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. Set up your App 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';
import { isValidSuiObjectId } from '@mysten/sui.js/utils';
import { useState } from 'react';

export default function App() {
const currentAccount = useCurrentAccount();
const [counterId, setCounter] = useState(() => {
const hash = window.location.hash.slice(1);
return isValidSuiObjectId(hash) ? hash : null;
});

return (
<div>
<nav>
<ConnectButton />
</nav>
<section>
{!currentAccount ? (
'Please connect your wallet'
) : counterId ? (
<Counter id={counterId} />
) : (
<CreateCounter
onCreated={(id) => {
window.location.hash = id;
setCounter(id);
}}
/>
)}
</section>
</div>
);
}

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, it either renders a Counter (which you define in the next step) if you have a counter ID, or the CreateCounter button from the previous step. When a counter is created, you update the URL, and set the counter ID.

Building your counter user interface

For your counter, you want to display three elements:

  • The current count, which you fetch from the object using the getObject RPC method.
  • An increment button, which calls the increment Move function.
  • A reset button, which calls the set_value Move function with 0. This is only shown if the current user owns the counter.
import { useCurrentAccount, useSuiClientQuery } from '@mysten/dapp-kit';
import { SuiObjectData } from '@mysten/sui.js/client';

export function Counter({ id }: { id: string }) {
const currentAccount = useCurrentAccount();
const { data, refetch } = useSuiClientQuery('getObject', {
id,
options: {
showContent: true,
},
});

if (!data?.data) return <div>Not found</div>;

const ownedByCurrentAccount = getCounterFields(data.data)?.owner === currentAccount?.address;

return (
<div>
<div>Count: {getCounterFields(data.data)?.value}</div>

<button onClick={() => executeMoveCall('increment')}>Increment</button>
{ownedByCurrentAccount ? (
<button onClick={() => executeMoveCall('reset')}>Reset</button>
) : null}
</div>
);

function executeMoveCall(method: 'increment' | 'reset') {
// TODO
}
}

function getCounterFields(data: SuiObjectData) {
if (data.content?.dataType !== 'moveObject') {
return null;
}

return data.content.fields as { value: number; owner: string };
}

This snippet has a few new concepts to examine. It uses the useSuiClientQuery hook to make the getObject RPC call. This returns a data object representing your counter. dApp Kit doesn't know which fields your counter object has, so define a getCounterFields helper that gets the counter fields, and adds a type-cast so that you can access the expected value and owner fields in your component.

The code also adds an executeMoveCall function that still needs implementing. This works just like the create function you used to create the counter. Instead of using a callback prop like you did for CreateCounter, you can use the refetch provided by useSuiClientQuery to reload your Counter object after you've executed your PTB.

import {
useCurrentAccount,
useSignAndExecuteTransactionBlock,
useSuiClient,
useSuiClientQuery,
} from '@mysten/dapp-kit';
import { SuiObjectData } from '@mysten/sui.js/client';
import { TransactionBlock } from '@mysten/sui.js/transactions';

import { COUNTER_PACKAGE_ID } from './constants';

export function Counter({ id }: { id: string }) {
const currentAccount = useCurrentAccount();
const suiClient = useSuiClient();
const { mutate: signAndExecute } = useSignAndExecuteTransactionBlock();

// ...

function executeMoveCall(method: 'increment' | 'reset') {
const txb = new TransactionBlock();

if (method === 'reset') {
txb.moveCall({
arguments: [txb.object(id), txb.pure.u64(0)],
target: `${COUNTER_PACKAGE_ID}::counter::set_value`,
});
} else {
txb.moveCall({
arguments: [txb.object(id)],
target: `${COUNTER_PACKAGE_ID}::counter::increment`,
});
}

signAndExecute(
{
transactionBlock: txb,
},
{
onSuccess: (tx) => {
suiClient.waitForTransactionBlock({ digest: tx.digest }).then(() => {
refetch();
});
},
},
);
}
}

Your counter app is now ready to count. To learn more about dApp Kit, check out the dApp Kit docs.