UTA DevHub
Guides

Pre-UI Initialization Guide

Implementation patterns for the critical Pre-UI initialization stage in the application bootstrap process.

Pre-UI Initialization Guide

Overview

The Pre-UI initialization stage is the first and most critical phase of the application bootstrap process. During this phase, we perform essential setup tasks that must complete before showing any UI to the user.

Think of the Pre-UI stage as building the foundation for your application's user experience. The work done in this stage determines which screens users will see first, how the app will appear, and which core functionalities will be immediately available.

This guide explores practical implementation patterns to help you effectively manage the Pre-UI stage while maximizing performance – ensuring users see your app's interface as quickly as possible.

Quick Start

If you're eager to get started with Pre-UI initialization, here's a simplified approach you can implement right away:

  1. Register your critical tasks with the initializer:

    import { initializer, InitStage } from '@/core/shared/app/initialization';
     
    // Register a Pre-UI task
    initializer.registerTask(InitStage.PRE_UI, {
      name: 'auth:token-retrieval',
      execute: async () => {
        // Implementation here, e.g., retrieve authentication tokens
        const accessToken = await tokenService.getStoredAccessToken();
        authStore.setToken(accessToken);
      }
    });
  2. Keep the native splash screen visible until Pre-UI initialization completes:

    useEffect(() => {
      async function initializePreUI() {
        try {
          // Execute Pre-UI stage (critical, blocking)
          await initializer.executeStage(InitStage.PRE_UI);
          
          // Mark Pre-UI complete so we can render app shell
          setPreUIComplete(true);
          
          // Hide splash screen when complete
          SplashScreen.hide({ fade: true });
        } catch (error) {
          console.error('Pre-UI initialization failed:', error);
          SplashScreen.hide({ fade: true });
        }
      }
      
      initializePreUI();
    }, []);
  3. Use the useInitialization hook to track initialization progress in your components

The rest of this guide explains these concepts in depth, providing more context and advanced implementation patterns.

When To Use

The Pre-UI stage is best suited for a specific set of initialization tasks that must happen before your app can meaningfully display anything to the user. Let's explore which tasks belong in this stage and why careful consideration is important.

Appropriate Tasks for Pre-UI Initialization

  • Essential for Application Function: Components that must be available before any UI can be rendered

    • Example: Reading authentication tokens to determine if a user is logged in
    • Why: Without this, your app can't decide which screen to show first
  • Required for Navigation Decisions: Services that determine the initial navigation state

    • Example: Checking if a user has completed onboarding
    • Why: This information dictates the entire navigation flow of your app
  • Critical User Experience: Settings that significantly impact initial user experience

    • Example: Loading theme preferences (light/dark) or language settings
    • Why: Showing UI and then immediately changing these settings creates a jarring flash effect

Performance Considerations

Important: Keep Pre-UI initialization tasks to an absolute minimum, as every millisecond spent here directly adds to your app's startup time. Users often judge app quality based on startup performance, so this is a critical optimization area.

For tasks that are important but not critical to the first render, consider moving them to the initial-ui or background stages. This allows users to see and interact with your app sooner, improving perceived performance.

Implementation Patterns

Let's explore practical implementation patterns for the Pre-UI stage. Below is a visualization of how this stage fits into the overall app initialization flow:

As the diagram illustrates, the Pre-UI stage occurs immediately after app launch and before any UI is shown to the user. During this phase, we typically handle three primary concerns:

  1. Authentication State: Determining if a user is logged in
  2. Critical Preferences: Loading essential user settings
  3. App Configuration: Setting up core application parameters

Once these tasks complete, we transition from the native splash screen to the Initial UI rendering phase.

Splash Screen Management

For detailed implementation of splash screens during initialization, refer to the Splash Screen Guide. The Pre-UI initialization stage should integrate with splash screens as follows:

  • Keep the native splash screen visible until Pre-UI initialization completes
  • Use the splash screen's hide method as the final step of Pre-UI initialization
  • Consider fade transitions for a smoother user experience

Here's a basic integration example using our abstracted SplashScreen component:

// App.tsx or app/_layout.tsx
import { useEffect, useState } from 'react';
import { initializer, InitStage } from '@/core/shared/app/initialization';
import { SplashScreen } from '@/core/shared/splash-screen/SplashScreen';
 
