UTA DevHub
Guides

Multi-Account Support

Managing multiple user sessions within the application

GUIDE-09: Multi-Account Support Implementation Guide

Overview

This guide provides patterns for allowing users to be logged into multiple accounts simultaneously within the application and switch between them easily.

Key components of this approach:

  • Account Interface: Defines the structure for storing individual account details, including tokens and user information.
  • useAccountsStore (Zustand): A dedicated Zustand store manages the list of all logged-in accounts and keeps track of the currently active account ID.
  • Secure Persistence: The useAccountsStore (containing sensitive tokens) must be persisted securely using Zustand's persist middleware configured with secureStorage.
  • Modified Auth Flow: The standard login/register success handlers (useAuth or useSocialLoginMutation) are updated to add or update accounts in the accountsSlice.
  • switchAccount Logic: A mechanism to change the active account, involving updating Zustand state, the API client header, and potentially fetching data for the newly active account.

When To Use

Apply these patterns when your application needs to support users logged into multiple accounts concurrently (e.g., personal and work accounts) and allow seamless switching without repeated logins.

Prerequisites

  1. Core Auth Flow: The standard authentication flow (GUIDE-05) with Zustand (useAuthStore) and TanStack Query mutations must be implemented.
  2. Zustand Persist Middleware: Zustand's persist middleware should be configured with appropriate storage (GUIDE-02).
  3. Secure Storage Module: secureStorage module (DOC-04) must be available.
  4. Persist Configuration: Zustand's persist middleware must be configured with secureStorage to securely store the accounts data containing tokens.

Implementation Patterns

1. Account Interface

Define a type for storing account information.

// src/features/accounts/types/index.ts (or a shared location)
import { User } from '@/core/api/types/userTypes'; // Adjust path
 
export interface Account {
  id: string; // Unique identifier for the account entry (e.g., user ID from backend)
  email: string; // User email for identification
  user: User; // Snapshot of user details
  token: string; // Access token for this account
  refreshToken: string; // Refresh token for this account
  lastUsed: Date; // Timestamp of last usage
  accountType: string; // Type of account (e.g., personal, work)
}

2. Ensuring Secure Storage for Zustand Persist

Configure Zustand's persist middleware to use expo-secure-store through our secureStorage module.

// src/core/store/createSecureStore.ts
import { StateStorage } from 'zustand/middleware';
import { secureStorage } from '@/core/security/storage/secureStorage'; // Adjust import
 
// Create a storage adapter that uses secureStorage
export const createSecureStorage = (prefix: string): StateStorage => ({
  getItem: async (key) => {
    const value = await secureStorage.getItem(`${prefix}.${key}`);
    return value;
  },
  setItem: async (key, value) => {
    await secureStorage.setItem(`${prefix}.${key}`, value);
  },
  removeItem: async (key) => {
    await secureStorage.removeItem(`${prefix}.${key}`);
  },
});

3. Accounts Store (Zustand)

Create a Zustand store to manage the list of accounts and the active ID.

// src/features/accounts/state/useAccountsStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { createSecureStorage } from '@/core/store/createSecureStorage'; // Adjust path
import type { Account } from '../types'; // Adjust path
import type { User } from '@/core/api/types/userTypes'; // Import User type
 
interface AccountsState {
  accounts: Record<string, Account>; // Object storing accounts by ID
  activeAccountId: string | null; // Currently active account ID
  
  // Actions
  addAccount: (account: Account) => void;
  removeAccount: (accountId: string) => void;
  setActiveAccount: (accountId: string) => void;
  updateAccountUser: (params: { accountId: string; user: User }) => void;
  clearAccounts: () => void;
}
 
// Create secure storage for accounts
const secureStorage = createSecureStorage('accounts');
 
// Create the Zustand store with persistence
export const useAccountsStore = create<AccountsState>(
  persist(
    (set) => ({
      // Initial state
      accounts: {},
      activeAccountId: null,
      
      // Actions
      addAccount: (account) => set((state) => ({
        accounts: { ...state.accounts, [account.id]: account }
      })),
      
      removeAccount: (accountId) => set((state) => {
        const newAccounts = { ...state.accounts };
        delete newAccounts[accountId];
        
        return {
          accounts: newAccounts,
          // If we removed the active account, set activeAccountId to null
          activeAccountId: state.activeAccountId === accountId ? null : state.activeAccountId
        };
      }),
      
      setActiveAccount: (accountId) => set({
        activeAccountId: accountId
      }),
      
      updateAccountUser: ({ accountId, user }) => set((state) => {
        // Only update if the account exists
        if (!state.accounts[accountId]) return state;
        
        return {
          accounts: {
            ...state.accounts,
            [accountId]: {
              ...state.accounts[accountId],
              user
            }
          }
        };
      }),
      
      clearAccounts: () => set({
        accounts: {},
        activeAccountId: null
      })
    }),
    {
      name: 'accounts-storage',
      storage: createJSONStorage(() => secureStorage),
    }
  )
);
 
