This guide covers building custom authentication UI using the Para client directly with custom React Query hooks. This approach provides maximum control over authentication flow, caching strategies, and error handling.
Prerequisites
You must have a Para account set up with authentication methods enabled in your Developer Portal. Install the Web SDK:
npm install @getpara/web-sdk @tanstack/react-query
Para Client Setup
First, create a Para client instance that you’ll use throughout your application:
import { ParaWeb, Environment } from "@getpara/web-sdk";
const PARA_API_KEY = process.env.NEXT_PUBLIC_PARA_API_KEY!;
export const para = new ParaWeb(Environment.BETA, PARA_API_KEY);
Direct Client Methods
Custom Hook Implementation Examples
Here are examples of how to create custom hooks using the Para client directly with React Query:
Authentication Hook
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { para } from '@/lib/para/client';
import type { AuthState } from '@getpara/web-sdk';
interface SignUpOrLoginParams {
email?: string;
phoneNumber?: string;
countryCode?: string;
}
export const useParaAuth = () => {
const queryClient = useQueryClient();
const signUpOrLoginMutation = useMutation({
mutationFn: async ({ email, phoneNumber, countryCode }: SignUpOrLoginParams) => {
if (email) {
return await para.signUpOrLogIn({ auth: { email } });
} else if (phoneNumber && countryCode) {
const phone = `+${countryCode}${phoneNumber}` as `+${number}`;
return await para.signUpOrLogIn({ auth: { phone } });
}
throw new Error('Either email or phone number is required');
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paraAccount'] });
},
});
const verifyAccountMutation = useMutation({
mutationFn: async ({ verificationCode }: { verificationCode: string }) => {
return await para.verifyNewAccount({ verificationCode });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paraAccount'] });
},
});
const waitForLoginMutation = useMutation({
mutationFn: async (params?: { isCanceled?: () => boolean }) => {
return await para.waitForLogin(params);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paraAccount'] });
},
});
const logoutMutation = useMutation({
mutationFn: async (params?: { clearPregenWallets?: boolean }) => {
return await para.logout(params);
},
onSuccess: () => {
queryClient.invalidateQueries();
},
});
return {
signUpOrLogin: signUpOrLoginMutation.mutate,
signUpOrLoginAsync: signUpOrLoginMutation.mutateAsync,
isSigningUpOrLoggingIn: signUpOrLoginMutation.isPending,
signUpOrLoginError: signUpOrLoginMutation.error,
verifyAccount: verifyAccountMutation.mutate,
verifyAccountAsync: verifyAccountMutation.mutateAsync,
isVerifyingAccount: verifyAccountMutation.isPending,
verifyAccountError: verifyAccountMutation.error,
waitForLogin: waitForLoginMutation.mutate,
waitForLoginAsync: waitForLoginMutation.mutateAsync,
isWaitingForLogin: waitForLoginMutation.isPending,
waitForLoginError: waitForLoginMutation.error,
logout: logoutMutation.mutate,
logoutAsync: logoutMutation.mutateAsync,
isLoggingOut: logoutMutation.isPending,
logoutError: logoutMutation.error,
};
};
Account State Hook
import { useQuery } from '@tanstack/react-query';
import { para } from '@/lib/para/client';
export const useParaAccount = () => {
const { data: isLoggedIn = false, isLoading: isCheckingLogin } = useQuery({
queryKey: ['paraAccount', 'isLoggedIn'],
queryFn: async () => {
return await para.isFullyLoggedIn();
},
refetchInterval: 2000,
});
const { data: wallets = {}, isLoading: isLoadingWallets } = useQuery({
queryKey: ['paraAccount', 'wallets'],
queryFn: async () => {
return para.getWallets();
},
enabled: isLoggedIn,
refetchInterval: 5000,
});
const walletsArray = Object.values(wallets);
const primaryWallet = walletsArray.find(wallet => wallet.type === 'EVM') || walletsArray[0];
return {
isConnected: isLoggedIn,
address: primaryWallet?.address,
isLoading: isCheckingLogin || isLoadingWallets,
wallets: walletsArray,
primaryWallet,
};
};
Wallet Management Hook
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { para } from '@/lib/para/client';
import type { TWalletType } from '@getpara/web-sdk';
interface CreateWalletParams {
type?: TWalletType;
skipDistribute?: boolean;
}
export const useParaWallet = () => {
const queryClient = useQueryClient();
const createWalletMutation = useMutation({
mutationFn: async ({ type = 'EVM', skipDistribute = false }: CreateWalletParams) => {
return await para.createWallet({ type, skipDistribute });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paraAccount', 'wallets'] });
},
});
return {
createWallet: createWalletMutation.mutate,
createWalletAsync: createWalletMutation.mutateAsync,
isCreatingWallet: createWalletMutation.isPending,
createWalletError: createWalletMutation.error,
createdWallet: createWalletMutation.data,
};
};
Message Signing Hook
import { useMutation } from '@tanstack/react-query';
import { para } from '@/lib/para/client';
interface SignMessageParams {
message: string;
walletId?: string;
timeoutMs?: number;
onPoll?: () => void;
onCancel?: () => void;
isCanceled?: () => boolean;
}
export const useParaSignMessage = () => {
const signMessageMutation = useMutation({
mutationFn: async ({
message,
walletId,
timeoutMs = 120000,
...pollParams
}: SignMessageParams) => {
let targetWalletId = walletId;
if (!targetWalletId) {
const wallets = para.getWallets();
const walletsArray = Object.values(wallets);
const primaryWallet = walletsArray.find(w => w.type === 'EVM') || walletsArray[0];
if (!primaryWallet) {
throw new Error('No wallet available for signing');
}
targetWalletId = primaryWallet.id;
}
const messageBase64 = btoa(message);
return await para.signMessage({
walletId: targetWalletId,
messageBase64,
timeoutMs,
...pollParams,
});
},
});
return {
signMessage: signMessageMutation.mutate,
signMessageAsync: signMessageMutation.mutateAsync,
isSigning: signMessageMutation.isPending,
signError: signMessageMutation.error,
signature: signMessageMutation.data,
};
};
Advanced Custom Hook Examples
OAuth Authentication Hook
import { useMutation } from '@tanstack/react-query';
import { para } from '@/lib/para/client';
export const useParaOAuth = () => {
const oauthMutation = useMutation({
mutationFn: async ({
method,
onOAuthUrl,
isCanceled,
onCancel,
onPoll
}: {
method: 'google' | 'apple' | 'facebook' | 'discord' | 'x';
onOAuthUrl: (url: string) => void;
isCanceled?: () => boolean;
onCancel?: () => void;
onPoll?: () => void;
}) => {
return await para.verifyOAuth({
method,
onOAuthUrl,
isCanceled,
onCancel,
onPoll,
});
},
});
return {
authenticateWithOAuth: oauthMutation.mutate,
authenticateWithOAuthAsync: oauthMutation.mutateAsync,
isAuthenticating: oauthMutation.isPending,
authError: oauthMutation.error,
authResult: oauthMutation.data,
};
};
Session Management Hook
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { para } from '@/lib/para/client';
export const useParaSession = () => {
const queryClient = useQueryClient();
const { data: sessionInfo, isLoading: isLoadingSession } = useQuery({
queryKey: ['paraSession'],
queryFn: async () => {
const isLoggedIn = await para.isFullyLoggedIn();
if (!isLoggedIn) return null;
return {
isActive: true,
wallets: para.getWallets(),
timestamp: Date.now(),
};
},
refetchInterval: 30000, // Check session every 30 seconds
});
const refreshSessionMutation = useMutation({
mutationFn: async () => {
return await para.refreshSession?.() || true;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paraSession'] });
},
});
const keepAlive = () => {
if (sessionInfo?.isActive) {
refreshSessionMutation.mutate();
}
};
return {
sessionInfo,
isLoadingSession,
isSessionActive: !!sessionInfo?.isActive,
refreshSession: refreshSessionMutation.mutate,
isRefreshing: refreshSessionMutation.isPending,
keepAlive,
};
};
Error Handling Hook
import { useCallback } from 'react';
import { toast } from 'react-hot-toast';
export const useParaErrorHandler = () => {
const handleError = useCallback((error: Error, context?: string) => {
console.error(`Para Error ${context ? `(${context})` : ''}:`, error);
// Handle specific error types
if (error.message.includes('network')) {
toast.error('Network error. Please check your connection.');
} else if (error.message.includes('unauthorized')) {
toast.error('Authentication expired. Please log in again.');
} else if (error.message.includes('cancelled')) {
toast.error('Operation was cancelled.');
} else {
toast.error(error.message || 'An unexpected error occurred.');
}
}, []);
const withErrorHandling = useCallback(<T extends any[], R>(
fn: (...args: T) => Promise<R>,
context?: string
) => {
return async (...args: T): Promise<R | undefined> => {
try {
return await fn(...args);
} catch (error) {
handleError(error as Error, context);
return undefined;
}
};
}, [handleError]);
return { handleError, withErrorHandling };
};
Benefits of Direct Client Approach
- Full Control: Complete control over state management and caching strategies
- Custom Return Interfaces: Design return interfaces that match your application’s needs
- Enhanced Error Handling: Add custom error processing and retry logic
- Flexible Polling: Implement custom polling behavior for long-running operations
- Performance Optimization: Optimize queries and mutations for your specific use case
Trade-offs
- More Boilerplate: Requires more code to implement the same functionality
- Manual Cache Management: You need to handle cache invalidation manually
- Type Safety: May need to define custom TypeScript interfaces
- Maintenance: More code to maintain and update as the SDK evolves
Best Practices
- Centralized Client: Create a single Para client instance and share it across your app
- Query Key Consistency: Use consistent query key patterns for cache management
- Error Boundaries: Implement error boundaries to catch and handle errors gracefully
- Loading States: Provide clear loading states for better user experience
- Cache Invalidation: Invalidate relevant queries after mutations
- Type Safety: Define proper TypeScript interfaces for your custom hooks
Next Steps