// In your root component
useEffect(() => {
  async function initializePreUI() {
    try {
      // Execute Pre-UI stage (critical, blocking)
      await initializer.executeStage(InitStage.PRE_UI);
      
      // Hide splash screen with smooth fade
      SplashScreen.hide({ fade: true, duration: 500 });
      
      // Continue with non-blocking stages
    } catch (error) {
      console.error('Pre-UI initialization failed:', error);
      // Still hide splash screen even on error
      SplashScreen.hide({ fade: true });
    }
  }
  
  initializePreUI();
}, []);

For complete implementation details, including setup, configuration, and advanced usage, see the Splash Screen Guide.

Authentication State Initialization

The most common Pre-UI initialization task is retrieving and validating authentication state. This information is crucial because it determines which UI flows your users will see - such as login screens, onboarding, or the main application content.

By loading authentication state during the Pre-UI stage, you can seamlessly route users to the appropriate screens during the initial-ui without jarring redirects or flashes of incorrect content.

Here's how you might implement authentication state initialization:

// core/domains/auth/initialization.ts
import { initializer, InitStage, InitTask } from '@/core/shared/app/initialization';
import { tokenService } from './tokenService';
import { authStore } from './store';
import { authEvents } from './events';
 
export const authInitialization: InitTask = {
  name: 'auth:token-retrieval',
  
  async execute(): Promise<void> {
    try {
      // Retrieve tokens from secure storage
      const accessToken = await tokenService.getStoredAccessToken();
      const refreshToken = await tokenService.getStoredRefreshToken();
      
      if (accessToken && refreshToken) {
        // Set tokens in memory
        authStore.setTokens(accessToken, refreshToken);
        
        // Validate token without blocking UI render
        // This performs a lightweight validation - not a full API call
        const isTokenValid = tokenService.isTokenValid(accessToken);
        
        if (isTokenValid) {
          authEvents.emit('auth:initialized', { isAuthenticated: true });
        } else {
          // Token exists but is expired - will be refreshed later
          authEvents.emit('auth:initialized', { isAuthenticated: false, needsRefresh: true });
        }
      } else {
        // No tokens found
        authEvents.emit('auth:initialized', { isAuthenticated: false });
      }
    } catch (error) {
      console.error('Failed to initialize auth tokens:', error);
      // Emit event with error but allow initialization to continue
      authEvents.emit('auth:initialized', { isAuthenticated: false, error });
    }
  }
};
 
// Register with initializer
initializer.registerTask(InitStage.PRE_UI, authInitialization);

Critical Preferences Hydration

User preferences like theme choice (light/dark mode) and language settings directly affect how your UI appears. Loading these preferences during the Pre-UI stage helps prevent jarring visual changes after your app becomes visible.

Imagine a user with dark mode enabled seeing a bright white screen flash briefly before your app switches to dark mode—this creates a poor experience. By loading these critical preferences early, you ensure the UI appears correctly from the first moment.

Less critical preferences (like notification settings or display options) can be loaded later in the background. Here's how to implement critical preferences hydration:

// core/shared/preferences/initialization.ts
import { initializer, InitStage, InitTask } from '@/core/shared/app/initialization';
import { storage } from '@/core/shared/utils/storage';
import { preferencesStore } from './store';
 
export const criticalPreferencesInit: InitTask = {
  name: 'preferences:critical',
  // Optional dependency on auth - if auth fails, preferences should still load
  dependencies: ['auth:token-retrieval'], 
  
  async execute(): Promise<void> {
    try {
      // Only load critical preferences in Pre-UI stage
      // These are preferences that affect initial rendering
      const criticalPrefs = await Promise.all([
        storage.get('app:theme'),
        storage.get('app:language'),
        storage.get('app:textSize')
      ]);
      
      // Update store with values or defaults
      preferencesStore.setTheme(criticalPrefs[0] || 'system');
      preferencesStore.setLanguage(criticalPrefs[1] || 'en');
      preferencesStore.setTextSize(criticalPrefs[2] || 'medium');
    } catch (error) {
      console.error('Failed to load critical preferences:', error);
      // Continue with defaults
      preferencesStore.setTheme('system');
      preferencesStore.setLanguage('en');
      preferencesStore.setTextSize('medium');
    }
  }
};
 
// Register with initializer
initializer.registerTask(InitStage.PRE_UI, criticalPreferencesInit);

App Configuration Loading

