Building Offline-First React Native Apps
Mobile16 min read

Building Offline-First React Native Apps

A deep, practical guide to offline-first architecture in React Native: choosing storage, detecting connectivity, persisting server cache, optimistic updates, and a real mutation-queue sync engine with conflict resolution.

Taha Kocal

Taha Kocal

Full-Stack & Mobile Developer

Jul 24, 2026
#React Native#Offline First#MMKV#TanStack Query#Mobile Architecture#SQLite#Sync

Offline-first is an architecture pattern where the local device database is the source of truth for the UI, and the network acts as an eventually-consistent backup that syncs in the background. Instead of blocking screens on HTTP requests, an offline-first React Native app reads from local storage such as MMKV or SQLite, applies writes optimistically, and queues mutations in a durable outbox that replays when connectivity returns. This matters because on mobile, a lost connection is the normal operating condition: users open apps in elevators, subways, rural areas, and airplane mode. The approach delivers two concrete benefits: screens render instantly from disk or memory rather than waiting on a network round trip, and every mutation is replayable and idempotent, so retries are safe. Building it requires four pieces: a storage engine, reliable connectivity detection, a persisted server cache (typically TanStack Query), and a sync engine with conflict resolution.

This article walks through a complete offline-first architecture for React Native: how to pick a storage engine, how to detect real connectivity (not just a radio being on), how to persist server data with TanStack Query, how to make writes feel instant with optimistic updates, and how to build a durable outbox that replays mutations when the network returns. We finish with conflict resolution, media caching, background sync, and testing.

Why Does Offline-First Matter?

Offline-first is not just about surviving a dead connection. It is a performance and UX strategy. When the local store is the source of truth, every screen renders instantly from memory or disk instead of waiting on a round trip. The network becomes an enhancement, not a dependency. This produces apps that feel dramatically faster even on good connections, because you never block the UI on latency you do not control.

The core principles of an offline-first app:

  • Reads always resolve from local storage first, then revalidate from the network in the background
  • Writes are applied optimistically to local state and queued for later synchronization
  • The UI never blocks on the network; loading states are the exception, not the rule
  • Connectivity is treated as a transient, unreliable signal that can change at any moment
  • Every mutation must be replayable and idempotent so retries are safe

How Do You Choose a Storage Engine?

The storage layer is the foundation of everything else, so it is worth understanding the trade-offs before committing. There is no single winner; the right choice depends on data volume, query complexity, and whether you need reactive queries.

AsyncStorage

AsyncStorage is the historical default: a simple, asynchronous, unencrypted key-value store backed by SQLite on Android and files on iOS. It is fine for small amounts of data such as flags, tokens, and cached JSON blobs, but every read and write crosses the async bridge and it becomes a bottleneck under load. Treat it as a lowest-common-denominator fallback rather than a primary engine for a serious offline app.

MMKV

MMKV (from the react-native-mmkv package) is a memory-mapped key-value store written in C++. Reads and writes are synchronous and roughly an order of magnitude faster than AsyncStorage, it supports encryption out of the box, and it integrates cleanly with the New Architecture via JSI. For key-value needs including token storage and persisting a serialized query cache, MMKV is the modern default. Its main limitation is that it is a key-value store, not a queryable database, so it is a poor fit for large relational datasets.

expo-sqlite, op-sqlite, and WatermelonDB

When you need real queries, indexes, and thousands of rows, you want SQLite. expo-sqlite ships with the Expo SDK and now exposes a modern synchronous API plus async variants; it is the pragmatic default in an Expo project. op-sqlite is a high-performance JSI-based SQLite binding that is significantly faster for bulk operations and supports extensions like SQLCipher for encryption and libSQL for sync. WatermelonDB sits on top of SQLite and adds a reactive, lazy ORM designed specifically for large datasets and sync; components subscribe to observable queries and re-render automatically when the underlying rows change, which is powerful but adds a learning curve and opinionated schema management.

A quick decision guide:

  • Small config and cached blobs, need speed and encryption: MMKV
  • Legacy, minimal footprint, tiny data: AsyncStorage
  • Relational data in an Expo app, moderate volume: expo-sqlite
  • High-throughput SQLite with encryption or libSQL sync: op-sqlite
  • Large reactive datasets with built-in sync primitives: WatermelonDB

