Chromia
Start Building

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:

src/components/ContextProvider.tsx
"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:

  1. ChromiaContext: stores a Session object, 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:

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:

  1. Start the app: The app begins by connecting to MetaMask, a widely used Ethereum wallet provider.
  2. 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).
  3. 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.
  4. Create an account (new user): When no account is linked to the wallet, the app creates one. Module 1 covers this process in detail.
  5. 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:

src/components/ContextProvider.tsx
// 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:

  1. Initialize the client: The client connects to the local blockchain node with blockchainIid 0. Running chr node start to 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 blockchainRid to blockchainIid for 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});
  2. Connect with MetaMask: The app connects to MetaMask. The ethereum object is declared globally on the Window interface so the compiler does not emit warnings.

  3. 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.

  4. 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.

  5. 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 Session uses. register_user is 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 in src/app/user.tsx that generates a random name.

src/app/user.tsx
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:

src/components/ContextProvider.tsx
// 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:

src/app/hooks.tsx
// 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:

src/components/NewsFeed.tsx
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:

src/components/NewsFeed.tsx
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:

src/components/NewsFeed.tsx
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:

src/components/UserList.tsx
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.

src/components/UserItem.tsx
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:

src/components/NewPost.tsx
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.

Written for this site from Chromia’s public course. Code samples are the course’s own.