Back to Blog

How to Build Local-First Apps with React Native + RxDB. Architecture and Examples

Eugene Setsko - dev.family
Eugene Setsko
mobile developer

Aug 7, 2025

11 minutes reading

How to Build Local-First Apps with React Native + RxDB. Architecture and Examples - dev.family

Imagine a delivery driver who can’t load their route because of a weak signal, or a warehouse manager who can’t access the inventory list because the server is down. These types of issues can slow down operations, cause delays, and lead to lost revenue. For this reason, in some scenarios, mobile apps must be able to function without an internet connection. We’ve covered more use cases in this article. 

Many of our clients face connectivity challenges, whether on remote job sites, at offsite events, in logistics, or on warehouse floors. In these situations, it’s critical that data remains accessible on the device and that any changes are automatically synced to the server once the connection is restored.

These apps follow the local-first principle, processing most data locally and using the server to sync and consolidate information across devices. This approach ensures a stable and reliable user experience regardless of network conditions.

In this article, we’ll walk through a practical example of how to build a local-first mobile app. You’ll learn: 

  • How does two-way sync between the client and server work?
  • How to handle data conflicts?
  • What to keep in mind when designing solutions like this?

Looking for developers for your project? We are ready to become your technical partners

Tech Stack

For our example, we used the following technologies: 

  • Client: React Native + react-native-nitro-sqlite + RxDB;
  • Server: NestJS + TypeORM + PostgreSQL;
  • React Native – is a cross-platform framework that uses JavaScript and React to build mobile apps with native UI. It allows developers to create apps for both iOS and Android from a single codebase;
  • react-native-nitro-sqlite – is a library that provides efficient local SQLite database management on React Native devices;
  • RxDB – is an offline-first, reactive database optimized for client-side use. It stores documents locally and supports real-time queries with live subscriptions. It can also sync automatically with a remote server;
  • NestJS – is a Node.js framework for building scalable backend applications;
  • TypeORM – is an object-relational mapping (ORM) library for TypeScript and JavaScript that simplifies working with relational databases;
  • PostgreSQL –  is an object-relational database management system known for its robustness and reliability.

RxDB Local Storage via SQLite

We’ll start by setting up the local database that RxDB will use to interact with SQLite, including support for validation and encryption.

In a mobile app, RxDB stores data in a local SQLite database, with the structure defined as standard SQL tables. To handle this, we use the storage-sqlite plugin, which allows RxDB to translate its collections and documents into SQL queries. This makes it possible to work with SQLite as if it were a document-based database with a fully reactive API.

//storage.ts

import {
  getRxStorageSQLiteTrial,
  getSQLiteBasicsQuickSQLite,
} from 'rxdb/plugins/storage-sqlite';
import { open } from 'react-native-nitro-sqlite';
import { wrappedValidateAjvStorage } from 'rxdb/plugins/validate-ajv';
import { wrappedKeyEncryptionCryptoJsStorage } from 'rxdb/plugins/encryption-crypto-js';
const sqliteBasics = getSQLiteBasicsQuickSQLite(open);
const storage = getRxStorageSQLiteTrial({ sqliteBasics });
const validatedStorage = wrappedValidateAjvStorage({ storage });
const encryptedStorage = wrappedKeyEncryptionCryptoJsStorage({
  storage: validatedStorage,
});
export { encryptedStorage };

Creating a Database Instance

Next, we will set up the database manager and initialize the collections. Below is the full code for the class. We will go over its key methods.

//Instance.ts