Pick the simplest storage that fits your data shape. Most apps do best with MMKV for key-value plus a SQLite engine for anything relational, rather than forcing everything into one tool.

A Typed Storage Layer

Rather than sprinkling MMKV or AsyncStorage calls throughout the app, wrap them behind a single interface. This keeps call sites clean, lets you swap engines later, and gives you typed helpers for JSON. Here is a thin wrapper around MMKV that exposes typed get/set and an AsyncStorage-compatible adapter for libraries that expect a persistor.

typescript
// storage/kv.ts
import { MMKV } from 'react-native-mmkv';

// A single encrypted instance for the whole app.
export const mmkv = new MMKV({
  id: 'app-storage',
  encryptionKey: 'rotate-me-from-secure-store',
});

export const storage = {
  getString(key: string): string | undefined {
    return mmkv.getString(key);
  },
  set(key: string, value: string | number | boolean): void {
    mmkv.set(key, value);
  },
  getJSON<T>(key: string): T | undefined {
    const raw = mmkv.getString(key);
    if (!raw) return undefined;
    try {
      return JSON.parse(raw) as T;
    } catch {
      // Corrupt entry: remove it so we fail safe on next read.
      mmkv.delete(key);
      return undefined;
    }
  },
  setJSON<T>(key: string, value: T): void {
    mmkv.set(key, JSON.stringify(value));
  },
  delete(key: string): void {
    mmkv.delete(key);
  },
};

// Adapter that mimics the AsyncStorage interface some libraries expect.
export const mmkvPersistor = {
  setItem: (key: string, value: string) => {
    mmkv.set(key, value);
    return Promise.resolve();
  },
  getItem: (key: string) => {
    return Promise.resolve(mmkv.getString(key) ?? null);
  },
  removeItem: (key: string) => {
    mmkv.delete(key);
    return Promise.resolve();
  },
};

Because MMKV is synchronous, getJSON returns immediately with no await. That single property is what makes rendering from cache feel instant: your components can read persisted state during the first render pass instead of flashing an empty state while an async read resolves.

How Do You Detect Real Connectivity?

A common mistake is trusting the radio state. A device can report Wi-Fi connected while sitting behind a captive portal with no internet, or report cellular while the tower is congested. The @react-native-community/netinfo library distinguishes isConnected (a radio is attached to a network) from isInternetReachable (a reachability probe actually succeeded). Always gate sync decisions on internet reachability, not just connection.

tsx
// hooks/useOnlineStatus.ts
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
import { useEffect, useState } from 'react';

export interface OnlineStatus {
  isConnected: boolean;
  isInternetReachable: boolean;
  type: NetInfoState['type'];
}

export function useOnlineStatus(): OnlineStatus {
  const [status, setStatus] = useState<OnlineStatus>({
    isConnected: true,
    isInternetReachable: true,
    type: 'unknown',
  });

  useEffect(() => {
    const unsubscribe = NetInfo.addEventListener((state) => {
      setStatus({
        isConnected: Boolean(state.isConnected),
        // null means "not yet determined"; treat it optimistically as true.
        isInternetReachable: state.isInternetReachable ?? true,
        type: state.type,
      });
    });
    return unsubscribe;
  }, []);

  return status;
}

// A promise-based check for use inside imperative sync code.
export async function isOnline(): Promise<boolean> {
  const state = await NetInfo.fetch();
  return Boolean(state.isConnected) && state.isInternetReachable !== false;
}

How Do You Persist the Server Cache with TanStack Query?

TanStack Query is the natural fit for the read side of an offline-first app. It already models server state as a cache with staleness and background refetching. What we add are two things: persistence so the cache survives app restarts, and an online manager wired to NetInfo so Query knows when it can retry. Persisting to MMKV means that on cold start the user sees their last-known data immediately, and Query silently revalidates once a connection is available.

tsx
// query/client.tsx
import NetInfo from '@react-native-community/netinfo';
import {
  QueryClient,
  focusManager,
  onlineManager,
} from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { AppState, AppStateStatus } from 'react-native';
import { useEffect } from 'react';
import { mmkvPersistor } from '../storage/kv';