// Helper selectors for common data access patterns
export const useAllAccounts = () => useAccountsStore((state) => Object.values(state.accounts));
export const useAccountById = (id: string) => useAccountsStore((state) => state.accounts[id]);
export const useActiveAccountId = () => useAccountsStore((state) => state.activeAccountId);
export const useActiveAccount = () => {
  const { accounts, activeAccountId } = useAccountsStore();
  return activeAccountId ? accounts[activeAccountId] : null;
};

4. No Additional Configuration Needed

With Zustand's persist middleware already configured in our useAccountsStore, no additional persistence setup is required. The middleware handles:

  • Automatic persistence of state changes
  • Secure storage through our custom storage adapter
  • Hydration on app startup

This approach simplifies our implementation compared to Redux, as we don't need to set up a separate persistence layer.

5. Modify Auth Success Handling

Update the login/register success handler to store account info in both contexts.

// src/features/auth/hooks/useAuth.ts
import { /* ... existing imports ... */ } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { nanoid } from 'nanoid'; // Or any other UUID generator
import { authService } from '@/core/api/services/authService';
import { apiClient } from '@/core/api/apiClient';
import { secureStorage } from '@/core/security/storage/secureStorage';
import { useAuthStore } from '@/features/auth/state/useAuthStore'; // Import the auth store
import { useAccountsStore } from '@/features/accounts/state/useAccountsStore';
 
export const useLoginMutation = () => {
  const queryClient = useQueryClient();
  
  // Get actions from Zustand stores
  const setTokens = useAuthStore((state) => state.setTokens);
  const setUser = useAuthStore((state) => state.setUser);
  const addAccount = useAccountsStore((state) => state.addAccount);
  const setActiveAccount = useAccountsStore((state) => state.setActiveAccount);
 
  return useMutation({
    mutationFn: authService.login,
    onSuccess: async (data) => {
      // Create a unique account ID for this login
      // In practice, you'd usually use a user ID from the backend
      const accountId = data.user.id || nanoid();
 
      // Create the account object for multi-account storage
      const newAccount = {
        id: accountId,
        user: data.user,
        token: data.token,
        refreshToken: data.refreshToken,
        lastActive: new Date().toISOString(),
      };
 
      // Add the account to accounts store
      addAccount(newAccount);
 
      // Set this account as active
      setActiveAccount(newAccount.id);
 
      // ALSO update the standard auth state (to drive the current UI view)
      setTokens({ token: data.token, refreshToken: data.refreshToken });
      setUser(data.user);
 
      // Configure API client with the token
      apiClient.setAuthHeader(data.token);
 
      // Store tokens securely - optional, as the accounts are already persisted securely
      await secureStorage.setItem('auth_token', data.token);
      await secureStorage.setItem('refresh_token', data.refreshToken);
 
      // Pre-populate query cache
      queryClient.setQueryData(['user', 'current'], data.user);
    },
    // ... other mutation options
  });
};

6. Implement Account Switching Hook (useSwitchAccount)

Create a dedicated hook for account switching.

