Skip to main content

Philosophy

New Expensify is designed to work perfectly offline. Users can perform almost any action without an internet connection, and those actions are automatically synchronized when connectivity is restored.
Core Principle: Allow users to do as much as possible when offline. Assume most requests will succeed and proceed optimistically.

Offline UX Patterns

Every feature in New Expensify follows one of these offline patterns:

Pattern A: Optimistic Without Feedback

When to use: The user needs instant feedback but doesn’t need to know when the server confirms the change. Example: Pinning a chat
import API from '@libs/API';
import ONYXKEYS from '@src/ONYXKEYS';

function pinReport(reportID: string) {
  const optimisticData: OnyxUpdate[] = [
    {
      onyxMethod: Onyx.METHOD.MERGE,
      key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
      value: {
        isPinned: true,
      },
    },
  ];

  // No successData or failureData needed
  // User doesn't need to know when server confirms
  API.write('TogglePinnedChat', {reportID}, {optimisticData});
}
Implementation:
  • Use API.write()
  • Only provide optimisticData
  • UI updates immediately
  • No loading indicators needed

Pattern B: Optimistic WITH Feedback

When to use: The user needs to know that data is pending server confirmation. Example: Sending a chat message
function sendMessage(reportID: string, text: string) {
  const messageID = generateRandomID();
  
  const optimisticData: OnyxUpdate[] = [
    {
      onyxMethod: Onyx.METHOD.MERGE,
      key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
      value: {
        [messageID]: {
          message: text,
          created: new Date().toISOString(),
          pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
        },
      },
    },
  ];

  const successData: OnyxUpdate[] = [
    {
      onyxMethod: Onyx.METHOD.MERGE,
      key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
      value: {
        [messageID]: {
          pendingAction: null, // Clear pending state
        },
      },
    },
  ];

  const failureData: OnyxUpdate[] = [
    {
      onyxMethod: Onyx.METHOD.MERGE,
      key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
      value: {
        [messageID]: {
          errors: {
            [Date.now()]: 'Failed to send message',
          },
          pendingAction: null,
        },
      },
    },
  ];

  API.write('AddComment', {reportID, message: text}, {
    optimisticData,
    successData,
    failureData,
  });
}
Implementation:
  • Use API.write()
  • Include optimisticData, successData, and failureData
  • Add pendingAction to show pending state
  • Wrap UI in OfflineWithFeedback component
UI Component:
import OfflineWithFeedback from '@components/OfflineWithFeedback';

function MessageItem({message}) {
  const handleDismissError = () => {
    // Clear errors from Onyx
    Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {
      [message.id]: {errors: null},
    });
  };

  return (
    <OfflineWithFeedback
      pendingAction={message.pendingAction}
      errors={message.errors}
      onClose={handleDismissError}
    >
      <Text>{message.text}</Text>
    </OfflineWithFeedback>
  );
}
Visual Feedback:
  • Adding: Item shown at 50% opacity when offline
  • Updating: Item shown at 50% opacity when offline
  • Deleting: Item shown with strikethrough when offline
  • Error: Item shown with red error message and dismiss button

Pattern C: Blocking Form

When to use:
  • Form requires server validation
  • Server response is unpredictable
  • Moving money or other critical operations
Example: Inviting workspace members
import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
import {useNetwork} from '@hooks/useNetwork';

function InviteMembersForm() {
  const {isOffline} = useNetwork();
  const [emails, setEmails] = useState([]);

  const handleSubmit = () => {
    API.write('InviteMembersToWorkspace', {emails});
  };

  return (
    <FormProvider>
      <TextInput
        label="Email addresses"
        value={emails}
        onChangeText={setEmails}
      />
      
      <FormAlertWithSubmitButton
        isLoading={isLoading}
        buttonText="Invite"
        onSubmit={handleSubmit}
        enabledWhenOffline={false}
        message={isOffline ? 'You appear offline' : ''}
      />
    </FormProvider>
  );
}
Implementation:
  • Use FormAlertWithSubmitButton
  • Set enabledWhenOffline={false}
  • Users can fill out form offline, but can’t submit
  • Data is saved locally via form draft

Pattern D: Full Page Blocking

When to use:
  • READ request where stale data is unacceptable
  • Data must be fetched from server before display
  • Only use as last resort
Example: Loading bank accounts from Plaid
import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
import {useNetwork} from '@hooks/useNetwork';