// Let Query drive online/offline behaviour from real reachability.
onlineManager.setEventListener((setOnline) => {
  return NetInfo.addEventListener((state) => {
    setOnline(Boolean(state.isConnected) && state.isInternetReachable !== false);
  });
});

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Keep cached data usable offline for a full day.
      gcTime: 1000 * 60 * 60 * 24,
      staleTime: 1000 * 60,
      retry: 2,
      // Do not throw away cached data when a refetch fails offline.
      networkMode: 'offlineFirst',
    },
    mutations: {
      networkMode: 'offlineFirst',
    },
  },
});

const persister = createSyncStoragePersister({
  storage: {
    getItem: (key) => mmkvPersistor.getItem(key) as unknown as string,
    setItem: (key, value) => mmkvPersistor.setItem(key, value),
    removeItem: (key) => mmkvPersistor.removeItem(key),
  },
});

export function AppQueryProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    const sub = AppState.addEventListener('change', (state: AppStateStatus) => {
      focusManager.setFocused(state === 'active');
    });
    return () => sub.remove();
  }, []);

  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{ persister, maxAge: 1000 * 60 * 60 * 24 }}
    >
      {children}
    </PersistQueryClientProvider>
  );
}

The two settings that matter most here are networkMode: offlineFirst and the persister. offlineFirst tells Query to always run the query function once (serving cached data if it fails) rather than pausing when offline, so your selectors keep returning the last snapshot. The persister serializes the whole cache into MMKV on a debounced interval and rehydrates it on launch.

Optimistic Updates

Reads are only half the story. When a user taps a like, edits a note, or checks off a task, they expect the UI to respond instantly, whether or not the request will reach the server this second. Optimistic updates apply the change to the local cache immediately, then reconcile with the server response, rolling back if it ultimately fails. TanStack Query gives us the onMutate, onError, and onSettled lifecycle to do this cleanly.

tsx
// hooks/useToggleTask.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { enqueueMutation } from '../sync/outbox';

interface Task {
  id: string;
  title: string;
  done: boolean;
  updatedAt: number;
  version: number;
}

export function useToggleTask() {
  const qc = useQueryClient();

  return useMutation({
    mutationKey: ['toggleTask'],
    mutationFn: async (task: Task) => {
      // The outbox owns the network call so it survives app restarts.
      return enqueueMutation({
        type: 'task.toggle',
        payload: { id: task.id, done: !task.done, version: task.version },
      });
    },
    onMutate: async (task) => {
      await qc.cancelQueries({ queryKey: ['tasks'] });
      const previous = qc.getQueryData<Task[]>(['tasks']);

      qc.setQueryData<Task[]>(['tasks'], (old = []) =>
        old.map((t) =>
          t.id === task.id
            ? { ...t, done: !t.done, updatedAt: Date.now() }
            : t,
        ),
      );

      // Return context so onError can roll back.
      return { previous };
    },
    onError: (_err, _task, context) => {
      if (context?.previous) {
        qc.setQueryData(['tasks'], context.previous);
      }
    },
    onSettled: () => {
      qc.invalidateQueries({ queryKey: ['tasks'] });
    },
  });
}

The Outbox: A Durable Mutation Queue

Optimistic updates make the UI feel fast, but they do not solve durability. If the user toggles ten tasks in the subway and then closes the app, those writes must not be lost. The solution is an outbox (also called a mutation queue): every write is appended to a persisted, ordered log. A background processor drains that log whenever connectivity allows, retrying with backoff and removing entries only after the server confirms them.

A robust outbox needs a few non-negotiable properties:

  • Durability: entries are persisted to disk before the optimistic update is shown
  • Ordering: mutations replay in the order they were created to preserve causality
  • Idempotency: each entry carries a client-generated id so retries never double-apply
  • Bounded retries: transient failures back off; permanent failures are quarantined, not looped forever
  • Observability: the UI can show a pending count and surface entries stuck in error
typescript
// sync/outbox.ts
import 'react-native-get-random-values';
import { v4 as uuid } from 'uuid';
import { storage } from '../storage/kv';
import { isOnline } from '../hooks/useOnlineStatus';
import { handlers } from './handlers';

const QUEUE_KEY = 'outbox:v1';
const MAX_ATTEMPTS = 8;

