Mobile App Architecture Patterns

Choose and implement the right architecture pattern for your mobile application. Covers MVC, MVVM, Clean Architecture, Coordinator pattern, dependency injection, and how architecture choices affect testability, maintainability, and team velocity.

Mobile app architecture patterns are recurring structural solutions to common problems in building scalable, maintainable, and testable mobile applications. They define how components interact, how data flows, and how state is managed across the app’s layers. Use these patterns when your app grows beyond a single-screen, flat UI, and you need to handle complex navigation, asynchronous data, offline capabilities, and consistent state across views.

Organizing State and Data Flow

Single Source of Truth with Centralized State Management

Use a central store to manage app state across screens and components. The store is a single, mutable object updated via actions. Each action is a plain object with a type field and optional payload.

{
  "type": "USER_LOGIN",
  "payload": {
    "id": 123,
    "name": "Alice",
    "token": "abc123"
  }
}

Actions are dispatched to the store, which applies them via reducers—pure functions that take current state and action, and return new state.

function userReducer(state = { currentUser: null }, action) {
  switch (action.type) {
    case 'USER_LOGIN':
      return { ...state, currentUser: action.payload };
    case 'USER_LOGOUT':
      return { ...state, currentUser: null };
    default:
      return state;
  }
}

The app re-renders whenever the state changes. Subscribers listen for state updates, and updates are batched using requestAnimationFrame to avoid jank.

Common pitfalls:

Failure mode: When a screen loads, it receives stale data because the store was not updated before rendering. Fix by dispatching LOAD_USER_SUCCESS *before* rendering the screen.

Component-Driven State with Local State and Lifted State

Combine local component state (e.g., useState in React) with lifted state in the central store. Local state manages UI-specific details like form inputs, scroll position, or loading indicators.

function UserProfileScreen({ userId }) {
  const [name, setName] = useState('');
  const [loading, setLoading] = useState(false);
  const dispatch = useDispatch();

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(user => {
        setName(user.name);
        dispatch({ type: 'USER_LOAD', payload: user });
        setLoading(false);
      });
  }, [userId]);

  return (
    <div>
      <input value={name} onChange={e => setName(e.target.value)} />
      <button onClick={() => dispatch({ type: 'USER_SAVE', payload: { name } })}>
        Save
      </button>
      {loading && <p>Loading...</p>}
    </div>
  );
}

When a user edits a field, local state is updated immediately. On save, the action is dispatched to the central store. This pattern reduces latency and improves UX.

Key edge case: When multiple screens modify the same entity, the central store may reflect a newer version than the local state. Use optimistic updates to resolve this.

Optimistic Updates with Conflict Resolution

When a user submits a form, update the UI immediately before the server responds.

dispatch({ type: 'POST_CREATE', payload: { title: 'New Post', body: 'Hello' } });

fetch('/api/posts', {
  method: 'POST',
  body: JSON.stringify({ title: 'New Post', body: 'Hello' })
})
.then(res => res.json())
.then(post => {
  dispatch({ type: 'POST_CREATE_SUCCESS', payload: post });
})
.catch(err => {
  dispatch({ type: 'POST_CREATE_FAILURE', payload: err });
});

The UI shows the new post immediately. If the server fails, the UI can show a "syncing" indicator and retry.

Failure mode: A second user edits the same post while the first is still syncing. The first edit is overwritten. Fix with versioning: each entity has a version field. On update, the client sends expectedVersion, and the server responds with currentVersion. If mismatched, apply a merge strategy (e.g., last-write-wins, merge-into).

Managing Asynchronous Data and Side Effects

Async Actions with Middleware

Use middleware to handle side effects like network calls, file I/O, or caching. The middleware chain processes each action before it reaches the reducer.

const middleware = (store) => (next) => (action) => {
  if (action.type === 'FETCH_USER') {
    const { userId } = action.payload;
    store.dispatch({ type: 'USER_FETCH_START', payload: userId });

    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(user => {
        store.dispatch({ type: 'USER_FETCH_SUCCESS', payload: user });
      })
      .catch(err => {
        store.dispatch({ type: 'USER_FETCH_FAILURE', payload: err });
      });
    return;
  }

  return next(action);
};

The middleware is applied during store setup:

const store = createStore(rootReducer, applyMiddleware(middleware));

Common failure: Middleware runs but does not update the store. Check that next(action) is called after processing. Also, ensure that dispatch in middleware returns the result (e.g., next(action) returns a Promise).

Caching and Rehydration

Cache responses in memory and persistence layer (e.g., SQLite, localStorage). On app startup, rehydrate state from disk.

async function loadState() {
  const saved = await AsyncStorage.getItem('app-state');
  return saved ? JSON.parse(saved) : {};
}

async function saveState(state) {
  await AsyncStorage.setItem('app-state', JSON.stringify(state));
}

const store = createStore(rootReducer, loadState());
store.subscribe(() => saveState(store.getState()));

