Building an Internal Mobile App with React Native
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:
typescriptclass 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:
typescriptinterface 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:
typescriptconst 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:
typescriptfunction 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
- Invest heavily in auth infrastructure. Enterprise auth is 10x harder than consumer auth. Build the token manager early and build it well.
- Circuit breakers are essential. Network conditions in corporate environments are unpredictable. Degrade gracefully.
- Persist critical state. Users will kill and reopen the app constantly. Make sure they do not lose context.
- Test on real devices with real network conditions. The simulator lies.
- Ship regularly. Internal apps benefit from short feedback loops and steady iteration.