export interface OutboxEntry {
  id: string;
  type: keyof typeof handlers;
  payload: unknown;
  createdAt: number;
  attempts: number;
  status: 'pending' | 'error';
  lastError?: string;
}

type Listener = (entries: OutboxEntry[]) => void;
const listeners = new Set<Listener>();

function read(): OutboxEntry[] {
  return storage.getJSON<OutboxEntry[]>(QUEUE_KEY) ?? [];
}

function write(entries: OutboxEntry[]): void {
  storage.setJSON(QUEUE_KEY, entries);
  listeners.forEach((fn) => fn(entries));
}

export function subscribeOutbox(fn: Listener): () => void {
  listeners.add(fn);
  fn(read());
  return () => listeners.delete(fn);
}

// Called by mutation hooks. Persists first, then kicks the processor.
export async function enqueueMutation(input: {
  type: OutboxEntry['type'];
  payload: unknown;
}): Promise<{ queuedId: string }> {
  const entry: OutboxEntry = {
    id: uuid(),
    type: input.type,
    payload: input.payload,
    createdAt: Date.now(),
    attempts: 0,
    status: 'pending',
  };
  write([...read(), entry]);
  // Fire and forget; the processor is safe to call concurrently.
  void processOutbox();
  return { queuedId: entry.id };
}

let processing = false;

export async function processOutbox(): Promise<void> {
  if (processing) return;
  if (!(await isOnline())) return;
  processing = true;

  try {
    // Always re-read from disk so we see the latest queue.
    let queue = read().filter((e) => e.status === 'pending');

    for (const entry of queue) {
      try {
        const handler = handlers[entry.type];
        // Handlers are idempotent: they send entry.id as an Idempotency-Key.
        await handler(entry.payload, entry.id);
        // Success: drop this entry from the persisted queue.
        write(read().filter((e) => e.id !== entry.id));
      } catch (err) {
        const attempts = entry.attempts + 1;
        const permanent = attempts >= MAX_ATTEMPTS;
        write(
          read().map((e) =>
            e.id === entry.id
              ? {
                  ...e,
                  attempts,
                  status: permanent ? 'error' : 'pending',
                  lastError: String(err),
                }
              : e,
          ),
        );
        // Stop the run on the first failure to preserve ordering.
        break;
      }
    }
  } finally {
    processing = false;
  }
}

The handlers map is where each mutation type becomes an actual network call. Keeping handlers separate from the queue keeps the queue generic and testable. Note the Idempotency-Key header: because we pass the stable entry id, a retry that succeeds after a dropped response will not create a duplicate record on the server.

typescript
// sync/handlers.ts
import { api } from '../api/client';

export const handlers = {
  'task.toggle': async (
    payload: { id: string; done: boolean; version: number },
    idempotencyKey: string,
  ) => {
    await api.patch(`/tasks/${payload.id}`, {
      body: { done: payload.done, version: payload.version },
      headers: { 'Idempotency-Key': idempotencyKey },
    });
  },
  'task.create': async (
    payload: { clientId: string; title: string },
    idempotencyKey: string,
  ) => {
    await api.post('/tasks', {
      body: { clientId: payload.clientId, title: payload.title },
      headers: { 'Idempotency-Key': idempotencyKey },
    });
  },
} as const;

Finally, wire the processor to connectivity changes and app foregrounding so the queue drains automatically without any manual button. Whenever the device comes back online or the app returns to the foreground, we attempt a flush.

tsx
// sync/useOutboxProcessor.ts
import NetInfo from '@react-native-community/netinfo';
import { useEffect } from 'react';
import { AppState } from 'react-native';
import { processOutbox } from './outbox';

export function useOutboxProcessor() {
  useEffect(() => {
    const net = NetInfo.addEventListener((state) => {
      if (state.isConnected && state.isInternetReachable !== false) {
        void processOutbox();
      }
    });
    const app = AppState.addEventListener('change', (s) => {
      if (s === 'active') void processOutbox();
    });
    // Attempt one flush on mount in case we launched with a full queue.
    void processOutbox();
    return () => {
      net();
      app.remove();
    };
  }, []);
}

How Do You Resolve Sync Conflicts?

