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.

For simplified state management with pre-built hooks, see the React Hooks Approach.

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

  1. Full Control: Complete control over state management and caching strategies
  2. Custom Return Interfaces: Design return interfaces that match your application’s needs
  3. Enhanced Error Handling: Add custom error processing and retry logic
  4. Flexible Polling: Implement custom polling behavior for long-running operations
  5. Performance Optimization: Optimize queries and mutations for your specific use case

Trade-offs

  1. More Boilerplate: Requires more code to implement the same functionality
  2. Manual Cache Management: You need to handle cache invalidation manually
  3. Type Safety: May need to define custom TypeScript interfaces
  4. Maintenance: More code to maintain and update as the SDK evolves

Best Practices

  1. Centralized Client: Create a single Para client instance and share it across your app
  2. Query Key Consistency: Use consistent query key patterns for cache management
  3. Error Boundaries: Implement error boundaries to catch and handle errors gracefully
  4. Loading States: Provide clear loading states for better user experience
  5. Cache Invalidation: Invalidate relevant queries after mutations
  6. Type Safety: Define proper TypeScript interfaces for your custom hooks

Next Steps