// src/features/accounts/hooks/useSwitchAccount.ts
import { useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/core/api/apiClient';
import { useAuthStore } from '@/features/auth/state/useAuthStore';
import { useAccountsStore, useActiveAccountId, useAccountById } from '@/features/accounts/state/useAccountsStore';
import { userService } from '@/core/api/services/userService';
 
export const useSwitchAccount = () => {
  const queryClient = useQueryClient();
  const activeAccountId = useActiveAccountId();
  
  // Get actions from Zustand stores
  const clearAuthState = useAuthStore((state) => state.clearAuthState);
  const setTokens = useAuthStore((state) => state.setTokens);
  const setUser = useAuthStore((state) => state.setUser);
  const setActiveAccount = useAccountsStore((state) => state.setActiveAccount);
  const updateAccountUser = useAccountsStore((state) => state.updateAccountUser);
 
  const switchToAccount = useCallback(async (targetAccountId: string) => {
    // Safety check: do nothing if target is already active
    if (targetAccountId === activeAccountId) return;
 
    // 1. Get target account details from Zustand store
    const targetAccount = useAccountById(targetAccountId);
    if (!targetAccount) {
      console.error(`Account with ID ${targetAccountId} not found`);
      return;
    }
 
    // 2. Clear current *active* auth state
    clearAuthState();
 
    // 3. Set API client header with the target account's token
    apiClient.setAuthHeader(targetAccount.token);
 
    // 4. Set the active auth state with the target account
    setTokens({ token: targetAccount.token, refreshToken: targetAccount.refreshToken });
    setUser(targetAccount.user);
 
    // 5. Update active account ID in the accounts store
    setActiveAccount(targetAccountId);
 
    try {
      // 6. Optional: Fetch fresh user data to ensure it's up-to-date
      // (useful if account was added some time ago)
      const userData = await userService.getCurrentUser();
      setUser(userData);
      updateAccountUser({ accountId: targetAccountId, user: userData });
 
    } catch (error) {
      console.error('Error refreshing user data when switching accounts:', error);
      // Don't block account switching on failed refresh
    }
 
  }, [queryClient, activeAccountId, clearAuthState, setTokens, setUser, setActiveAccount, updateAccountUser]);
 
  return { switchToAccount };
};

7. Account Removal/Logout

Implement an extended logout to handle account removal:

  1. Implement removeCurrentAccount in useAuth that:
    1. Gets the current activeAccountId from the accounts store.
    2. Call removeAccount(activeAccountId) from the accounts store actions.
    3. If other accounts remain, call switchToAccount with the ID of another account.
    4. If no accounts remain, call the main logout() function.
  2. Implement logoutAll that:
    1. Call clearAccounts() from the accounts store actions.
    2. Call the standard logout flow to clear everything else.

8. Account Selection UI

Create a profile selector component that displays all available accounts.

// src/features/accounts/components/AccountSelector.tsx
import React from 'react';
import { FlatList, Pressable, Text, Image, StyleSheet } from 'react-native';
import { useAllAccounts, useActiveAccountId } from '../state/useAccountsStore';
import { useSwitchAccount } from '../hooks/useSwitchAccount';
 
const AccountSelector = () => {
  const accounts = useAllAccounts();
  const activeAccountId = useActiveAccountId();
  const { switchToAccount } = useSwitchAccount();
 
  return (
    <FlatList
      data={accounts}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => (
        <Pressable 
          style={[styles.accountItem, activeAccountId === item.id && styles.activeItem]}
          onPress={() => switchToAccount(item.id)}
        >
          <Image source={{ uri: item.user.avatarUrl }} style={styles.avatar} />
          <Text style={styles.userName}>{item.user.name}</Text>
          {activeAccountId === item.id && (
            <Text style={styles.activeIndicator}>Active</Text>
          )}
        </Pressable>
      )}
    />
  );
};
 
const styles = StyleSheet.create({
  accountItem: {
    paddingVertical: 12,
    paddingHorizontal: 10,
    borderBottomWidth: 1,
    borderBottomColor: '#eee',
    flexDirection: 'row',
    alignItems: 'center',
  },
  activeAccountItem: { backgroundColor: '#e0f0ff' }, // Light blue for active
  switchingAccountItem: { opacity: 0.6 }, // Dim slightly while switching
  accountInfo: { flex: 1, marginLeft: 10 },
  accountName: { fontWeight: '500', fontSize: 15 },
  accountEmail: { color: '#555', fontSize: 12 },
  activeMarker: { color: '#007AFF', fontSize: 20, fontWeight: 'bold' },
  addButton: {
      marginTop: 15,
      paddingVertical: 12,
      alignItems: 'center',
      borderWidth: 1,
      borderColor: '#007AFF',
      borderRadius: 6,
  },
  addButtonText: {
      color: '#007AFF',
      fontWeight: '500',
      fontSize: 15,
  },
});

Considerations & Challenges

  • (Be Aware ❗) Secure Persistence: Ensuring the accountsSlice containing multiple tokens is stored only using the securePersistStorage engine is critical. Misconfiguration (e.g., accidentally persisting it via AsyncStorage) could expose all stored tokens.
  • (Be Aware ❗) Token Refresh: The standard API interceptor (GUIDE-05) typically refreshes the token for the currently active session only. Implementing background refresh for inactive accounts is significantly more complex (managing multiple timers, secure token access) and generally not implemented unless specifically required and carefully designed.
  • (Do ✅) State Synchronization: When switching accounts, ensure all relevant user-specific data is cleared/refetched. Use TanStack Query's queryClient.removeQueries or queryClient.invalidateQueries targeting user-specific keys, and queryClient.fetchQuery for immediate essential data reloading.
  • (Consider 🤔) Backend Support: This pattern assumes the backend issues independent tokens per login and treats each as a separate session. If the backend has its own server-side multi-session management, this client-side approach might need adjustment or simplification.
  • (Do ✅) UI/UX: Clearly indicate the active account throughout the app (e.g., in a profile header). Provide an intuitive and easily accessible way to switch accounts (e.g., via profile menu).
  • (Do ✅) Error Handling: Implement robust error handling during the switchAccount process, especially if fetching essential data for the new account fails. Decide whether to revert the switch or force a logout.
  • GUIDE-05: Authentication Flow Implementation Guide
  • GUIDE-02: State Management Implementation Guide (Redux)
  • GUIDE-07: Social Login Implementation Guide
  • DOC-03: API & State Management Reference
  • DOC-04: Security & Offline Framework Reference