function BankAccountsScreen() {
  const {isOffline} = useNetwork();
  const [bankAccounts] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST);

  if (isOffline && !bankAccounts) {
    return (
      <FullPageOfflineBlockingView>
        You need to be online to view bank accounts.
      </FullPageOfflineBlockingView>
    );
  }

  return <BankAccountsList accounts={bankAccounts} />;
}
Implementation:
  • Wrap component in FullPageOfflineBlockingView
  • Only show when offline AND data is not available
  • Provide clear instructions to user

Pattern None: No Offline Behavior

When to use:
  • No server interaction
  • OR data is READ and stale data is acceptable
Example: About page with static content
function AboutScreen() {
  return (
    <ScreenWrapper>
      <Text>About New Expensify</Text>
      <Text>Version 1.0.0</Text>
    </ScreenWrapper>
  );
}
Implementation:
  • Use API.read() for server data
  • No special offline handling
  • Shows stale data until fresh data loads

Choosing the Right Pattern

Request Queue

How It Works

WRITE requests are queued and replayed when online:
  1. User performs action while offline
  2. API.write() creates request with optimistic data
  3. Request is persisted to disk (via Onyx)
  4. Optimistic data is applied immediately
  5. Request waits in queue
  6. When online, queue processes requests in order
  7. Success/failure data is applied based on response

Sequential Queue

Requests are processed sequentially to maintain order:
// From src/libs/Network/SequentialQueue.ts

// Requests are processed one at a time
// If a request fails, it's retried
// Queue is persisted across app restarts

Viewing the Queue

During development:
import {getAll} from '@userActions/PersistedRequests';

const queuedRequests = getAll();
console.log('Queued requests:', queuedRequests);

Conflict Resolution

What Are Conflicts?

Conflicts occur when:
  • User makes changes offline on Device A
  • User makes different changes online on Device B
  • Device A comes online and tries to sync

Built-In Strategies

1. Last Write Wins

Default behavior - the most recent change is kept:
// Device A (offline): Sets name to "Alice"
// Device B (online): Sets name to "Bob"
// Device A syncs: "Bob" wins (most recent)

2. Duplicate Detection

Prevents duplicate requests:
import {writeWithNoDuplicatesConflictAction} from '@libs/API';

function createWorkspace(name: string) {
  writeWithNoDuplicatesConflictAction(
    'CreateWorkspace',
    {name},
    onyxData,
    (request) => request.command === 'CreateWorkspace',
  );
}

3. Custom Conflict Resolution

function updatePolicy(policyID: string, changes: PolicyChanges) {
  const conflictResolver = {
    checkAndFixConflictingRequest: (requests) => {
      // Find conflicting requests
      const conflicts = requests.filter(
        r => r.command === 'UpdatePolicy' && r.data.policyID === policyID,
      );
      
      // Merge changes
      const mergedChanges = mergeConflicts(conflicts, changes);
      
      return {
        conflictAction: {type: 'replace'},
        updatedRequest: createRequest(policyID, mergedChanges),
      };
    },
  };

  API.write('UpdatePolicy', {policyID, ...changes}, onyxData, conflictResolver);
}

Network Detection

Using Network State

import {useNetwork} from '@hooks/useNetwork';

function MyComponent() {
  const {isOffline} = useNetwork();

  return (
    <View>
      {isOffline && <Text>You are offline</Text>}
    </View>
  );
}

Subscribing to Network Changes

import Onyx from 'react-native-onyx';
import ONYXKEYS from '@src/ONYXKEYS';

Onyx.connect({
  key: ONYXKEYS.NETWORK,
  callback: (network) => {
    if (network?.isOffline) {
      console.log('Offline mode');
    } else {
      console.log('Online mode');
    }
  },
});

Error Handling

Displaying Errors

Use the Red Brick Road (RBR) pattern to guide users to errors:
import MenuItem from '@components/MenuItem';

function WorkspaceSettingsItem({policy}) {
  const hasErrors = !!policy.errors;
  
  return (
    <MenuItem
      title={policy.name}
      brickRoadIndicator={hasErrors ? 'error' : undefined}
      onPress={() => Navigation.navigate(ROUTES.WORKSPACE_OVERVIEW.getRoute(policy.id))}
    />
  );
}

Clearing Errors

function clearPolicyErrors(policyID: string) {
  Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
    errors: null,
    pendingAction: null,
  });
}

Retry Logic