Load essential configuration that affects how the app should function:

// core/shared/config/initialization.ts
import { initializer, InitStage, InitTask } from '@/core/shared/app/initialization';
import { configStore } from './store';
import { publicApi } from '@/core/shared/api/client';
 
export const appConfigInit: InitTask = {
  name: 'config:app',
  
  async execute(): Promise<void> {
    try {
      // First try to load from local cache
      const cachedConfig = await storage.get('app:config');
      
      if (cachedConfig) {
        // Set cached config immediately
        configStore.setConfig(JSON.parse(cachedConfig));
        
        // Check if cache is still valid
        const lastUpdated = await storage.get('app:config:timestamp');
        const now = Date.now();
        
        // Only use cached config if it's less than 24 hours old
        if (lastUpdated && (now - parseInt(lastUpdated)) < 24 * 60 * 60 * 1000) {
          return; // Use cached config, no need to fetch
        }
      }
      
      // If no valid cache, fetch config but don't block initialization
      // We'll use defaults for now and update once the fetch completes
      this.fetchConfigInBackground();
    } catch (error) {
      console.error('Failed to load app configuration:', error);
      // Continue with default configuration
      configStore.setConfig({
        features: {
          newUserOnboarding: true,
          analytics: true,
          deepLinking: true
        },
        limits: {
          maxUploadSize: 5 * 1024 * 1024, // 5MB
          requestsPerMinute: 60
        }
      });
    }
  },
  
  // Non-blocking fetch that will update config once complete
  private async fetchConfigInBackground(): Promise<void> {
    try {
      // Use a shorter timeout for config fetch
      const config = await publicApi.get('/config/app', { timeout: 5000 });
      
      // Update store with fresh config
      configStore.setConfig(config);
      
      // Cache for future use
      await storage.set('app:config', JSON.stringify(config));
      await storage.set('app:config:timestamp', Date.now().toString());
    } catch (error) {
      console.warn('Failed to fetch fresh configuration:', error);
      // App will continue with cached or default config
    }
  }
};
 
// Register with initializer
initializer.registerTask(InitStage.PRE_UI, appConfigInit);

Registering Multiple Pre-UI Tasks

When a domain requires multiple initialization tasks, use a centralized registration pattern:

// core/domains/[domain]/initialization.ts
import { initializer, InitStage } from '@/core/shared/app/initialization';
 
// Define all initialization tasks
const taskA = { name: 'domain:taskA', execute: async () => { /* ... */ } };
const taskB = { name: 'domain:taskB', execute: async () => { /* ... */ } };
 
// Register all Pre-UI tasks
export function registerInitializationTasks() {
  initializer.registerTask(InitStage.PRE_UI, taskA);
  
  // Only add to Pre-UI if absolutely necessary
  // Consider if this can be moved to a later stage
  initializer.registerTask(InitStage.PRE_UI, taskB);
}
 
// Call this function in the domain's index.ts

Common Challenges

Managing Dependencies Between Tasks

Tasks in the Pre-UI stage often have dependencies on each other. Use the dependencies property to declare these relationships:

// core/domains/products/initialization.ts
import { initializer, InitStage, InitTask } from '@/core/shared/app/initialization';
 
const productCatalogInit: InitTask = {
  name: 'products:catalog-init',
  // This task depends on config being loaded first
  dependencies: ['config:app'],
  
  async execute(): Promise<void> {
    // Implementation that uses configuration
    const config = configStore.getConfig();
    // Initialize catalog based on config
    // ...
  }
};
 
initializer.registerTask(InitStage.PRE_UI, productCatalogInit);

Handling Initialization Failures

Pre-UI tasks should be resilient to failures and provide fallbacks when possible:

// Pattern for resilient initialization tasks
const resilientTask: InitTask = {
  name: 'domain:resilient-task',
  
  async execute(): Promise<void> {
    try {
      // Attempt primary initialization
      await this.primaryInitialization();
    } catch (error) {
      // Log the error
      console.error('Primary initialization failed:', error);
      
      try {
        // Attempt fallback initialization
        await this.fallbackInitialization();
      } catch (fallbackError) {
        // If fallback also fails, use defaults
        console.error('Fallback initialization failed:', fallbackError);
        this.useDefaults();
      }
    }
  },
  
  async primaryInitialization() {
    // Optimal initialization path
  },
  
  async fallbackInitialization() {
    // Alternative initialization strategy
  },
  
  useDefaults() {
    // Set up with default values
  }
};