Edge case: While the app is suspended, the server updates a user. On resume, the client fetches fresh data but overwrites the cached version. Use timestamped cache entries with lastFetchedAt and expiresAt.

{
  "users": {
    "123": {
      "id": 123,
      "name": "Alice",
      "lastFetchedAt": "2024-04-05T10:00:00Z",
      "expiresAt": "2024-04-05T11:00:00Z"
    }
  }
}

A refreshOnMount effect checks if Date.now() > expiresAt, and triggers a refetch.

Offline-First Architecture

Offline-First with Queue and Sync

When the network is unavailable, queue all actions to a local store (e.g., SQLite, Realm, or a simple JSON file). On network reconnect, replay the queue.

[
  {
    "type": "POST_CREATE",
    "payload": { "title": "Offline Post", "body": "Wrote this while on train" },
    "timestamp": "2024-04-05T12:30:00Z"
  },
  {
    "type": "USER_LOGOUT",
    "payload": {},
    "timestamp": "2024-04-05T12:32:00Z"
  }
]

On reconnect, the app fetches the queue and sends each action to the server. After success, the queue is cleared.

Failure mode: Network is lost after sending actions but before receiving responses. Use idempotent actions: each action includes a id field, and the server checks if it has already processed an action with that id.

Edge case: A user edits a post offline, then the server updates the same post. The client must merge the changes. Use a merge strategy:

Scalable Component and Module Architecture

Feature-Based Modularization

Organize code by feature, not by layer. Each feature is a self-contained module with its own state, components, and services.

src/
  features/
    user/
      index.js
      reducer.js
      actions.js
      components/
        UserProfile.js
        UserList.js
      services/
        api.js
      routes.js
    post/
      index.js
      reducer.js
      components/
        PostEditor.js
        PostList.js
      services/
        api.js

Each feature exports a configureStore function that returns a store enhancer.

function configureUserFeature(store) {
  store.dispatch({ type: 'USER_FEATURE_LOADED' });
  return store;
}

Combine features at app level:

const rootReducer = combineReducers({
  user: userReducer,
  post: postReducer
});

const store = createStore(rootReducer);

const userStore = configureUserFeature(store);
const postStore = configurePostFeature(store);

// Load feature-specific state
userStore.dispatch({ type: 'USER_LOAD', payload: { id: 123 } });

Key edge: A feature module expects a userId in the state, but the app’s root state does not include it. Fix with feature-specific selectors:

function getUserById(state, id) {
  return state.user?.users?.[id];
}

Dependency Injection with Service Registry

Register services (e.g., API clients, analytics trackers, push notifiers) in a registry. Services are resolved by name.

const serviceRegistry = new Map();

function registerService(name, service) {
  serviceRegistry.set(name, service);
}

function getService(name) {
  const service = serviceRegistry.get(name);
  if (!service) throw new Error(`Service ${name} not found`);
  return service;
}

Use the registry in components:

function useAnalytics() {
  const analytics = getService('analytics');
  return analytics;
}

function useApi() {
  const api = getService('api');
  return api;
}

Failure mode: A service is registered but not available at startup. Check that registration happens before the first call. Use lifecycle hooks to register services during app boot.

Navigation state is stored in a stack or tree. Use a navigate action to push, pop, or replace routes.

{
  "type": "NAVIGATE",
  "payload": {
    "route": "UserProfile",
    "params": { "userId": 123 },
    "options": { "animated": true }
  }
}

Deep links are handled by parsing URLs and dispatching NAVIGATE actions.

function handleDeepLink(url) {
  const { pathname, searchParams } = new URL(url);

  const route = pathname.slice(1);
  const params = Object.fromEntries(searchParams.entries());

  dispatch({ type: 'NAVIGATE', payload: { route, params } });
}

Edge case: A deep link arrives while the app is suspended. The app must handle it on startup. Use onAppResume to process pending deep links.

Error Handling and Monitoring

All actions and side effects should emit errors to a central error log.

function logError(error, context) {
  const entry = {
    timestamp: new Date().toISOString(),
    message: error.message,
    stack: error.stack,
    context
  };

  // Write to file or send to backend
  fetch('/api/errors', {
    method: 'POST',
    body: JSON.stringify(entry)
  });
}

Use try/catch around all async operations and register global error handlers.

window.addEventListener('unhandledrejection', (event) => {
  logError(event.reason, { type: 'unhandledrejection' });
});

window.addEventListener('error', (event) => {
  logError(event.error, { type: 'global-error', url: event.filename });
});

Failure mode: Error is logged but not visible in dev console. Ensure console.error is used, and errors are sent immediately, not queued.

Summary

Mobile app architecture patterns are not frameworks—they are proven ways to structure code for clarity, scalability, and resilience. Choose a pattern based on the app's complexity: start with centralized state, add async actions, then evolve to offline-first, modular features, and robust error handling. These patterns interlock: a feature module uses centralized state, performs async actions, caches data, and handles deep links. Together, they form a cohesive, predictable foundation for mobile apps.

This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.