React Integration for Beginners: From API Calls to Smart Contracts

If you already know basic React — components, props, state — the next step is learning how React apps actually talk to the outside world: APIs, GraphQL servers, crypto wallets, and smart contracts. This is called “integration,” and it’s exactly what separates a beginner React developer from someone who can build real, production-ready apps.
This guide is written in plain JavaScript (no TypeScript), and walks through the path step by step:
API Integration → GraphQL Integration → Wallet Integration → Contract Integration
Along the way, we’ll cover the React hooks you actually need for this kind of work.
Repository: mirbasit01/Integration-hub on GitHub — every example in this post has working code there. Clone it, run it, and practice each step yourself instead of just reading. Live demo: integrationbymirbasit01.netlify.app.
1. The Hooks You Need to Know First

Before touching any API or wallet, make sure these hooks feel natural to you. Everything else in this guide builds on top of them.
useState
Stores data that can change and re-renders your component when it changes.
const [user, setUser] = useState(null);
useEffect
Runs code when your component loads, or when something changes. This is where most API calls happen.
useEffect(() => {
fetchUser();
}, []); // empty array = run once when component mountsuseContext
Lets you share data (like a logged-in user, or a wallet connection) across many components without passing props manually at every level.
const { user } = useContext(AuthContext);Custom Hooks
A custom hook is just a normal function, whose name starts with use, that reuses logic across components. This is the single most important pattern for integration work.
function useUser(id) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); useEffect(() => {
let ignore = false; // guards against a stale responsesetLoading(true);
setError(null);
fetch(`/api/users/${id}`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => {
if (!ignore) {
setUser(data);
setLoading(false);
}
})
.catch((err) => {
if (!ignore) {
setError(err);
setLoading(false);
}
}); return () => {
ignore = true; // component unmounted, or id changed — ignore this request
};
}, [id]); return { user, loading, error };
}Two things matter here that beginners often skip:
- Errors are caught, not just successes — the hook returns an error value the UI can check.
- The ignore flag stops an old request from overwriting newer data. If id changes quickly (say, the user clicks between two profiles), the first request might still be in flight when the second one finishes. Without this guard, the slower, outdated response can land last and show the wrong user.

Now any component can just do:
const { user, loading, error } = useUser("123");useCallback and useMemo
These prevent functions and values from being recreated on every render — useful once your app has real API calls and re-renders often.
useRef
Holds a value that doesn’t trigger a re-render when it changes. Common for things like storing a socket connection or a timer ID.

2. Step One: API Integration
This is the foundation. Almost every real app needs to fetch or send data to a server.
The basic fetch
const response = await fetch("/api/users");
const data = await response.json();Don’t scatter fetch() everywhere
A common beginner mistake is calling fetch() directly inside every component. Instead, build a small API client — one place that knows how to talk to your backend. We'll use a factory function instead of a class, keeping it consistent with function-based components.
function createApiClient(baseURL) {
async function get(path) {
const response = await fetch(`${baseURL}${path}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
} async function post(path, body) {
const response = await fetch(`${baseURL}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
} return { get, post };
}const api = createApiClient("https://api.example.com");Now every component uses api.get("/users") instead of repeating fetch logic. This one change is the beginning of "integration architecture."
Always handle four states

Loading, success, empty, and error. Beginners often only handle success — here’s what a real component looks like when it handles all four:
function UserList() {
const [users, setUsers] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); useEffect(() => {
setLoading(true);
api.get("/users")
.then((data) => setUsers(data))
.catch((err) => setError(err))
.finally(() => setLoading(false));
}, []);if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error.message} />;
if (!users || users.length === 0) return <EmptyState text="No users yet" />;
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
Notice the order: check loading first, then error, then empty, and only then render the real data. Skipping any of these is how beginners end up with blank screens or crashes when the API is slow, fails, or simply returns nothing.
3. Step Two: GraphQL Integration

GraphQL is an alternative to REST where the client asks for exactly the fields it needs.
A basic query
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}Using it in React with fetch (no extra library)
async function fetchUser(id) {
const response = await fetch("/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: `
query GetUser($id: ID!) {
user(id: $id) { id name email }
}
`,
variables: { id },
}),
}); if (!response.ok) {
throw new Error(`Network error: HTTP ${response.status}`);
} const { data, errors } = await response.json();// GraphQL has its own error format — a request can return HTTP 200
// and still contain errors, so this check is easy to miss.
if (errors && errors.length > 0) {
throw new Error(errors[0].message);
}
return data.user;
}
Notice there are two different kinds of failure to handle: a network/HTTP failure (!response.ok), and a GraphQL-level failure, which comes back inside a normal 200 response as an errors array. Checking only response.ok and skipping errors is a very common beginner bug in GraphQL apps.

Later, once you’re comfortable, tools like Apollo Client or urql handle caching and re-fetching for you — but understanding the raw request first is important.
4. Step Three: Wallet Integration

If your app talks to blockchains, the first integration point is connecting a user’s wallet (like MetaMask).
The basic idea
async function connectWallet() {
if (!window.ethereum) {
alert("Please install MetaMask");
return;
} const accounts = await window.ethereum.request({
method: "eth_requestAccounts",
});return accounts[0]; // the connected wallet address
}
As a custom hook
function useWallet() {
const [address, setAddress] = useState(null); async function connect() {
const accounts = await window.ethereum.request({
method: "eth_requestAccounts",
});
setAddress(accounts[0]);
} return { address, connect };
}In real projects, libraries like wagmi and viem handle wallet connection, network switching, and account changes for you — but this raw version is what they do under the hood.
5. Step Four: Smart Contract Integration

Once a wallet is connected, you can read from and write to smart contracts. Both readContract and writeContract need a client — this is what actually talks to the blockchain, and it's set up once, outside your components:

import { createPublicClient, createWalletClient, custom, http } from "viem";
import { mainnet } from "viem/chains";// Public client — for reads. No wallet needed, just an RPC connection.
const publicClient = createPublicClient({
chain: mainnet,
transport: http(),
});
// Wallet client — for writes. Wraps the connected wallet (e.g. MetaMask).
const walletClient = createWalletClient({
chain: mainnet,
transport: custom(window.ethereum),
});
Reading data (no wallet signature needed)
import { readContract } from "viem/actions";async function getBalance(address) {
const balance = await readContract(publicClient, {
address: contractAddress,
abi: contractAbi,
functionName: "balanceOf",
args: [address],
});
return balance;
}A read only needs the publicClient — the user doesn't even need a wallet connected to see this data, since it's just querying public chain state.
Writing data (requires wallet confirmation)
async function transferTokens(to, amount) {
const hash = await writeContract(walletClient, {
address: contractAddress,
abi: contractAbi,
functionName: "transfer",
args: [to, amount],
});
return hash;
}A write needs the walletClient instead, because it has to be signed by the user's connected wallet.
The key idea: reads are free and instant, writes cost gas and need user approval. Your UI should treat these very differently — writes need loading states, confirmations, and error handling for rejected transactions.
Recap: The Path
API Integration
↓
GraphQL Integration
↓
Wallet Integration
↓
Contract Integration
Don’t try to master all four at once. Start by building a small API client, wrap it in one custom hook, and use it in a real component. Once that feels natural, move to the next layer. Each step builds directly on the one before it — and by the end, you won’t just be “using” integrations, you’ll be able to build them for other developers too.