import { addRxPlugin, createRxDatabase, RxDatabase, WithDeleted } from 'rxdb';
import { RxDBDevModePlugin } from 'rxdb/plugins/dev-mode';
import { replicateRxCollection } from 'rxdb/plugins/replication';
import NetInfo from '@react-native-community/netinfo';
import {
  CheckPointType,
  MyDatabaseCollections,
  ReplicateCollectionDto,
} from './types.ts';
import { encryptedStorage } from './storage.ts';
import { defaultConflictHandler } from './utills.ts';
import { usersApi, userSchema, UserType } from '../features/users';
import { RxDBMigrationSchemaPlugin } from 'rxdb/plugins/migration-schema';
import { RxDBQueryBuilderPlugin } from 'rxdb/plugins/query-builder';
import { RxDBUpdatePlugin } from 'rxdb/plugins/update';
// for support query.update method
addRxPlugin(RxDBUpdatePlugin);
// for support chained query methods
addRxPlugin(RxDBQueryBuilderPlugin);
// for enabling data migration
addRxPlugin(RxDBMigrationSchemaPlugin);
export class RxDatabaseManager {
  private static instance: RxDatabaseManager;
  private db: RxDatabase<MyDatabaseCollections> | null = null;
  private isOnline = false;
  private constructor() {}
  public static getInstance(): RxDatabaseManager {
    if (!RxDatabaseManager.instance) {
      RxDatabaseManager.instance = new RxDatabaseManager();
    }
    return RxDatabaseManager.instance;
  }
  public async init(): Promise<RxDatabase<MyDatabaseCollections>> {
    if (this.db) return this.db;
    if (__DEV__) {
      // needs to be added in dev mode
      addRxPlugin(RxDBDevModePlugin);
    }
    this.db = await createRxDatabase<MyDatabaseCollections>({
      name: 'myDb',
      storage: encryptedStorage,
      multiInstance: false, // No multi-instance support for React Native
      closeDuplicates: true, // Close duplicate database instances
    });
    await this.db.addCollections({
      users: {
        schema: userSchema,
        conflictHandler: defaultConflictHandler,
        migrationStrategies: {
          // 1: function (oldDoc: UserType) {},
        },
      },
    });
    this.setupConnectivityListener();
    return this.db;
  }
  public getDb(): RxDatabase<MyDatabaseCollections> {
    if (!this.db) {
      throw new Error('Database not initialized. Call init() first.');
    }
    return this.db;
  }
  private replicateCollection<T>(dto: ReplicateCollectionDto<T>) {
    const { collection, replicationId, api } = dto;
    const replicationState = replicateRxCollection<WithDeleted<T>, number>({
      collection: collection,
      replicationIdentifier: replicationId,
      pull: {
        async handler(checkpointOrNull: unknown, batchSize: number) {
          const typedCheckpoint = checkpointOrNull as CheckPointType;
          const updatedAt = typedCheckpoint ? typedCheckpoint.updatedAt : 0;
          const id = typedCheckpoint ? typedCheckpoint.id : '';
          const response = await api.pull({ updatedAt, id, batchSize });
          return {
            documents: response.data.documents,
            checkpoint: response.data.checkpoint,
          };
        },
        batchSize: 20,
      },
      push: {
        async handler(changeRows) {
          console.log('push');
          const response = await api.push({ changeRows });
          return response.data;
        },
      },
    });
    replicationState.active$.subscribe(v => {
      console.log('Replication active$:', v);
    });
    replicationState.canceled$.subscribe(v => {
      console.log('Replication canceled$:', v);
    });
    replicationState.error$.subscribe(async error => {
      console.error('Replication error$:', error);
    });
  }
  private async startReplication() {
    const db = this.getDb();
    this.replicateCollection<UserType>({
      collection: db.users,
      replicationId: '/users/sync',
      api: {
        push: usersApi.push,
        pull: usersApi.pull,
      },
    });
  }
  private setupConnectivityListener() {
    NetInfo.addEventListener(state => {
      const wasOffline = !this.isOnline;
      this.isOnline = Boolean(state.isConnected);
      if (this.isOnline && wasOffline) {
        this.onReconnected();
      }
    });
  }
  private async onReconnected() {
    this.startReplication();
  }
}

When the app launches, we create an instance of RxDatabaseManager.

//App.tsx

useEffect(() => {
  const init = async () => {
    const dbManager = RxDatabaseManager.getInstance();
    dbManager
      .init()
      .then(() => {
        setAppStatus('ready');
      })
      .catch((error) => {
        console.log('Error initializing database:', error);
        setAppStatus('error');
      });
  };
  init();
}, []);

Data Replication: Syncing Client and Server

We call the onReconnected method when the app goes from offline to online. This triggers data synchronization between the local database and the server via replicateRxCollection.

RxDB then sends a checkpoint to the server  (updatedAt, id) which values from the last document that was successfully received.

//instance.ts

async handler(checkpointOrNull: unknown, batchSize: number) {
  const typedCheckpoint = checkpointOrNull as CheckPointType;
  const updatedAt = typedCheckpoint ? typedCheckpoint.updatedAt : 0;
  const id = typedCheckpoint ? typedCheckpoint.id : '';
  const response = await api.pull({ updatedAt, id, batchSize });
  return {
    documents: response.data.documents,
    checkpoint: response.data.checkpoint,
  };
},

The server returns a list of new/modified documents since then (documents) and a new checkpoint (checkpoint).

The server responds with a list of new or updated documents (documents) and new checkpoint.

//users.query-repository.ts

async pull(dto: PullUsersDto): Promise<UserViewDto[]> {
  const { id, updatedAt, batchSize } = dto;
  const users = await this.users
    .createQueryBuilder('user')
    .where('user.updatedAt > :updatedAt', { updatedAt })
    .orWhere('user.updatedAt = :updatedAt AND user.id > :id', {
      updatedAt,
      id,
    })
    .orderBy('user.updatedAt', 'ASC')
    .addOrderBy('user.id', 'ASC')
    .limit(batchSize)
    .getMany();
  return users.map(UserViewDto.mapToView);
}

