Integrating Midnight with Wallets and React: A Complete Frontend Guide
Building privacy-preserving dApps on Midnight means mastering one critical integration point: the dApp Connector API. This is how your React frontend talks to the Midnight Lace wallet (and eventually other Midnight-compatible wallets). Get this right and everything else — reading contract state, submitting transactions, reacting to wallet events — follows a clean, predictable pattern.
This guide covers the full TypeScript/React integration layer. No Compact contract code. No backend. Just the browser-side plumbing that turns a static React app into a live Midnight dApp.
Prerequisites
You'll need:
- Node.js v22+
- The Midnight Lace wallet browser extension installed and configured for Preprod
- A React project (Vite works well)
- Basic familiarity with TypeScript and React hooks
Install the dApp Connector API package:
npm install @midnight-ntwrk/dapp-connector-api
How the dApp Connector API Works
When Midnight Lace is installed, it injects a window.midnight object into the browser. Each wallet registers under its own key — Lace uses window.midnight.mnLace. Before your app does anything wallet-related, you need to check that this object exists.
The API has two phases:
-
Pre-connection (
InitialAPI): read-only wallet metadata (name, icon, version). You can't do anything meaningful yet. -
Post-connection (
ConnectedAPI): full access — addresses, balances, transaction submission, ZK proof delegation.
import type { InitialAPI, ConnectedAPI } from '@midnight-ntwrk/dapp-connector-api';
// Pre-connection: just metadata
const wallet: InitialAPI = window.midnight?.mnLace;
console.log(wallet.name); // "Midnight Lace"
console.log(wallet.apiVersion); // e.g. "1.0.0"
// Post-connection: full API
const api: ConnectedAPI = await wallet.connect('preprod');
The connect() call triggers a permission prompt in the wallet extension. Once the user approves, you have the ConnectedAPI and can start making requests.
Setting Up TypeScript Types
Before writing hooks and components, define a clean type boundary for your wallet state. This keeps your components from coupling directly to the Connector API types.
// types/wallet.ts
export type NetworkId = 'undeployed' | 'preview' | 'preprod';
export interface WalletState {
status: 'disconnected' | 'connecting' | 'connected' | 'error';
shieldedAddress: string | null;
unshieldedAddress: string | null;
error: string | null;
}
export interface WalletContextValue {
walletState: WalletState;
connect: () => Promise<void>;
disconnect: () => void;
api: import('@midnight-ntwrk/dapp-connector-api').ConnectedAPI | null;
}
The api field is what you'll thread through to any component that needs to submit transactions. Everything else — address display, connection status, error messages — comes from WalletState.
The useWallet Hook
Centralize all wallet logic in a single hook. Components should not call window.midnight directly.
// hooks/useWallet.ts
import { useState, useCallback, useRef } from 'react';
import type { ConnectedAPI } from '@midnight-ntwrk/dapp-connector-api';
import type { WalletState, WalletContextValue, NetworkId } from '../types/wallet';
const NETWORK_ID: NetworkId = 'preprod';
const initialState: WalletState = {
status: 'disconnected',
shieldedAddress: null,
unshieldedAddress: null,
error: null,
};
export function useWallet(): WalletContextValue {
const [walletState, setWalletState] = useState<WalletState>(initialState);
const apiRef = useRef<ConnectedAPI | null>(null);
const connect = useCallback(async () => {
setWalletState(prev => ({ ...prev, status: 'connecting', error: null }));
try {
// Check extension is present
const midnight = (window as any).midnight;
if (!midnight?.mnLace) {
throw new Error('Midnight Lace wallet not found. Install the extension and refresh.');
}
const wallet = midnight.mnLace;
// Connect and get the full API
const api = await wallet.connect(NETWORK_ID);
apiRef.current = api;
// Fetch addresses
const { shieldedAddress } = await api.getShieldedAddresses();
const unshieldedAddress = await api.getUnshieldedAddress();
const status = await api.getConnectionStatus();
if (status !== 'connected') {
throw new Error(`Unexpected connection status: ${status}`);
}
setWalletState({
status: 'connected',
shieldedAddress,
unshieldedAddress,
error: null,
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setWalletState(prev => ({ ...prev, status: 'error', error: message }));
apiRef.current = null;
}
}, []);
const disconnect = useCallback(() => {
apiRef.current = null;
setWalletState(initialState);
}, []);
return {
walletState,
connect,
disconnect,
api: apiRef.current,
};
}
A few design choices worth noting:
-
apiRefstores theConnectedAPIwithout triggering re-renders on every update. The wallet state object drives rendering; the API reference is imperative. - We verify
getConnectionStatus()after connecting. This catches the edge case where the wallet connects but isn't fully synced. - Error messages are user-readable, not raw exception stacks.
The WalletProvider Context
Wrap your app in a context so any component can access wallet state without prop drilling.
// context/WalletContext.tsx
import { createContext, useContext, type ReactNode } from 'react';
import { useWallet } from '../hooks/useWallet';
import type { WalletContextValue } from '../types/wallet';
const WalletContext = createContext<WalletContextValue | null>(null);
export function WalletProvider({ children }: { children: ReactNode }) {
const wallet = useWallet();
return (
<WalletContext.Provider value={wallet}>
{children}
</WalletContext.Provider>
);
}
export function useWalletContext(): WalletContextValue {
const ctx = useContext(WalletContext);
if (!ctx) throw new Error('useWalletContext must be used inside WalletProvider');
return ctx;
}
Wrap your app root:
// main.tsx
import { WalletProvider } from './context/WalletContext';
ReactDOM.createRoot(document.getElementById('root')!).render(
<WalletProvider>
<App />
</WalletProvider>
);
The WalletCard Component
Now that the logic lives in the hook, the component is purely presentational.
// components/WalletCard.tsx
import { useWalletContext } from '../context/WalletContext';
function truncate(addr: string): string {
return `${addr.slice(0, 8)}...${addr.slice(-6)}`;
}
export function WalletCard() {
const { walletState, connect, disconnect } = useWalletContext();
const { status, shieldedAddress, unshieldedAddress, error } = walletState;
if (status === 'disconnected' || status === 'error') {
return (
<div className="wallet-card">
{error && <p className="error">{error}</p>}
<button onClick={connect} disabled={status === 'connecting'}>
Connect Midnight Lace
</button>
</div>
);
}
if (status === 'connecting') {
return <div className="wallet-card">Connecting...</div>;
}
return (
<div className="wallet-card">
<p>Connected</p>
{shieldedAddress && (
<p>
<strong>Shielded:</strong> {truncate(shieldedAddress)}
</p>
)}
{unshieldedAddress && (
<p>
<strong>Unshielded:</strong> {truncate(unshieldedAddress)}
</p>
)}
<button onClick={disconnect}>Disconnect</button>
</div>
);
}
Reading Contract State from React
Once the wallet is connected, you have access to getConfiguration() which returns service URIs — the indexer, prover server, and Substrate node. You need these to query contract state.
Reading contract state doesn't require the wallet directly. You query the public indexer. But the wallet's getConfiguration() gives you the indexer URI so you're pointing at the right network.
// hooks/useContractState.ts
import { useState, useEffect } from 'react';
import { useWalletContext } from '../context/WalletContext';
interface ContractConfig {
indexerUri: string;
networkId: string;
}
async function fetchContractConfig(api: any): Promise<ContractConfig> {
const config = await api.getConfiguration();
return {
indexerUri: config.indexerUri,
networkId: config.networkId,
};
}
export function useContractState<T>(
contractAddress: string | null,
deserialize: (data: Uint8Array) => T,
) {
const { api } = useWalletContext();
const [state, setState] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!api || !contractAddress) return;
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
try {
// getConfiguration gives you the indexer URI
const config = await fetchContractConfig(api);
// Query the indexer directly for contract state
const response = await fetch(
`${config.indexerUri}/api/v1/contracts/${contractAddress}/state`
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const { data } = await response.json();
const bytes = Uint8Array.from(Buffer.from(data, 'hex'));
if (!cancelled) {
setState(deserialize(bytes));
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load state');
}
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => { cancelled = true; };
}, [api, contractAddress, deserialize]);
return { state, loading, error };
}
In practice, the deserialize function comes from your compiled Compact contract's TypeScript bindings. The Compact compiler generates type-safe deserializers — you pass them directly to useContractState.
Sending Transactions from the Frontend
This is where things get interesting. Midnight transactions go through a multi-step process:
- Your app builds an unsealed transaction (the contract call intent)
- The wallet balances it (adds DUST fees, selects unspent outputs)
- The prover server generates a ZK proof
- The wallet submits the proven transaction to the network
The Connector API abstracts steps 2–4 behind balanceUnsealedTransaction() and submitTransaction(). You rarely call the prover directly — you can delegate via getProvingProvider().
Here's a transaction submission hook:
// hooks/useTransaction.ts
import { useState, useCallback } from 'react';
import { useWalletContext } from '../context/WalletContext';
type TxStatus = 'idle' | 'pending' | 'success' | 'error';
export function useTransaction() {
const { api } = useWalletContext();
const [status, setStatus] = useState<TxStatus>('idle');
const [txHash, setTxHash] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const submit = useCallback(
async (unsealedTx: any) => {
if (!api) {
setError('Wallet not connected');
return;
}
setStatus('pending');
setError(null);
setTxHash(null);
try {
// Wallet balances the transaction (adds fees, selects inputs)
const balancedTx = await api.balanceUnsealedTransaction(unsealedTx);
// Submit the balanced + proven transaction
const result = await api.submitTransaction(balancedTx);
setTxHash(result.txHash ?? null);
setStatus('success');
} catch (err) {
setError(err instanceof Error ? err.message : 'Transaction failed');
setStatus('error');
}
},
[api],
);
const reset = useCallback(() => {
setStatus('idle');
setTxHash(null);
setError(null);
}, []);
return { submit, status, txHash, error, reset };
}
Usage in a component:
function IncrementButton({ contract }: { contract: DeployedContract }) {
const { submit, status, txHash, error } = useTransaction();
const handleClick = async () => {
// contract.callTx.increment() returns an unsealed tx from Midnight.js
const unsealedTx = await contract.callTx.increment();
await submit(unsealedTx);
};
return (
<div>
<button onClick={handleClick} disabled={status === 'pending'}>
{status === 'pending' ? 'Submitting...' : 'Increment'}
</button>
{status === 'success' && <p>Done! Tx: {txHash}</p>}
{error && <p className="error">{error}</p>}
</div>
);
}
Handling Wallet Events and State Changes
The Midnight Lace wallet state is an RxJS Observable — it emits new state whenever something changes (sync status, balance updates, network switches). In a React app, you subscribe to this in a useEffect.
The pattern mirrors React's own reactivity model: subscribe on mount, unsubscribe on cleanup.
// hooks/useWalletSync.ts
import { useState, useEffect } from 'react';
interface WalletSyncState {
isSynced: boolean;
syncProgress: number | null;
}
export function useWalletSync(wallet: any | null) {
const [syncState, setSyncState] = useState<WalletSyncState>({
isSynced: false,
syncProgress: null,
});
useEffect(() => {
if (!wallet) return;
// wallet.state() returns an Observable
const subscription = wallet
.state()
.pipe(
// Throttle to avoid flooding React with updates
throttleTime(2_000),
)
.subscribe((state: any) => {
setSyncState({
isSynced: state.isSynced,
syncProgress: state.syncProgress ?? null,
});
});
return () => subscription.unsubscribe();
}, [wallet]);
return syncState;
}
If you need RxJS, install it:
npm install rxjs
And import throttleTime:
import { throttleTime } from 'rxjs/operators';
For contract state that should update in real-time (e.g., showing the current counter value), you can poll or — if the indexer provides a WebSocket — subscribe to state change events. Polling on a short interval (5–10 seconds) is the pragmatic choice for most dApps:
// hooks/usePolledContractState.ts
import { useState, useEffect, useRef } from 'react';
export function usePolledContractState<T>(
fetchState: () => Promise<T | null>,
intervalMs = 8_000,
) {
const [state, setState] = useState<T | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
useEffect(() => {
let mounted = true;
async function tick() {
try {
const s = await fetchState();
if (mounted) {
setState(s);
setLastUpdated(new Date());
}
} catch {
// silently fail — stale state is better than a broken UI
}
}
tick(); // immediate first load
const id = setInterval(tick, intervalMs);
return () => {
mounted = false;
clearInterval(id);
};
}, [fetchState, intervalMs]);
return { state, lastUpdated };
}
Detecting Wallet Extension Availability
Users who don't have the extension installed need a graceful fallback, not a cryptic JavaScript error.
// utils/wallet.ts
export function isMidnightAvailable(): boolean {
return typeof window !== 'undefined' && !!(window as any).midnight?.mnLace;
}
export function getMidnightWallet() {
if (!isMidnightAvailable()) {
throw new Error(
'Midnight Lace wallet not detected. ' +
'Install it from the extension store and refresh the page.'
);
}
return (window as any).midnight.mnLace;
}
Check this before rendering your connect button:
function ConnectSection() {
const available = isMidnightAvailable();
if (!available) {
return (
<div>
<p>Midnight Lace wallet is not installed.</p>
<a href="https://docs.midnight.network" target="_blank" rel="noreferrer">
Install the extension →
</a>
</div>
);
}
return <WalletCard />;
}
Putting It All Together
Here's how the pieces wire up in a full app:
App
├── WalletProvider ← wallet state, connect/disconnect
│ ├── ConnectSection ← extension check, WalletCard
│ ├── ContractPanel ← uses useContractState (read)
│ │ └── IncrementButton ← uses useTransaction (write)
│ └── SyncIndicator ← uses useWalletSync (events)
Each layer has one job. WalletProvider owns connection lifecycle. Components read from context and call hook-provided functions. No component ever touches window.midnight directly.
Common Pitfalls
window.midnight is undefined on first render. Extension injection is asynchronous. Check availability inside an event handler or useEffect, not at module level or in the render path.
Network mismatch errors. Your wallet is configured for one network (e.g. Preprod), but your app passes a different networkId to connect(). Always source the network ID from a single config constant.
Stale ConnectedAPI after network switch. If the user switches networks in the wallet, your existing ConnectedAPI reference is stale. The cleanest fix is to call disconnect() and re-connect programmatically when a network change event fires.
Proof server not running. ZK proof generation requires the prover server (typically Docker). If it's not running, balanceUnsealedTransaction() will hang or time out. Show a clear error, not a spinner that runs forever.
What's Next
This guide covered the full browser-side integration layer: extension detection, wallet connection, address fetching, contract state queries, transaction submission, and wallet event subscriptions. The patterns here — context for shared state, hooks for async logic, observables for reactive updates — scale to any Midnight dApp regardless of what your Compact contracts do.
For the full-stack picture — adding a backend for off-chain data and wiring up contract deployment — see the companion guide: "Building a Full-Stack Midnight dApp."
Top comments (0)