Tools: Reading and Reacting to Contract State from a Frontend (2026)

Tools: Reading and Reacting to Contract State from a Frontend (2026)

Reading and Reacting to Contract State from a Frontend

Two Paths to Contract State

Using indexerPublicDataProvider

Wiring It Into Your Contract API

Direct GraphQL: One-Shot Queries

WebSocket Subscriptions with contractActions

The WebSocket Behavior Gotcha

Subscriptions vs Polling: Why It Matters

Deserializing the Hex State — The Part Nobody Warns You About

Putting It Together: A React Hook

Rendering the Component

Testing the Integration Locally

Common Mistakes

Summary When I first tried to display live contract state in a Midnight React app, I expected something like Ethereum's eth_call — send a read request, get structured data back, render it. What I got instead was a hex string that looked like "0x0000000000000000a4f3..." and zero obvious way to turn it into something my component could display. This is the tutorial I needed then. We'll cover how the indexer exposes contract state via GraphQL, when to use indexerPublicDataProvider versus writing your own subscription, how WebSocket subscriptions actually behave (they're not polling, and that matters), and — the part that stumped me longest — how to deserialize the opaque hex field into real TypeScript types. All examples target @midnight-ntwrk/midnight-js v7.x and TypeScript 5. Midnight.js gives you two ways to read contract state from a frontend: indexerPublicDataProvider — a high-level provider from @midnight-ntwrk/midnight-js-indexer-public-data-provider that wraps the indexer's GraphQL API. It handles connection lifecycle, retries, and gives you typed state through your contract's generated Ledger types. This is the right choice for most dApps. Direct GraphQL — talking to the indexer's GraphQL endpoint directly with graphql-ws or any GraphQL client. More flexible, required when you need subscription fan-out to multiple components or when you're building tooling that doesn't fit the provider model. I'll cover both. Start with the provider and only reach for raw GraphQL when you need it. Install the packages: Your indexer exposes its GraphQL API at two endpoints: For local development that's http://localhost:8088/api/v4/graphql and ws://localhost:8088/api/v4/graphql/ws. The public testnet uses https://indexer.testnet-02.midnight.network/api/v4/graphql. Store these as environment variables — you'll use both: The provider pattern is how Midnight.js is designed to work. You create a provider instance and pass it into your contract API — the SDK handles subscribing to state changes and handing you typed updates. The provider connects over WebSocket immediately on creation. It uses the graphql-transport-ws subprotocol — make sure your network doesn't strip WebSocket upgrade headers (this bites people behind certain reverse proxies). Your compiled Compact contract exports a namespace — I'll call it Counter here — that includes a Ledger object with your contract's state type and serialization logic. The contract API factory takes providers as arguments: From there, contractAPI.state is an Observable<Counter.Ledger.State> — subscribe to it and you get typed state updates every time a transaction touches the contract: This is the happy path. If you're in this situation, stop reading and go build your component. Sometimes you want to fetch current contract state without setting up the full provider stack — for a dashboard, a block explorer component, or a read-only page that doesn't need a wallet. The contractAction query returns the most recent action at an optional block offset: Notice the union types. Every contract action is one of three things: ContractDeploy (first block the contract appeared), ContractCall (someone called an entry point), or ContractUpdate (maintenance state change from the ledger itself). For reading state you typically don't care which type it is — state is present on all three — but entryPoint only exists on ContractCall, so use inline fragments if you need it. Here's a typed fetch helper: The returned state is a HexEncoded scalar — just a hex string. More on decoding it in a moment. The contractActions subscription streams every action for a contract address as it's included in a block. This is fundamentally different from polling: the server pushes updates to you, so you're not hitting the indexer repeatedly and there's no lag introduced by a polling interval. You can also pass an offset to start from a specific block height — useful if you want to replay history: Here's what surprised me: the subscription doesn't immediately emit the current state when you connect. It emits the next action after your connection is established. If your contract hasn't been called recently, your component will sit empty until something happens on-chain. The fix is to fetch current state first (one-shot HTTP query), then subscribe for updates: The createClient here is from graphql-ws: One thing that's easy to miss: graphql-ws uses the graphql-transport-ws subprotocol, not the older graphql-ws subprotocol. These are different things despite the confusing naming. If you're connecting through a proxy, make sure Upgrade: websocket and Sec-WebSocket-Protocol: graphql-transport-ws headers are preserved. Nginx in particular will strip WebSocket upgrade headers unless you explicitly configure proxy_set_header Upgrade $http_upgrade and proxy_set_header Connection "upgrade". The latency difference is real. With polling every 5 seconds, a user might wait up to 5 seconds to see their transaction reflected in the UI. With a subscription, the update arrives within a block time (~6 seconds on testnet, faster on mainnet) of the transaction being finalized — and your component renders it without any artificial delay. More importantly: subscriptions scale better. Polling 50 components each at 3-second intervals is 50 HTTP requests per 3 seconds. Subscriptions are one persistent WebSocket connection per provider instance, regardless of how many components are listening. The tradeoff is reconnection handling. If the WebSocket drops, graphql-ws will attempt to reconnect, but you'll miss actions that happened during the gap. For most UIs this doesn't matter — you refetch current state on reconnect and pick up from there. For audit logs or event history, you'd want to track the last-seen block offset and replay from it. The raw GraphQL response looks like this: That hex string is a serialized representation of your contract's ledger state. It's not JSON. It's not a human-readable format. It's a byte sequence encoded in Midnight's internal state format, and you need the contract's own TypeScript types to interpret it. Your Compact compiler generates a TypeScript module that includes a Ledger namespace with a State type and a serialize/deserialize pair. Here's what the deserialization looks like for a simple counter contract: What does Counter.Ledger.State look like? For a counter contract that tracks a single count value and an owner address: The critical thing: count comes back as a bigint, not a number. Midnight's ledger arithmetic uses 64-bit integers internally. If you try to do state.count + 1 with a regular number, JavaScript will silently corrupt it for values above Number.MAX_SAFE_INTEGER. Always use BigInt arithmetic or convert explicitly when you know the range is safe: Here's a complete custom hook that combines the initial fetch and live subscription: No polling interval, no manual refresh button. The component stays in sync with the chain automatically. During development, the indexer's GraphiQL playground is your best friend. It's available at http://localhost:8088/api/v4/graphql in a browser — just navigate there and you'll get the interactive query editor. Run this query to verify the indexer can see your contract: If it returns null, the indexer hasn't seen your contract yet. Either the deployment transaction hasn't been finalized, or you're using a contract address from a previous run that no longer exists on this local chain. Re-deploy and use the new address. To test subscriptions in the playground, use the WebSocket endpoint in your browser's developer tools Network tab — filter by WS and you'll see the framed messages flowing when you trigger a contract call from another window. One more local-dev tip: the indexer doesn't persist state between restarts of the local Docker stack by default. If you docker compose down, your deployed contracts and their state history are gone. Either always re-deploy on startup (fine for dev) or mount a volume for the indexer's database. Not handling the union type. If you query contractAction without inline fragments and just access state directly, TypeScript will complain — and it's right. The response might be a ContractDeploy, ContractCall, or ContractUpdate. Always use fragments. Treating state as JSON. It's not. It's a hex-encoded binary blob. Passing it directly to JSON.parse() will throw. Always go through your contract's Ledger.State.deserialize(). Forgetting to dispose the WebSocket client. createClient opens a connection immediately. If you create one inside a React component without returning a cleanup function, you'll accumulate connections every time the component remounts. The useEffect cleanup in the hook above handles this. Using Number() on ledger integers. As mentioned: bigint arithmetic, or .toString() for display. Don't coerce to number unless you've validated the value fits safely. Indexer lag on startup. After starting the local stack, the indexer needs time to sync with the node. Queries against a freshly started indexer might return empty or stale results for the first 30–60 seconds. If you're hitting contractAction and getting null on a contract you just deployed, wait and retry rather than assuming the contract isn't there. Reading contract state from a Midnight frontend has two main paths: use indexerPublicDataProvider when you're inside the full Midnight.js provider stack (which handles everything for you), and use direct GraphQL with graphql-ws when you need more control or are building read-only tooling. The key insight is the state-then-subscribe pattern: fetch current state immediately over HTTP to avoid an empty initial render, then open a WebSocket subscription to push updates as transactions land. The subscription doesn't backfill — it only streams future actions. And the gotcha that trips everyone up: state is hex-encoded binary, not a readable value. Your contract's generated Ledger.State.deserialize() is the only correct way to turn it into something your component can display. Once you make that connection, building reactive Midnight UIs is straightforward. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

$ -weight: 500;">npm -weight: 500;">install @midnight-ntwrk/midnight-js-indexer-public-data-provider \ @midnight-ntwrk/compact-runtime \ graphql-ws -weight: 500;">npm -weight: 500;">install @midnight-ntwrk/midnight-js-indexer-public-data-provider \ @midnight-ntwrk/compact-runtime \ graphql-ws -weight: 500;">npm -weight: 500;">install @midnight-ntwrk/midnight-js-indexer-public-data-provider \ @midnight-ntwrk/compact-runtime \ graphql-ws // src/config.ts export const INDEXER_HTTP_URL = process.env.REACT_APP_INDEXER_URL ?? 'http://localhost:8088/api/v4/graphql'; export const INDEXER_WS_URL = process.env.REACT_APP_INDEXER_WS_URL ?? 'ws://localhost:8088/api/v4/graphql/ws'; // src/config.ts export const INDEXER_HTTP_URL = process.env.REACT_APP_INDEXER_URL ?? 'http://localhost:8088/api/v4/graphql'; export const INDEXER_WS_URL = process.env.REACT_APP_INDEXER_WS_URL ?? 'ws://localhost:8088/api/v4/graphql/ws'; // src/config.ts export const INDEXER_HTTP_URL = process.env.REACT_APP_INDEXER_URL ?? 'http://localhost:8088/api/v4/graphql'; export const INDEXER_WS_URL = process.env.REACT_APP_INDEXER_WS_URL ?? 'ws://localhost:8088/api/v4/graphql/ws'; import { createIndexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider'; import { INDEXER_WS_URL } from './config'; const publicDataProvider = createIndexerPublicDataProvider(INDEXER_WS_URL); import { createIndexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider'; import { INDEXER_WS_URL } from './config'; const publicDataProvider = createIndexerPublicDataProvider(INDEXER_WS_URL); import { createIndexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider'; import { INDEXER_WS_URL } from './config'; const publicDataProvider = createIndexerPublicDataProvider(INDEXER_WS_URL); import { Counter } from './contract/managed/counter/contract/index.cjs'; const providers: MidnightProviders<Counter.Ledger> = { publicDataProvider, // ...wallet, proofProvider, privateStateProvider }; const contractAPI = Counter.createContractAPI(contractAddress, providers); import { Counter } from './contract/managed/counter/contract/index.cjs'; const providers: MidnightProviders<Counter.Ledger> = { publicDataProvider, // ...wallet, proofProvider, privateStateProvider }; const contractAPI = Counter.createContractAPI(contractAddress, providers); import { Counter } from './contract/managed/counter/contract/index.cjs'; const providers: MidnightProviders<Counter.Ledger> = { publicDataProvider, // ...wallet, proofProvider, privateStateProvider }; const contractAPI = Counter.createContractAPI(contractAddress, providers); import { useEffect, useState } from 'react'; function useCounterState(contractAPI: ReturnType<typeof Counter.createContractAPI>) { const [state, setState] = useState<Counter.Ledger.State | null>(null); useEffect(() => { const sub = contractAPI.state.subscribe({ next: setState, error: console.error, }); return () => sub.unsubscribe(); }, [contractAPI]); return state; } import { useEffect, useState } from 'react'; function useCounterState(contractAPI: ReturnType<typeof Counter.createContractAPI>) { const [state, setState] = useState<Counter.Ledger.State | null>(null); useEffect(() => { const sub = contractAPI.state.subscribe({ next: setState, error: console.error, }); return () => sub.unsubscribe(); }, [contractAPI]); return state; } import { useEffect, useState } from 'react'; function useCounterState(contractAPI: ReturnType<typeof Counter.createContractAPI>) { const [state, setState] = useState<Counter.Ledger.State | null>(null); useEffect(() => { const sub = contractAPI.state.subscribe({ next: setState, error: console.error, }); return () => sub.unsubscribe(); }, [contractAPI]); return state; } query GetContractState($address: HexEncoded!) { contractAction(address: $address) { __typename ... on ContractDeploy { address state zswapState } ... on ContractCall { address state zswapState entryPoint unshieldedBalances { tokenType amount } } ... on ContractUpdate { address state zswapState } } } query GetContractState($address: HexEncoded!) { contractAction(address: $address) { __typename ... on ContractDeploy { address state zswapState } ... on ContractCall { address state zswapState entryPoint unshieldedBalances { tokenType amount } } ... on ContractUpdate { address state zswapState } } } query GetContractState($address: HexEncoded!) { contractAction(address: $address) { __typename ... on ContractDeploy { address state zswapState } ... on ContractCall { address state zswapState entryPoint unshieldedBalances { tokenType amount } } ... on ContractUpdate { address state zswapState } } } async function fetchContractState(address: string): Promise<string> { const query = ` query GetContractState($address: HexEncoded!) { contractAction(address: $address) { ... on ContractCall { state } ... on ContractDeploy { state } ... on ContractUpdate { state } } } `; const res = await fetch(INDEXER_HTTP_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables: { address } }), }); const { data } = await res.json(); return data.contractAction.state as string; // still hex at this point } async function fetchContractState(address: string): Promise<string> { const query = ` query GetContractState($address: HexEncoded!) { contractAction(address: $address) { ... on ContractCall { state } ... on ContractDeploy { state } ... on ContractUpdate { state } } } `; const res = await fetch(INDEXER_HTTP_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables: { address } }), }); const { data } = await res.json(); return data.contractAction.state as string; // still hex at this point } async function fetchContractState(address: string): Promise<string> { const query = ` query GetContractState($address: HexEncoded!) { contractAction(address: $address) { ... on ContractCall { state } ... on ContractDeploy { state } ... on ContractUpdate { state } } } `; const res = await fetch(INDEXER_HTTP_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables: { address } }), }); const { data } = await res.json(); return data.contractAction.state as string; // still hex at this point } subscription WatchContractActions($address: HexEncoded!) { contractActions(address: $address) { __typename ... on ContractCall { address state entryPoint unshieldedBalances { tokenType amount } } ... on ContractDeploy { address state } ... on ContractUpdate { address state } } } subscription WatchContractActions($address: HexEncoded!) { contractActions(address: $address) { __typename ... on ContractCall { address state entryPoint unshieldedBalances { tokenType amount } } ... on ContractDeploy { address state } ... on ContractUpdate { address state } } } subscription WatchContractActions($address: HexEncoded!) { contractActions(address: $address) { __typename ... on ContractCall { address state entryPoint unshieldedBalances { tokenType amount } } ... on ContractDeploy { address state } ... on ContractUpdate { address state } } } subscription WatchFromBlock($address: HexEncoded!, $height: Int!) { contractActions(address: $address, offset: { height: $height }) { ... on ContractCall { state entryPoint } } } subscription WatchFromBlock($address: HexEncoded!, $height: Int!) { contractActions(address: $address, offset: { height: $height }) { ... on ContractCall { state entryPoint } } } subscription WatchFromBlock($address: HexEncoded!, $height: Int!) { contractActions(address: $address, offset: { height: $height }) { ... on ContractCall { state entryPoint } } } async function setupStateStream( address: string, onState: (hexState: string) => void ) { // Get current state immediately const initial = await fetchContractState(address); onState(initial); // Then subscribe for future updates const client = createClient({ url: INDEXER_WS_URL }); const unsub = client.subscribe( { query: ` subscription ($address: HexEncoded!) { contractActions(address: $address) { ... on ContractCall { state } ... on ContractDeploy { state } ... on ContractUpdate { state } } } `, variables: { address }, }, { next: ({ data }) => onState(data.contractActions.state), error: console.error, complete: () => console.log('subscription closed'), } ); return unsub; // call this to cancel } async function setupStateStream( address: string, onState: (hexState: string) => void ) { // Get current state immediately const initial = await fetchContractState(address); onState(initial); // Then subscribe for future updates const client = createClient({ url: INDEXER_WS_URL }); const unsub = client.subscribe( { query: ` subscription ($address: HexEncoded!) { contractActions(address: $address) { ... on ContractCall { state } ... on ContractDeploy { state } ... on ContractUpdate { state } } } `, variables: { address }, }, { next: ({ data }) => onState(data.contractActions.state), error: console.error, complete: () => console.log('subscription closed'), } ); return unsub; // call this to cancel } async function setupStateStream( address: string, onState: (hexState: string) => void ) { // Get current state immediately const initial = await fetchContractState(address); onState(initial); // Then subscribe for future updates const client = createClient({ url: INDEXER_WS_URL }); const unsub = client.subscribe( { query: ` subscription ($address: HexEncoded!) { contractActions(address: $address) { ... on ContractCall { state } ... on ContractDeploy { state } ... on ContractUpdate { state } } } `, variables: { address }, }, { next: ({ data }) => onState(data.contractActions.state), error: console.error, complete: () => console.log('subscription closed'), } ); return unsub; // call this to cancel } import { createClient } from 'graphql-ws'; import { createClient } from 'graphql-ws'; import { createClient } from 'graphql-ws'; { "data": { "contractActions": { "state": "000000000000000a0000000000000000" } } } { "data": { "contractActions": { "state": "000000000000000a0000000000000000" } } } { "data": { "contractActions": { "state": "000000000000000a0000000000000000" } } } import { Counter } from './contract/managed/counter/contract/index.cjs'; function hexToBytes(hex: string): Uint8Array { const clean = hex.startsWith('0x') ? hex.slice(2) : hex; const bytes = new Uint8Array(clean.length / 2); for (let i = 0; i < bytes.length; i++) { bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); } return bytes; } function decodeState(hexState: string): Counter.Ledger.State { const bytes = hexToBytes(hexState); return Counter.Ledger.State.deserialize(bytes); } import { Counter } from './contract/managed/counter/contract/index.cjs'; function hexToBytes(hex: string): Uint8Array { const clean = hex.startsWith('0x') ? hex.slice(2) : hex; const bytes = new Uint8Array(clean.length / 2); for (let i = 0; i < bytes.length; i++) { bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); } return bytes; } function decodeState(hexState: string): Counter.Ledger.State { const bytes = hexToBytes(hexState); return Counter.Ledger.State.deserialize(bytes); } import { Counter } from './contract/managed/counter/contract/index.cjs'; function hexToBytes(hex: string): Uint8Array { const clean = hex.startsWith('0x') ? hex.slice(2) : hex; const bytes = new Uint8Array(clean.length / 2); for (let i = 0; i < bytes.length; i++) { bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); } return bytes; } function decodeState(hexState: string): Counter.Ledger.State { const bytes = hexToBytes(hexState); return Counter.Ledger.State.deserialize(bytes); } // Generated by Compact compiler — don't edit namespace Ledger { export type State = { count: bigint; owner: Uint8Array; // public key bytes }; export const State = { serialize: (state: State): Uint8Array => { /* ... */ }, deserialize: (bytes: Uint8Array): State => { /* ... */ }, }; } // Generated by Compact compiler — don't edit namespace Ledger { export type State = { count: bigint; owner: Uint8Array; // public key bytes }; export const State = { serialize: (state: State): Uint8Array => { /* ... */ }, deserialize: (bytes: Uint8Array): State => { /* ... */ }, }; } // Generated by Compact compiler — don't edit namespace Ledger { export type State = { count: bigint; owner: Uint8Array; // public key bytes }; export const State = { serialize: (state: State): Uint8Array => { /* ... */ }, deserialize: (bytes: Uint8Array): State => { /* ... */ }, }; } // Safe for display <span>{state.count.toString()}</span> // Safe math const next = state.count + 1n; // note the 'n' suffix // Unsafe — don't do this for contract values const display = Number(state.count); // corrupts above 2^53 // Safe for display <span>{state.count.toString()}</span> // Safe math const next = state.count + 1n; // note the 'n' suffix // Unsafe — don't do this for contract values const display = Number(state.count); // corrupts above 2^53 // Safe for display <span>{state.count.toString()}</span> // Safe math const next = state.count + 1n; // note the 'n' suffix // Unsafe — don't do this for contract values const display = Number(state.count); // corrupts above 2^53 import { useEffect, useRef, useState } from 'react'; import { createClient } from 'graphql-ws'; import { Counter } from './contract/managed/counter/contract/index.cjs'; import { INDEXER_HTTP_URL, INDEXER_WS_URL } from './config'; type ContractState = Counter.Ledger.State; function hexToBytes(hex: string): Uint8Array { const clean = hex.startsWith('0x') ? hex.slice(2) : hex; const bytes = new Uint8Array(clean.length / 2); for (let i = 0; i < bytes.length; i++) { bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); } return bytes; } const CONTRACT_STATE_SUBSCRIPTION = ` subscription WatchContract($address: HexEncoded!) { contractActions(address: $address) { ... on ContractCall { state } ... on ContractDeploy { state } ... on ContractUpdate { state } } } `; async function fetchCurrentState(address: string): Promise<ContractState> { const res = await fetch(INDEXER_HTTP_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: ` query ($address: HexEncoded!) { contractAction(address: $address) { ... on ContractCall { state } ... on ContractDeploy { state } ... on ContractUpdate { state } } } `, variables: { address }, }), }); const { data } = await res.json(); return Counter.Ledger.State.deserialize(hexToBytes(data.contractAction.state)); } export function useContractState(contractAddress: string) { const [state, setState] = useState<ContractState | null>(null); const [error, setError] = useState<Error | null>(null); const [loading, setLoading] = useState(true); const clientRef = useRef<ReturnType<typeof createClient> | null>(null); useEffect(() => { if (!contractAddress) return; let cancelled = false; // Fetch current state first fetchCurrentState(contractAddress) .then((s) => { if (!cancelled) { setState(s); setLoading(false); } }) .catch((e) => { if (!cancelled) { setError(e); setLoading(false); } }); // Then subscribe for updates const client = createClient({ url: INDEXER_WS_URL }); clientRef.current = client; const unsub = client.subscribe( { query: CONTRACT_STATE_SUBSCRIPTION, variables: { address: contractAddress }, }, { next: ({ data }) => { if (!cancelled) { const hexState = data?.contractActions?.state; if (hexState) { try { setState(Counter.Ledger.State.deserialize(hexToBytes(hexState))); } catch (e) { setError(e as Error); } } } }, error: (e) => { if (!cancelled) setError(e as Error); }, complete: () => {}, } ); return () => { cancelled = true; unsub(); client.dispose(); }; }, [contractAddress]); return { state, loading, error }; } import { useEffect, useRef, useState } from 'react'; import { createClient } from 'graphql-ws'; import { Counter } from './contract/managed/counter/contract/index.cjs'; import { INDEXER_HTTP_URL, INDEXER_WS_URL } from './config'; type ContractState = Counter.Ledger.State; function hexToBytes(hex: string): Uint8Array { const clean = hex.startsWith('0x') ? hex.slice(2) : hex; const bytes = new Uint8Array(clean.length / 2); for (let i = 0; i < bytes.length; i++) { bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); } return bytes; } const CONTRACT_STATE_SUBSCRIPTION = ` subscription WatchContract($address: HexEncoded!) { contractActions(address: $address) { ... on ContractCall { state } ... on ContractDeploy { state } ... on ContractUpdate { state } } } `; async function fetchCurrentState(address: string): Promise<ContractState> { const res = await fetch(INDEXER_HTTP_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: ` query ($address: HexEncoded!) { contractAction(address: $address) { ... on ContractCall { state } ... on ContractDeploy { state } ... on ContractUpdate { state } } } `, variables: { address }, }), }); const { data } = await res.json(); return Counter.Ledger.State.deserialize(hexToBytes(data.contractAction.state)); } export function useContractState(contractAddress: string) { const [state, setState] = useState<ContractState | null>(null); const [error, setError] = useState<Error | null>(null); const [loading, setLoading] = useState(true); const clientRef = useRef<ReturnType<typeof createClient> | null>(null); useEffect(() => { if (!contractAddress) return; let cancelled = false; // Fetch current state first fetchCurrentState(contractAddress) .then((s) => { if (!cancelled) { setState(s); setLoading(false); } }) .catch((e) => { if (!cancelled) { setError(e); setLoading(false); } }); // Then subscribe for updates const client = createClient({ url: INDEXER_WS_URL }); clientRef.current = client; const unsub = client.subscribe( { query: CONTRACT_STATE_SUBSCRIPTION, variables: { address: contractAddress }, }, { next: ({ data }) => { if (!cancelled) { const hexState = data?.contractActions?.state; if (hexState) { try { setState(Counter.Ledger.State.deserialize(hexToBytes(hexState))); } catch (e) { setError(e as Error); } } } }, error: (e) => { if (!cancelled) setError(e as Error); }, complete: () => {}, } ); return () => { cancelled = true; unsub(); client.dispose(); }; }, [contractAddress]); return { state, loading, error }; } import { useEffect, useRef, useState } from 'react'; import { createClient } from 'graphql-ws'; import { Counter } from './contract/managed/counter/contract/index.cjs'; import { INDEXER_HTTP_URL, INDEXER_WS_URL } from './config'; type ContractState = Counter.Ledger.State; function hexToBytes(hex: string): Uint8Array { const clean = hex.startsWith('0x') ? hex.slice(2) : hex; const bytes = new Uint8Array(clean.length / 2); for (let i = 0; i < bytes.length; i++) { bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); } return bytes; } const CONTRACT_STATE_SUBSCRIPTION = ` subscription WatchContract($address: HexEncoded!) { contractActions(address: $address) { ... on ContractCall { state } ... on ContractDeploy { state } ... on ContractUpdate { state } } } `; async function fetchCurrentState(address: string): Promise<ContractState> { const res = await fetch(INDEXER_HTTP_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: ` query ($address: HexEncoded!) { contractAction(address: $address) { ... on ContractCall { state } ... on ContractDeploy { state } ... on ContractUpdate { state } } } `, variables: { address }, }), }); const { data } = await res.json(); return Counter.Ledger.State.deserialize(hexToBytes(data.contractAction.state)); } export function useContractState(contractAddress: string) { const [state, setState] = useState<ContractState | null>(null); const [error, setError] = useState<Error | null>(null); const [loading, setLoading] = useState(true); const clientRef = useRef<ReturnType<typeof createClient> | null>(null); useEffect(() => { if (!contractAddress) return; let cancelled = false; // Fetch current state first fetchCurrentState(contractAddress) .then((s) => { if (!cancelled) { setState(s); setLoading(false); } }) .catch((e) => { if (!cancelled) { setError(e); setLoading(false); } }); // Then subscribe for updates const client = createClient({ url: INDEXER_WS_URL }); clientRef.current = client; const unsub = client.subscribe( { query: CONTRACT_STATE_SUBSCRIPTION, variables: { address: contractAddress }, }, { next: ({ data }) => { if (!cancelled) { const hexState = data?.contractActions?.state; if (hexState) { try { setState(Counter.Ledger.State.deserialize(hexToBytes(hexState))); } catch (e) { setError(e as Error); } } } }, error: (e) => { if (!cancelled) setError(e as Error); }, complete: () => {}, } ); return () => { cancelled = true; unsub(); client.dispose(); }; }, [contractAddress]); return { state, loading, error }; } import React from 'react'; import { useContractState } from './useContractState'; interface CounterDisplayProps { contractAddress: string; } export function CounterDisplay({ contractAddress }: CounterDisplayProps) { const { state, loading, error } = useContractState(contractAddress); if (loading) return <div className="counter">Loading...</div>; if (error) return <div className="counter error">Error: {error.message}</div>; if (!state) return null; return ( <div className="counter"> <h2>Counter</h2> <p className="count">{state.count.toString()}</p> <p className="owner"> Owner: {Buffer.from(state.owner).toString('hex').slice(0, 16)}... </p> </div> ); } import React from 'react'; import { useContractState } from './useContractState'; interface CounterDisplayProps { contractAddress: string; } export function CounterDisplay({ contractAddress }: CounterDisplayProps) { const { state, loading, error } = useContractState(contractAddress); if (loading) return <div className="counter">Loading...</div>; if (error) return <div className="counter error">Error: {error.message}</div>; if (!state) return null; return ( <div className="counter"> <h2>Counter</h2> <p className="count">{state.count.toString()}</p> <p className="owner"> Owner: {Buffer.from(state.owner).toString('hex').slice(0, 16)}... </p> </div> ); } import React from 'react'; import { useContractState } from './useContractState'; interface CounterDisplayProps { contractAddress: string; } export function CounterDisplay({ contractAddress }: CounterDisplayProps) { const { state, loading, error } = useContractState(contractAddress); if (loading) return <div className="counter">Loading...</div>; if (error) return <div className="counter error">Error: {error.message}</div>; if (!state) return null; return ( <div className="counter"> <h2>Counter</h2> <p className="count">{state.count.toString()}</p> <p className="owner"> Owner: {Buffer.from(state.owner).toString('hex').slice(0, 16)}... </p> </div> ); } query { contractAction(address: "YOUR_CONTRACT_ADDRESS_HERE") { __typename ... on ContractCall { state entryPoint } ... on ContractDeploy { state } } } query { contractAction(address: "YOUR_CONTRACT_ADDRESS_HERE") { __typename ... on ContractCall { state entryPoint } ... on ContractDeploy { state } } } query { contractAction(address: "YOUR_CONTRACT_ADDRESS_HERE") { __typename ... on ContractCall { state entryPoint } ... on ContractDeploy { state } } } - HTTP: http://<host>:<port>/api/v4/graphql — for one-shot queries - WebSocket: wss://<host>:<port>/api/v4/graphql/ws — for subscriptions