Timeout Protection

Protect against hanging initialization tasks with timeout wrappers:

// core/shared/utils/timeout.ts
export async function withTimeout<T>(
  promise: Promise<T>,
  timeoutMs: number,
  fallback?: () => T | Promise<T>
): Promise<T> {
  let timeoutId: NodeJS.Timeout;
  
  const timeoutPromise = new Promise<never>((_, reject) => {
    timeoutId = setTimeout(() => {
      reject(new Error(`Operation timed out after ${timeoutMs}ms`));
    }, timeoutMs);
  });
  
  try {
    const result = await Promise.race([promise, timeoutPromise]);
    clearTimeout(timeoutId);
    return result;
  } catch (error) {
    clearTimeout(timeoutId);
    if (error.message.includes('timed out') && fallback) {
      return await fallback();
    }
    throw error;
  }
}
 
// Usage in initialization task
async execute(): Promise<void> {
  await withTimeout(
    this.loadConfiguration(),
    3000, // 3 second timeout
    () => this.useDefaultConfiguration() // Fallback function
  );
}

Performance Considerations

  • (Do ✅) Keep Pre-UI tasks minimal

    • Only include absolutely critical initialization
    • Consider moving tasks to later stages when possible
    • Measure the impact of each task on startup time
  • (Do ✅) Use caching strategies

    • Cache responses from previous sessions
    • Use cache-first, update-in-background pattern
    • Set appropriate cache expiration policies
  • (Don't ❌) Perform network requests unnecessarily

    • Avoid network calls in Pre-UI stage if possible
    • If required, set short timeouts (3-5 seconds)
    • Always have offline fallbacks
  • (Do ✅) Load progressively

    • Load only what's needed for initial UI
    • Defer loading additional data to later stages
    • Consider staged loading within the Pre-UI tasks themselves

Practical Examples

Application Theme Provider Initialization

// ui/theme/initialization.ts
import { initializer, InitStage, InitTask } from '@/core/shared/app/initialization';
import { storage } from '@/core/shared/utils/storage';
import { themeStore } from './store';
import { Appearance } from 'react-native';
 
const themeInitialization: InitTask = {
  name: 'ui:theme-init',
  
  async execute(): Promise<void> {
    try {
      // Get user preference
      const storedTheme = await storage.get('theme:preference');
      
      // Determine theme mode
      if (storedTheme === 'dark' || storedTheme === 'light') {
        // Use explicit user preference
        themeStore.setThemeMode(storedTheme);
      } else if (storedTheme === 'system' || !storedTheme) {
        // Default to system preference
        const deviceTheme = Appearance.getColorScheme() || 'light';
        themeStore.setThemeMode(deviceTheme);
        
        // Set up listener for system theme changes
        this.setupSystemThemeListener();
      }
      
      // Load custom theme if any
      const customTheme = await storage.get('theme:custom');
      if (customTheme) {
        try {
          themeStore.setCustomTheme(JSON.parse(customTheme));
        } catch (parseError) {
          console.error('Failed to parse custom theme:', parseError);
          // Continue with default theme
        }
      }
    } catch (error) {
      console.error('Theme initialization failed:', error);
      // Fall back to light theme
      themeStore.setThemeMode('light');
    }
  },
  
  setupSystemThemeListener() {
    // Subscribe to system theme changes
    const subscription = Appearance.addChangeListener(({ colorScheme }) => {
      if (themeStore.getThemePreference() === 'system') {
        themeStore.setThemeMode(colorScheme || 'light');
      }
    });
    
    // Store subscription for potential cleanup
    themeStore.setAppearanceSubscription(subscription);
  }
};
 
initializer.registerTask(InitStage.PRE_UI, themeInitialization);

Feature Flags Initialization

// core/shared/features/initialization.ts
import { initializer, InitStage, InitTask } from '@/core/shared/app/initialization';
import { storage } from '@/core/shared/utils/storage';
import { featureStore } from './store';
import { publicApi } from '@/core/shared/api/client';
 
const featureFlagsInit: InitTask = {
  name: 'features:flags-init',
  
  async execute(): Promise<void> {
    try {
      // Try to load cached feature flags first
      const cachedFlags = await storage.get('features:flags');
      
      if (cachedFlags) {
        // Use cached flags immediately
        featureStore.setFeatureFlags(JSON.parse(cachedFlags));
        
        // Check age of cache
        const cacheTimestamp = await storage.get('features:flags:timestamp') || '0';
        const cacheAge = Date.now() - parseInt(cacheTimestamp);
        
        // If cache is fresh enough, don't fetch new flags yet
        if (cacheAge < 60 * 60 * 1000) { // 1 hour
          return;
        }
      }
      
      // Default flags used if no cache or fetch fails
      const defaultFlags = {
        newCheckout: false,
        enhancedSearch: false,
        betaFeatures: false
      };
      
      // Set defaults if no cache was found
      if (!cachedFlags) {
        featureStore.setFeatureFlags(defaultFlags);
      }
      
      // Fetch latest flags in background, don't block initialization
      this.fetchFeatureFlagsInBackground(defaultFlags);
    } catch (error) {
      console.error('Feature flags initialization failed:', error);
      // Set safe defaults
      featureStore.setFeatureFlags({
        newCheckout: false,
        enhancedSearch: false,
        betaFeatures: false
      });
    }
  },
  
  async fetchFeatureFlagsInBackground(defaultFlags) {
    try {
      // Short timeout for feature flags
      const flags = await publicApi.get('/config/features', { 
        timeout: 5000 
      });
      
      // Update store
      featureStore.setFeatureFlags({
        ...defaultFlags,
        ...flags
      });
      
      // Cache for future use
      await storage.set('features:flags', JSON.stringify(flags));
      await storage.set('features:flags:timestamp', Date.now().toString());
    } catch (error) {
      console.warn('Failed to fetch latest feature flags:', error);
      // App will continue with cached or default flags
    }
  }
};
 
initializer.registerTask(InitStage.PRE_UI, featureFlagsInit);

Migration Considerations

When migrating from older initialization patterns to our staged initialization approach for Pre-UI tasks, consider these helpful guidelines:

From Direct Initialization to Task-Based Approach

If you're currently using direct initialization in your app entry file like this:

// Old approach in App.tsx
const App = () => {
  const [isReady, setIsReady] = useState(false);
  
  useEffect(() => {
    async function initialize() {
      try {
        // Direct initialization calls
        await loadAuthTokens();
        await loadConfiguration();
        await setupTheme();
        // Show UI
        setIsReady(true);
      } catch (error) {
        console.error('Initialization failed', error);
      }
    }
    initialize();
  }, []);
  
  if (!isReady) return null;
  
  return <MainApp />;
};

You can enhance this by:

  1. Converting each initialization function to a task that follows the InitTask interface
  2. Registering these tasks with the initializer for the Pre-UI stage
  3. Using the centralizer initializer's flow management instead of custom state

This approach provides better error handling, performance tracking, and a clearer separation of concerns.

Ensuring Backward Compatibility

When implementing the new initialization system:

  • Consider wrapping existing initialization logic in tasks temporarily during migration
  • Keep fallbacks for essential functionality in case initialization fails
  • Add detailed logging to help debug any issues during the transition

The task-based approach is particularly helpful for team development as it creates clear boundaries for initialization responsibilities.

Summary

The Pre-UI initialization stage forms the foundation of your app's startup experience. By thoughtfully implementing this stage, you'll significantly improve both actual and perceived performance for your users.

Key Takeaways

  • Strategic Task Placement: Be deliberate about what you include in the Pre-UI stage. Every task here directly impacts startup time.

  • User Experience Focus: The work done in this stage should enable a smooth, coherent experience from the moment the UI appears.

  • Staged Approach Benefits: By carefully dividing initialization between the Pre-UI stage and later stages like initial-ui, background, and finalization, you create a responsive app that feels fast even while work continues in the background.

Best Practices

  • Keep Pre-UI tasks minimal and fast - Ruthlessly evaluate what truly needs to happen before showing UI
  • Use caching aggressively - Store and reuse data to avoid network calls during startup
  • Provide fallbacks for everything - Ensure your app can still function if initialization tasks fail
  • Measure initialization performance - Use metrics to identify bottlenecks
  • Move tasks to later stages whenever possible - If a task doesn't need to block UI rendering, defer it

By applying these patterns, you'll create a more responsive application that delights users with its speed and reliability.

Core Initialization Documentation

Other Initialization Stages