25 / 27Module 2: The React projectBeginner
Connect the client
This lesson connects the frontend to the blockchain with the postchain-client library and the FT4-wrapper. You build a ContextProvider and write custom React hooks so the app's components can work with the blockchain directly.
Create a context
Blockchain data and state belong in a single shared context that every part of the app can reach. Add a file called ContextProvider.tsx to the src/components directory and declare the context there:
"use client";import { Session, createKeyStoreInteractor, createSingleSigAuthDescriptorRegistration, createWeb3ProviderEvmKeyStore, hours, registerAccount, registrationStrategy, ttlLoginRule,} from "@chromia/ft4";import { createClient } from "postchain-client";import { ReactNode, createContext, useContext, useEffect, useState } from "react";import { getRandomUserName } from "../app/user";// Create context for Chromia sessionconst ChromiaContext = createContext<Session | undefined>(undefined);export function ContextProvider({ children }: { children: ReactNode }) { // Initialize session and EVM address states const [session, setSession] = useState<Session | undefined>(undefined); // Additional state initialization will be defined here return <ChromiaContext.Provider value={session}>{children}</ChromiaContext.Provider>;}The ContextProvider component defines one context:
- ChromiaContext: stores a
Sessionobject, which wraps the FT4 client. It tracks the current session's state, including the key pairs used to sign transactions.
The component sets up the initial state and wraps the app's components in the context. To make the context available, wrap the NavBar and {children} tags in src/app/layout.tsx:
import { ContextProvider } from '@/components/ContextProvider'<ContextProvider> <NavBar /> {children}</ContextProvider>Initialize the session
The flow diagram below shows how a new session is set up:
The steps work as follows:
- Start the app: The app begins by connecting to MetaMask, a widely used Ethereum wallet provider.
- Check whether the user exists in the dapp: The app checks if the user's Ethereum wallet is already linked to an FT account in the decentralized app (dapp).
- Create a session (existing user): When the wallet is linked to an account, the app opens a new session, through which the user interacts with the FT protocol.
- Create an account (new user): When no account is linked to the wallet, the app creates one. Module 1 covers this process in detail.
- Create a session: Once the account exists, the app opens a session so the user can interact with the blockchain through the FT4-wrapper.
Next, implement this flow:
// 2.declare global { interface Window { ethereum: any; }}export function ContextProvider({ children }: { children: ReactNode }) { const [session, setSession] = useState<Session | undefined>(undefined); useEffect(() => { const initSession = async () => { console.log("Initializing Session"); // 1. Initialize Client const client = await createClient({ nodeUrlPool: "http://localhost:7740", blockchainRid: "26A69CAACE069D03404D58E17CF9E38B4417274D5E4BEB663E6F329FD56F6D90", // Add your blockchainRid }); // 2. Connect with MetaMask const evmKeyStore = await createWeb3ProviderEvmKeyStore(window.ethereum); // 3. Get all accounts associated with evm address const evmKeyStoreInteractor = createKeyStoreInteractor(client, evmKeyStore); const accounts = await evmKeyStoreInteractor.getAccounts(); if (accounts.length > 0) { // 4. Start a new session const { session } = await evmKeyStoreInteractor.login({ accountId: accounts[0].id, config: { rules: ttlLoginRule(hours(2)), flags: ["MySession"], }, }); setSession(session); } else { // 5. Create a new account by signing a message using metamask const authDescriptor = createSingleSigAuthDescriptorRegistration(["A", "T"], evmKeyStore.id); const { session } = await registerAccount( client, evmKeyStore, registrationStrategy.open(authDescriptor, { config: { rules: ttlLoginRule(hours(2)), flags: ["MySession"], }, }), { name: "register_user", args: [getRandomUserName()], } ); setSession(session); } console.log("Session initialized"); }; initSession().catch(console.error); }, []); return <ChromiaContext.Provider value={session}>{children}</ChromiaContext.Provider>;}Here is what the code does:
-
Initialize the client: The client connects to the local blockchain node with
blockchainIid0. Runningchr node startto launch a test node prints logs that show the REST API port, along with the blockchain's internal identifier (chain id) and its external identifier (blockchain Rid).The internal Id (
blockchainIid) is the easier choice while testing, because it matches the chain's state. When you connect to a blockchain deployed on a network, use its referential Id (blockchainRid) instead, so the client targets and talks to the correct blockchain on that network.To switch from
blockchainRidtoblockchainIidfor local development, follow the code below:typescript const client = await createClient({ nodeUrlPool: "http://localhost:7740", // blockchainRid: "..." // Replace this blockchainIid: 0 // With this for local dev}); -
Connect with MetaMask: The app connects to MetaMask. The
ethereumobject is declared globally on theWindowinterface so the compiler does not emit warnings. -
Retrieve accounts: The app fetches every account in the dapp tied to the Ethereum wallet address. Each wallet is expected to have either 0 or 1 account.
-
Start a new session: If an account is found, the app starts a session using the "MySession" flag from the dapp's authentication handlers. The session expires after two hours, at which point the user must sign in again.
-
Create a new account: If no account is found, the user signs a message in MetaMask and the app sends a transaction to the dapp to create one. The Conceptual design page explains this in depth. The EVM key gets the "A" and "T" flags, which let it sign administrative and transfer operations, plus the "MySession" flag for the session key that the
Sessionuses.register_useris set as the registration operation, which lets you pass the user name as an argument.The account's user name comes from
getRandomUserName, a function defined insrc/app/user.tsxthat generates a random name.
const funnyAnimalNames = [ "SneakyLlama", "CheekyMonkey", "LaughingPenguin", "CrazyKangaroo", "GigglingHedgehog", "WackyWalrus", "DancingDolphin", "BumblingBee", "HoppingHare", "SingingSeagull",];export function getRandomUserName() { const randomIndex = Math.floor(Math.random() * funnyAnimalNames.length); return funnyAnimalNames[randomIndex];}Access context with custom hooks
Custom hooks make it simple to call backend queries and operations from the frontend:
// Define hooks for accessing contextexport function useSessionContext() { return useContext(ChromiaContext);}Then create a file named src/app/hooks.tsx and add a hook called useQuery:
// Create a custom hook for queriesimport { useSessionContext } from "@/components/ContextProvider";import { useEffect, useState, useCallback } from "react";import { RawGtv, DictPair } from "postchain-client";// Custom hook for queries and operationsexport function useQuery<TReturn extends RawGtv, TArgs extends DictPair | undefined = DictPair>( name: string, args?: TArgs) { const session = useSessionContext(); const [serializedArgs, setSerializedArgs] = useState(JSON.stringify(args)); const [data, setData] = useState<TReturn | undefined>(); // Function to send the query const sendQuery = useCallback(async () => { if (!session || !args) return; const data = await session.query<TReturn>({ name: name, args: args }); setSerializedArgs(JSON.stringify(args)); setData(data!!); }, [session, name, args]); // Trigger the query when session, query name, or arguments change useEffect(() => { sendQuery().catch(console.error); }, [session, name, serializedArgs]); // Return query result and reload function return { result: data, reload: sendQuery, };}The hook reads the required context and runs the query again whenever the session, the query name, or the arguments change. Serializing the arguments keeps them stable and prevents unwanted queries. If the arguments are not yet initialized, the hook waits to run the query until they are defined.
Implement the news feed
The news feed lives in the NewsFeed.tsx component. Start by defining Data Transfer Object (DTO) structures that match the return type of the get_posts query, so the data has a clear shape:
import { useEffect } from "react";import { useSessionContext } from "@/components/ContextProvider";import { useQuery } from "@/app/hooks";export type User = { name: string; id: number; account: number;};export type PostDto = { timestamp: number; user: User; content: string;};export type GetPostsReturnType = { pointer: number; posts: PostDto[];};Inside the NewsFeed component, call the useQuery hook to load the user's details, follower and following counts, and the feed's posts:
export default function NewsFeed() { const session = useSessionContext() const accountId = session?.account.id; const { result: userName } = useQuery<string>("get_user_name", accountId ? { user_id: accountId } : undefined); const { result: followersCount } = useQuery<number>("get_followers_count", accountId ? { user_id: accountId } : undefined); const { result: followingCount } = useQuery<number>("get_following_count", accountId ? { user_id: accountId } : undefined); const { result: newsFeed, reload: reloadPosts } = useQuery<GetPostsReturnType>("get_posts", accountId ? { user_id: accountId, n_posts: 10, pointer: 0 } : undefined); // Refresh posts every 10 seconds useEffect(() => { const refreshPosts = setInterval(() => { reloadPosts(); }, 10000); return () => { clearInterval(refreshPosts); } });These calls use the get_user_name, get_followers_count, get_following_count, and
get_posts queries. Posts reload every 10 seconds. While evmContext is not
yet initialized, you pass undefined so loading proceeds cleanly. Add the values to the layout like this:
return ( <div className="p-4 md:p-8"> <div className="flex justify-between items-center"> <h1 className="text-2xl font-bold">{userName}</h1> <div className="flex text-center"> {/* Followers Box */} <div className="bg-white m-1 p-2 rounded-lg shadow"> <h3 className="text-lg font-semibold">Followers</h3> <p className="text-3xl font-bold">{followersCount}</p> </div> {/* Following Box */} <div className="bg-white m-1 p-2 rounded-lg shadow"> <h3 className="text-lg font-semibold">Following</h3> <p className="text-3xl font-bold">{followingCount}</p> </div> </div> </div> {/* News Feed */} <div className="bg-white p-4 rounded-lg shadow"> <ul> {newsFeed ? ( newsFeed.posts.map((post, index) => ( <li key={index} className="mb-4"> <div className="flex"> <div className="font-semibold">{post.user.name}</div> <div className="text-gray-500 text-sm ml-2">{new Date(post.timestamp).toLocaleString()}</div> </div> <div className="mt-2">{post.content}</div> {/* Add a horizontal line between posts */} <hr className="my-4 border-t border-gray-300" /> </li> )) ) : ( <p>Loading...</p> )} </ul> </div> </div>);The UserList component
Add the following code to the UserList component to load 100 users:
import { useQuery } from "@/app/hooks";export default function UsersList() { const { result: users } = useQuery<GetUsersReturnType>("get_users", { n_users: 100, pointer: 0 });The app now fetches the first 100 users. You could extend the get_users and get_posts queries to load more users as you scroll, but this course keeps things simple.
The UserItem component
For the follow/unfollow button in the UserItem component to work, you need to know whether you already follow the user, then call follow_user or unfollow_user accordingly. Starting from the structure built in Lesson 2, add a hook for the session and call the matching operation.
export default function UserItem({ user }: { user: UsersDto }) { // Step 1: Initialize state variables const session = useSessionContext(); const accountId = session?.account.id; const { result: isFollowing, reload: updateIsFollowing } = useQuery<boolean>("is_following", accountId ? { my_id: accountId, your_id: user.id } : undefined); ... // Step 2: Handle follow/unfollow click const handleFollowClick = async (userId: Buffer, follow: boolean) => { if (!session) return try { setIsLoading(true); // Step 3: Handle follow/unfollow logic await session.call({ name: follow ? "follow_user" : "unfollow_user", args: [userId] }); updateIsFollowing();The isFollowing state is replaced by a query that returns the real follow status. The handleFollowClick function calls follow_user or unfollow_user to toggle that status, and then reloads the query to refresh isFollowing, so the component always shows the current follow state.
The NewPost component
Post creation follows the same pattern as UserItem. Using the scaffold from Lesson 2, add this logic:
export default function NewPost() { // Step 1: Initialize state variables const session = useSessionContext(); ... // Step 3: Handle form submission const onSubmit = async (data: string) => { if (!session) return try { if (data.trim() !== '') { setIsLoading(true); // Step 4: Content submission await session.call({ name: "make_post", args: [data], }) router.push('/');Your dapp now lets users write and publish posts.