//user.service.ts

async pull(dto: PullUsersDto) {
  const users = await this.usersRepository.pull(dto);
  const newCheckpoint =
    users.length === 0
      ? { id: dto.id, updatedAt: dto.updatedAt }
      : {
          id: users.at(-1)!.id,
          updatedAt: users.at(-1)!.updatedAt,
        };
  return {
    documents: users,
    checkpoint: newCheckpoint,
  };
}

RxDB then checks which documents have changed locally since the last sync. These changes are passed to changeRows and sent to the server via push.handler().

//instance.ts

async handler(changeRows) {
  const response = await api.push({ changeRows });
  return response.data;
},

On the server side, each incoming change is validated:

  • If there's no conflict, the change is applied.
  • If the server version has a newer updatedAt, a conflict is returned.

//user.service.ts

async push(dto: PushUsersDto) {
  const changeRows = dto.changeRows;
  const existingUsers = await this.usersRepository.findByIds(
    changeRows.map((changeRow) => changeRow.newDocumentState.id),
  );
  const existingMap = new Map(existingUsers.map((user) => [user.id, user]));
  const toSave: UserViewDto[] = [];
  const conflicts: UserViewDto[] = [];
  for (const changeRow of changeRows) {
    const newDoc = changeRow.newDocumentState;
    const existing = existingMap.get(newDoc.id);
    const isConflict = existing && existing.updatedAt > newDoc?.updatedAt;
    if (isConflict) {
      conflicts.push(existing);
    } else {
      toSave.push(newDoc);
    }
    if (toSave.length > 0) {
      await this.usersRepository.save(toSave);
    }
  }
  return conflicts;
}

Conflict Resolution

Any conflicts returned by the server are handled on the client side via conflictHandler.resolve().

In this example, we’re using a simple conflict resolution strategy: “Last update wins” Keep in mind that this approach might not be suitable for more complex scenarios.

//utills.ts

export const defaultConflictHandler: RxConflictHandler<{
  updatedAt: number;
}> = {
  isEqual(a, b) {
    return a.updatedAt === b.updatedAt;
  },
  resolve({ assumedMasterState, realMasterState, newDocumentState }) {
    return Promise.resolve(realMasterState);
  },
};

After a successful sync, RxDB updates its internal checkpoints, ensuring that only new changes are sent or requested during the next synchronization cycle.

Using Data in the App

Once the database is initialized, we update the app’s status to Ready and render the UI.

//UsersScreen.tsx

export const UsersScreen = () => {
  const users = useUsersSelector({
    sort: [{ updatedAt: 'desc' }],
  });
  const { createUser, deleteUser, updateUser } = useUsersService();
  return (
    <View style={styles.container}>
      {users.map(user => (
        <Text key={user.id}>{user.name}</Text>
      ))}
      <Button title={'Create new user'} onPress={createUser} />
      <Button
        disabled={users.length === 0}
        title={'Update user'}
        onPress={() => updateUser(users[0].id)}
      />
      <Button
        disabled={users.length === 0}
        title={'Delete user'}
        onPress={() => deleteUser(users[0].id)}
      />
    </View>
  );
};

Inside the useUsersService, we query the users collection with a specified filter and subscribe to changes in the results, which keeps the UI in sync automatically.

//user.selector.tsx

export const useUsersSelector = (query?: MangoQuery<UserType>) => {
  const userModel = RxDatabaseManager.getInstance().getDb().users;
  const [users, setUsers] = useState<UserType[]>([]);
  useEffect(() => {
    const subscription = userModel.find(query).$.subscribe(result => {
      setUsers(result);
    });
    return () => subscription.unsubscribe();
  }, [userModel, query]);
  return users;
};

Conclusion

In this example, we’ve demonstrated a basic implementation of a local-first architecture with client-server data synchronization. This approach helps keep data up to date and ensures the app remains functional without an internet connection.

For example, we used this approach to develop an app for riders of Sizl's dark kitchen in Chicago. In many parts of the city, connectivity is unstable. Previously, an order would not be considered complete unless the rider marked it as such in the app or uploaded a photo of the order at the door for contactless delivery. Now, they can perform these actions without an internet connection, and the app will synchronize the data later.

Sizl: How we became the tech partner for the Chicago-based Dark Kitchen Network - dev.family

Sizl: How we became the tech partner for the Chicago-based Dark Kitchen Network

Dark kitchen app for a fast-growing network offering food delivery and pickup in Chicago

However, real-world use cases often involve more complex scenarios, such as parallel updates from multiple devices, deletion of related data, or handling large volumes of documents. Such cases require additional logic and a more flexible architecture, which goes beyond the scope of this basic example.

Would you like to work with our team? Tell us about your project

You may also like: