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
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:
User performs action while offline
API.write() creates request with optimistic data
Request is persisted to disk (via Onyx)
Optimistic data is applied immediately
Request waits in queue
When online, queue processes requests in order
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 >
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