Failed requests are automatically retried:
// Automatic retry with exponential backoff
// Retry intervals: 1s, 2s, 4s, 8s, etc.
// Max retries: configurable per request

Pending States

Pending Actions

type PendingAction = 'add' | 'update' | 'delete';

// Adding new data
const optimisticData = [
  {
    onyxMethod: Onyx.METHOD.SET,
    key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
    value: {
      ...newPolicy,
      pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
    },
  },
];

// Updating existing data
const optimisticData = [
  {
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
    value: {
      name: newName,
      pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
    },
  },
];

// Deleting data
const optimisticData = [
  {
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
    value: {
      pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
    },
  },
];

Field-Level Pending States

For partial updates:
const optimisticData = [
  {
    onyxMethod: Onyx.METHOD.MERGE,
    key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
    value: {
      name: newName,
      pendingFields: {
        name: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
      },
    },
  },
];

Testing Offline Behavior

Simulating Offline Mode

import NetworkStore from '@libs/Network/NetworkStore';

// In tests
beforeEach(() => {
  NetworkStore.setIsOffline(true);
});

afterEach(() => {
  NetworkStore.setIsOffline(false);
});

test('handles offline state', () => {
  const {getByText} = render(<MyComponent />);
  expect(getByText('You are offline')).toBeTruthy();
});

Testing Optimistic Updates

import waitForBatchedUpdates from '@libs/waitForBatchedUpdates';
import * as API from '@libs/API';

jest.spyOn(API, 'write');

test('applies optimistic update', async () => {
  pinReport('123');
  
  await waitForBatchedUpdates();
  
  const [report] = await new Promise((resolve) => {
    Onyx.connect({
      key: `${ONYXKEYS.COLLECTION.REPORT}123`,
      callback: resolve,
    });
  });
  
  expect(report.isPinned).toBe(true);
  expect(API.write).toHaveBeenCalledWith(
    'TogglePinnedChat',
    expect.any(Object),
    expect.any(Object),
  );
});

Best Practices

1. Always Provide Optimistic Data

// ❌ Bad: No optimistic data
API.write('UpdateName', {name});

// ✅ Good: Immediate UI update
API.write('UpdateName', {name}, {optimisticData});

2. Handle Failures Gracefully

// ✅ Always include failureData
API.write('CreateWorkspace', params, {
  optimisticData,
  successData,
  failureData, // Remove optimistic data on failure
});

3. Use Appropriate Patterns

Choose the right offline pattern for your use case (see flowchart above).

4. Test Offline Scenarios

Always test:
  • Action performed offline
  • Action performed online
  • Error scenarios
  • Network changes during action

5. Provide Clear Feedback

Use OfflineWithFeedback to show pending states visually.

Common Pitfalls

1. Not Providing Failure Data

// ❌ Bad: Optimistic data stays even after failure
API.write('CreatePolicy', params, {optimisticData});

// ✅ Good: Cleans up on failure
API.write('CreatePolicy', params, {
  optimisticData,
  failureData,
});

2. Blocking When Not Necessary

// ❌ Bad: Blocks a simple update
if (isOffline) {
  return <OfflineBlockingView />;
}

// ✅ Good: Uses optimistic pattern
API.write('UpdateName', {name}, {optimisticData});

3. Not Handling Errors

// ✅ Always wrap in OfflineWithFeedback for Pattern B
<OfflineWithFeedback
  errors={item.errors}
  onClose={handleDismissError}
>
  {/* Content */}
</OfflineWithFeedback>

Performance Considerations

Batch API Calls

Group related updates:
// ❌ Bad: Multiple API calls
API.write('UpdateField1', {value1});
API.write('UpdateField2', {value2});
API.write('UpdateField3', {value3});

// ✅ Good: Single API call
API.write('UpdateFields', {
  field1: value1,
  field2: value2,
  field3: value3,
});

Optimize Onyx Updates

// ❌ Bad: Multiple Onyx updates
Onyx.merge(ONYXKEYS.POLICY, {name: newName});
Onyx.merge(ONYXKEYS.POLICY, {description: newDesc});

// ✅ Good: Single merged update
Onyx.merge(ONYXKEYS.POLICY, {
  name: newName,
  description: newDesc,
});

Next Steps

State Management

Learn more about Onyx

API Reference

Understand API patterns

Testing

Test offline behavior

Best Practices

Follow offline-first best practices

Build docs developers (and LLMs) love