Back to Blog
React NativeMobileEnterpriseAzure ADTypeScript

Building an Internal Mobile App with React Native

Umut Korkmaz2025-11-2010 min

When leadership asked for a mobile app that internal teams would rely on 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 the mobile app — a React Native application running on React Native 0.82.1 with React 19, supporting consistent day-to-day usage 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 (the internal platform), 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/          # Screen modules
  components/       # Reusable UI 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://internal-platform/.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.

App Structure and Shared Components

The app covers a lot of ground: document viewing, workflow approvals, task management, announcements, directory search, and more. With dozens of screens and 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 consistent day-to-day usage, performance is closely monitored in production:

  • App launch to interactive stays comfortably responsive on cold start
  • API response rendering remains fast for day-to-day screens
  • Crash-free behavior remains strong in production
  • SignalR reconnection is resilient during normal usage

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 regularly. Internal apps benefit from short feedback loops and steady iteration.