When writes are made offline and replayed later, the server may have changed underneath you. Two users edit the same note; your queued edit arrives after theirs. How you resolve that is a product decision as much as a technical one, but there are three common strategies.

Last-Write-Wins

The simplest strategy: whichever write reaches the server last overwrites the record, usually compared by a client or server timestamp. It is trivial to implement and acceptable for low-contention data like personal settings, but it silently discards concurrent edits, so it is a poor fit for collaborative or high-value data.

Versioning and Optimistic Concurrency

A more robust approach attaches a version number (or ETag) to every record. The client sends the version it based its edit on; if the server version has moved on, it rejects the write with a 409 Conflict. The outbox then decides whether to refetch and reapply, merge fields, or surface the conflict to the user. This preserves data integrity and is the recommended default for anything shared. The handler below shows how the outbox reacts to a version conflict.

typescript
// sync/resolveConflict.ts
import { api } from '../api/client';
import { queryClient } from '../query/client';

export async function toggleTaskWithConflict(
  payload: { id: string; done: boolean; version: number },
  idempotencyKey: string,
) {
  try {
    await api.patch(`/tasks/${payload.id}`, {
      body: { done: payload.done, version: payload.version },
      headers: { 'Idempotency-Key': idempotencyKey },
    });
  } catch (err: any) {
    if (err.status === 409) {
      // The server record moved on. Fetch the latest truth.
      const fresh = await api.get(`/tasks/${payload.id}`);

      // Field-level merge: keep our intent (done) on top of server state.
      await api.patch(`/tasks/${payload.id}`, {
        body: { done: payload.done, version: fresh.version },
        headers: { 'Idempotency-Key': `${idempotencyKey}:retry` },
      });

      // Refresh local cache so the UI reflects the merged result.
      queryClient.invalidateQueries({ queryKey: ['tasks'] });
      return;
    }
    throw err;
  }
}

CRDTs for True Concurrency

For genuinely collaborative editing (shared documents, whiteboards), conflict-free replicated data types such as those provided by Yjs or Automerge let multiple offline replicas converge deterministically without a central arbiter. They are powerful but heavy; reach for them only when last-write-wins and versioning genuinely cannot express your domain.

Do not pick a conflict strategy globally. Personal settings can be last-write-wins while a shared document needs versioning or CRDTs. Match the strategy to the contention profile of each entity.

Caching Images and Files

Data is not the only thing that needs to work offline. Avatars, thumbnails, and attachments should be cached to disk so they render without a connection. The expo-image component caches automatically with configurable cachePolicy, and for arbitrary files expo-file-system lets you download once to a stable path and reuse it. The key is deriving a deterministic local path from a stable remote key so lookups are cheap.

tsx
// media/cacheFile.ts
import * as FileSystem from 'expo-file-system';

const DIR = FileSystem.cacheDirectory + 'media/';

async function ensureDir() {
  const info = await FileSystem.getInfoAsync(DIR);
  if (!info.exists) {
    await FileSystem.makeDirectoryAsync(DIR, { intermediates: true });
  }
}

// Returns a local URI, downloading only if not already cached.
export async function getCachedFile(remoteUrl: string, key: string) {
  await ensureDir();
  const target = DIR + key;
  const info = await FileSystem.getInfoAsync(target);
  if (info.exists) return target;

  try {
    const result = await FileSystem.downloadAsync(remoteUrl, target);
    return result.uri;
  } catch {
    // Offline and not cached: let the caller show a placeholder.
    return null;
  }
}

Background Sync Considerations

Draining the outbox while the app is in the foreground covers most cases, but users expect changes to sync even after they close the app. Mobile operating systems restrict background execution aggressively, so you cannot run an arbitrary loop. On React Native, expo-background-task (or expo-task-manager with background fetch) lets you register a task that the OS wakes periodically, on its own schedule, subject to battery and network conditions.

Practical realities of background sync on mobile:

  • The OS decides when your task runs; you get opportunities, not guarantees, so foreground flushing must remain the primary path
  • Keep background work short and idempotent; it may be killed mid-run at any time
  • iOS is far stricter than Android and may throttle apps that repeatedly finish with no work to do
  • Always re-check connectivity inside the task; being woken does not mean the internet is reachable
  • Use background sync to shorten the window before data syncs, not as a correctness guarantee
