Back to Blog
React NativeMobileEnterpriseAzure ADTypeScript

Building a Corporate Mobile App with React Native for 2,000+ Employees

Umut Korkmaz2025-11-2010 min

When leadership asked for a mobile app that 2,000+ employees would use daily, I knew this was not a typical app store project. Corporate mobile apps have a unique set of constraints: you are dealing with enterprise authentication, strict security policies, legacy backend integrations, and users who will not tolerate bugs because this is their work tool, not a social media app they can delete.

This is how I built DigiPort — a React Native application running on React Native 0.82.1 with React 19, serving 500+ daily active users across the organization.

Architecture Decisions

The first decision was React Native over native development. We had a small team, tight deadlines, and needed to ship on both iOS and Android. The team already knew React from our web app (DigiFlow), so React Native was the natural choice. With React Native 0.82.1 and the New Architecture enabled by default, performance was no longer a concern.

The app structure is deliberately flat and modular:

src/
  screens/          # 38 screens
  components/       # 98 reusable components
  services/         # API, auth, notifications
  stores/           # Zustand stores
  hooks/            # Custom hooks
  navigation/       # React Navigation stacks
  utils/            # Helpers, constants, types

Authentication: MSAL and the Token Manager

Azure AD authentication via MSAL was the biggest technical challenge. The Microsoft Authentication Library for React Native works, but enterprise environments throw curveballs: conditional access policies, multi-factor authentication prompts, token refresh failures when the device has been offline, and VPN-dependent token endpoints.

I built a custom Token Manager with a circuit breaker pattern to handle all of this gracefully:

typescript
class TokenManager {
  private circuitBreaker: CircuitBreaker;
  private refreshPromise: Promise<string> | null = null;

  constructor() {
    this.circuitBreaker = new CircuitBreaker({
      failureThreshold: 3,
      resetTimeout: 30_000, // 30 seconds
      halfOpenMaxAttempts: 1,
    });
  }

  async getAccessToken(): Promise<string> {
    // Deduplicate concurrent token requests
    if (this.refreshPromise) return this.refreshPromise;

    return this.circuitBreaker.execute(async () => {
      const account = await this.getAccount();

      try {
        // Silent token acquisition first
        this.refreshPromise = msalInstance
          .acquireTokenSilent({
            scopes: ['api://digiflow/.default'],
            account,
          })
          .then(result => result.accessToken);

        return await this.refreshPromise;
      } catch (error) {
        if (error instanceof InteractionRequiredAuthError) {
          // Redirect to interactive login
          return this.acquireTokenInteractive();
        }
        throw error;
      } finally {
        this.refreshPromise = null;
      }
    });
  }
}

The circuit breaker prevents the app from hammering the token endpoint when Azure AD is having issues. After 3 consecutive failures, it opens the circuit and shows the user a meaningful error instead of spinning forever. After 30 seconds, it allows a single retry attempt. This pattern saved us during two Azure AD outages — the app degraded gracefully instead of crashing.

State Management with Zustand 5

I chose Zustand 5.0.8 for state management over Redux or MobX. In a mobile app, simplicity wins. Zustand stores are small, focused, and have zero boilerplate:

typescript
interface NotificationStore {
  unreadCount: number;
  notifications: Notification[];
  isConnected: boolean;
  addNotification: (notification: Notification) => void;
  markAsRead: (id: string) => void;
  setConnectionStatus: (status: boolean) => void;
}

export const useNotificationStore = create<NotificationStore>()(
  persist(
    (set, get) => ({
      unreadCount: 0,
      notifications: [],
      isConnected: false,
      addNotification: (notification) =>
        set((state) => ({
          notifications: [notification, ...state.notifications].slice(0, 100),
          unreadCount: state.unreadCount + 1,
        })),
      markAsRead: (id) =>
        set((state) => ({
          notifications: state.notifications.map((n) =>
            n.id === id ? { ...n, read: true } : n
          ),
          unreadCount: Math.max(0, state.unreadCount - 1),
        })),
      setConnectionStatus: (status) => set({ isConnected: status }),
    }),
    {
      name: 'notification-storage',
      storage: createJSONStorage(() => AsyncStorage),
    }
  )
);

The persist middleware with AsyncStorage means notifications survive app restarts. Users open the app and immediately see their unread count without waiting for a network call.

SignalR for Real-Time Updates

Document workflow approvals need to be real-time. When a manager approves a document, the requester should know immediately. I integrated SignalR with automatic reconnection and exponential backoff:

typescript
const connection = new HubConnectionBuilder()
  .withUrl(`${API_BASE}/hubs/workflow`, {
    accessTokenFactory: () => tokenManager.getAccessToken(),
  })
  .withAutomaticReconnect({
    nextRetryDelayInMilliseconds: (context) => {
      // Exponential backoff: 0s, 2s, 4s, 8s, 16s, max 30s
      const delay = Math.min(
        Math.pow(2, context.previousRetryCount) * 1000,
        30_000
      );
      return delay;
    },
  })
  .build();

The SignalR connection ties directly into the Zustand notification store. When a message arrives, it updates the store, which reactively updates every subscribed component — badge counts, notification lists, and workflow status indicators all update simultaneously.

38 Screens, 98 Components

The app covers a lot of ground: document viewing, workflow approvals, task management, announcements, directory search, and more. With 38 screens and 98 components, code organization matters.

Every screen follows the same pattern: a container component handles data fetching and state, and a presentation component handles rendering. Custom hooks extract shared logic:

typescript
function useWorkflowActions(documentId: string) {
  const queryClient = useQueryClient();

  const approveMutation = useMutation({
    mutationFn: (data: ApprovalData) =>
      workflowService.approve(documentId, data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['document', documentId] });
      queryClient.invalidateQueries({ queryKey: ['pending-approvals'] });
    },
  });

  const rejectMutation = useMutation({
    mutationFn: (data: RejectionData) =>
      workflowService.reject(documentId, data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['document', documentId] });
      queryClient.invalidateQueries({ queryKey: ['pending-approvals'] });
    },
  });

  return { approveMutation, rejectMutation };
}

Performance in Production

With 500+ daily active users, performance is closely monitored. Key metrics after 6 months in production:

  • App launch to interactive: ~1.8 seconds (cold start)
  • API response rendering: under 200ms for most screens
  • Crash-free rate: 99.7%
  • SignalR reconnection success: 98.5%

The biggest performance win was enabling the New Architecture in React Native 0.82.1. The JSI-based bridge eliminated serialization overhead, and Fabric's synchronous rendering made scrolling through long document lists noticeably smoother.

Lessons for Corporate Mobile Apps

  1. Invest heavily in auth infrastructure. Enterprise auth is 10x harder than consumer auth. Build the token manager early and build it well.
  2. Circuit breakers are essential. Network conditions in corporate environments are unpredictable. Degrade gracefully.
  3. Persist critical state. Users will kill and reopen the app constantly. Make sure they do not lose context.
  4. Test on real devices with real network conditions. The simulator lies.
  5. Ship weekly. Corporate apps need to iterate fast because feedback from 2,000 users comes in fast.