typescript
// sync/registerBackgroundSync.ts
import * as BackgroundTask from 'expo-background-task';
import * as TaskManager from 'expo-task-manager';
import { processOutbox } from './outbox';

const TASK = 'outbox-sync';

TaskManager.defineTask(TASK, async () => {
  try {
    await processOutbox();
    return BackgroundTask.BackgroundTaskResult.Success;
  } catch {
    return BackgroundTask.BackgroundTaskResult.Failed;
  }
});

export async function registerBackgroundSync() {
  const status = await BackgroundTask.getStatusAsync();
  if (status === BackgroundTask.BackgroundTaskStatus.Available) {
    await BackgroundTask.registerTaskAsync(TASK, {
      minimumInterval: 15, // minutes; the OS treats this as a hint.
    });
  }
}

How Do You Test Offline Scenarios?

Offline behavior is exactly the kind of thing that breaks silently, because the happy path with a fast simulator connection always looks fine. Test it deliberately. Mock NetInfo to flip connectivity mid-test, assert that the outbox persists entries across a simulated restart, and verify optimistic rollback on a rejected mutation.

typescript
// __tests__/outbox.test.ts
import { enqueueMutation, processOutbox, subscribeOutbox } from '../sync/outbox';
import * as net from '../hooks/useOnlineStatus';
import { handlers } from '../sync/handlers';

jest.mock('../sync/handlers');

it('persists while offline and drains when back online', async () => {
  const spy = jest.spyOn(net, 'isOnline');
  const seen: number[] = [];
  subscribeOutbox((entries) => seen.push(entries.length));

  // Offline: enqueue should persist but not send.
  spy.mockResolvedValue(false);
  await enqueueMutation({ type: 'task.toggle', payload: { id: '1', done: true, version: 1 } });
  expect(handlers['task.toggle']).not.toHaveBeenCalled();

  // Back online: processor drains the queue.
  spy.mockResolvedValue(true);
  (handlers['task.toggle'] as jest.Mock).mockResolvedValue(undefined);
  await processOutbox();

  expect(handlers['task.toggle']).toHaveBeenCalledTimes(1);
  // Queue ends empty after a successful send.
  expect(seen[seen.length - 1]).toBe(0);
});

it('keeps the entry and increments attempts on failure', async () => {
  jest.spyOn(net, 'isOnline').mockResolvedValue(true);
  (handlers['task.toggle'] as jest.Mock).mockRejectedValue(new Error('500'));

  await enqueueMutation({ type: 'task.toggle', payload: { id: '2', done: false, version: 1 } });
  await processOutbox();

  // The failed entry must survive for the next retry.
  expect(handlers['task.toggle']).toHaveBeenCalled();
});

Beyond unit tests, do manual QA with the device in airplane mode: create records, kill the app, restore the connection, and confirm everything syncs exactly once. Chaos-style testing (toggling the network repeatedly during a sync) surfaces ordering and idempotency bugs that a clean test never will.

Key Takeaways

What to remember when building offline-first React Native apps:

  • Treat the local device as the source of truth and the network as an eventually-consistent enhancement
  • Use MMKV for fast, encrypted key-value storage and a SQLite engine (expo-sqlite, op-sqlite, or WatermelonDB) for relational data
  • Gate sync on isInternetReachable from NetInfo, never on the radio state alone
  • Persist the TanStack Query cache and set networkMode to offlineFirst so screens render instantly on cold start
  • Make writes feel instant with optimistic updates, but never rely on them for durability
  • Route every write through a persisted, ordered, idempotent outbox that drains automatically on reconnect
  • Choose a conflict strategy per entity: last-write-wins for low-contention data, versioning for shared data, CRDTs for real collaboration
  • Cache images and files to disk, and treat background sync as a bonus rather than a guarantee
  • Test offline paths explicitly, including app restarts and failed mutations, because they will never break loudly on their own

Offline-first is more work up front, but it changes the character of your app. Instead of a thin client that stutters whenever the signal drops, you ship something that feels instant everywhere and quietly reconciles with the server when it can. Your users will rarely notice the machinery, which is exactly the point.

Share this article

Related Articles