By using this site, you agree to the Privacy Policy and Terms of Use.
Accept
World of SoftwareWorld of SoftwareWorld of Software
  • News
  • Software
  • Mobile
  • Computing
  • Gaming
  • Videos
  • More
    • Gadget
    • Web Stories
    • Trending
    • Press Release
Search
  • Privacy
  • Terms
  • Advertise
  • Contact
Copyright © All Rights Reserved. World of Software.
Reading: How I Built Local-First Apps with React Native + RxDB (and Why Your App Probably Needs This Too) | HackerNoon
Share
Sign In
Notification Show More
Font ResizerAa
World of SoftwareWorld of Software
Font ResizerAa
  • Software
  • Mobile
  • Computing
  • Gadget
  • Gaming
  • Videos
Search
  • News
  • Software
  • Mobile
  • Computing
  • Gaming
  • Videos
  • More
    • Gadget
    • Web Stories
    • Trending
    • Press Release
Have an existing account? Sign In
Follow US
  • Privacy
  • Terms
  • Advertise
  • Contact
Copyright © All Rights Reserved. World of Software.
World of Software > Computing > How I Built Local-First Apps with React Native + RxDB (and Why Your App Probably Needs This Too) | HackerNoon
Computing

How I Built Local-First Apps with React Native + RxDB (and Why Your App Probably Needs This Too) | HackerNoon

News Room
Last updated: 2025/08/11 at 12:07 PM
News Room Published 11 August 2025
Share
SHARE

You know that moment when you’re in the middle of nowhere, your 4G turns into “E”, and your mobile app decides to become a very expensive paperweight? Yeah. That’s exactly why I wrote this.

Picture a delivery driver staring at a loading spinner because the app can’t load their route without internet. Or a warehouse manager unable to pull the inventory list because the server’s down. That’s not “quirky behavior,” that’s lost time, lost money, and very angry users.

A bunch of our clients hit these connectivity potholes — remote job sites, offsite events, warehouse floors, logistics hubs. Basically, anywhere Wi-Fi goes to die. The fix? Local-first apps: process data locally, sync it when you can. Your users stay happy, and you don’t get midnight “the app’s broken” calls.

In this little adventure, I’ll show you how I built a local-first mobile app with React Native + RxDB. You’ll see:

  • How two-way sync actually works without melting your server.
  • How to handle “fun” situations like data conflicts.
  • What not to forget when designing one of these beasts.

Also — I’ll share one “last update wins” trick. Not always the right choice, but in our case… chef’s kiss.

The Stack (a.k.a. My Chosen Weapons)

For this build, I rolled with:

  • React Native — cross-platform magic: one codebase, iOS + Android.
  • react-native-nitro-sqlite — because local storage without SQLite is like pizza without cheese.
  • RxDB — offline-first, reactive DB that plays nice with sync.
  • NestJS + TypeORM + PostgreSQL — the backend dream team.

The end result: app works offline, syncs later, and survives dodgy connections without having a meltdown.

Step 1 — Local Storage via SQLite

First up, I needed a local database that RxDB could happily abuse. SQLite is the obvious choice, but I wrapped it with some extra powers — validation and encryption — because data integrity and privacy matter (and also because my future self will thank me).

//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 };

Yes, that’s three layers of wrapping. Like an onion. Or an enterprise app with way too many middleware layers.

Step 2 — Creating the Database Instance

Next, I built a RxDatabaseManager singleton. Because if you think having multiple instances of your DB is a good idea… you probably also enjoy merge conflicts in production.

Here’s the class in all its glory:

//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();
  }
}

This little beast:

  • Sets up the DB.
  • Watches for internet like a clingy ex.
  • Syncs as soon as we’re back online.

And yes, it logs everything. Future me will be grateful when debugging next time.

Step 3 — Bootstrapping the DB When the App Starts

When the app launches, I spin up my RxDatabaseManager instance. No magic here — just the good ol’ useEffect doing its thing.

If something explodes during init, I log it. Because pretending errors don’t exist is how you get haunted apps.

//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();
}, []);

Step 4 — Data Replication (a.k.a. Syncing Without Tears)

When the app goes from “offline cave mode” back online, onReconnected() fires. That kicks off data sync between the local DB and the server via replicateRxCollection.

Here’s the basic pull handler — RxDB sends a checkpoint (updatedAt, id) so the server knows where we left off. Because nobody wants to fetch the entireDB every time.

//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,
  };
},

On the server, I query only the new/updated stuff since the last checkpoint. Because bandwidth is precious, and so is my patience.

//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);
}

And then the server sends back both the docs and a shiny new checkpoint:

//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,
  };
}

Step 5 — Pushing Local Changes Back to the Server

RxDB also tracks what’s changed locally since the last sync and pushes it up. Think of it like Dropbox for your app’s data — without the random “conflicted copy” files (well… unless you handle conflicts badly).

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

On the backend, I check each incoming change:

  • If there’s no conflict, it goes straight in.
  • If the server’s version is newer, I throw it into the conflicts pile.
//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;
}

Step 6 — Conflict Resolution (The “Last Update Wins” Gambit)

Here’s where people usually overcomplicate things. Yes, you could build a NASA-grade merge strategy. Or… you could go with “last update wins” and call it a day.

It’s not always the right move, but in our case — simple, fast, good enough.

//utills.ts

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

Step 7 — Keeping the UI in Sync

After init, the app status flips to Ready, and we just… use it. No weird manual refresh buttons, no “tap to reload” nonsense.

//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>
  );
};

useUsersSelectorsubscribes to changes in the DB, so the UI updates itself. This is one of those “works like magic” moments that you shouldn’t think too hard about — just enjoy it.

//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;
};

Final Thoughts

We’ve basically built an app that:

  • Works offline.
  • Syncs automatically.
  • Doesn’t care if your Wi-Fi is having an existential crisis.

We used this same setup for Sizl’s dark kitchen riders in Chicago, where connectivity is… let’s just say “urban adventure mode.” Riders can now finish orders, take proof-of-delivery photos, and mark deliveries complete without internet. The app syncs later.

Sure, real-world cases can get gnarlier — multiple devices updating the same record, related data deletions, massive datasets. But the pattern holds. You just need to extend it with smarter conflict resolution and a more flexible architecture.

Would I recommend going local-first? Absolutely — unless you enjoy user rage tickets that start with “I was offline and…”

Click the link in my bio for more info on this one & other projects!

To check out our open source admin panel on React see our Github.

Sign Up For Daily Newsletter

Be keep up! Get the latest breaking news delivered straight to your inbox.
By signing up, you agree to our Terms of Use and acknowledge the data practices in our Privacy Policy. You may unsubscribe at any time.
Share This Article
Facebook Twitter Email Print
Share
What do you think?
Love0
Sad0
Happy0
Sleepy0
Angry0
Dead0
Wink0
Previous Article iPhone 17 Pro Leak Reveals Alleged New 5G Antenna Design
Next Article An AI Model for the Brain Is Coming to the ICU
Leave a comment

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Stay Connected

248.1k Like
69.1k Follow
134k Pin
54.3k Follow

Latest News

‘Ghost malls’ make comeback after closures & some unusual tenants are moving in
News
Cursor’s New Pricing Blew My Budget, So I Built a Usage Tracker | HackerNoon
Computing
Trump White House celebrates latest chapter of wins at 200-day mark
Computing
Iconic internet provider to switch off in weeks after 34 YEARS in the business
News

You Might also Like

Computing

Cursor’s New Pricing Blew My Budget, So I Built a Usage Tracker | HackerNoon

6 Min Read

Trump White House celebrates latest chapter of wins at 200-day mark

12 Min Read
Computing

Updated Inclusive Language Guide Calls Out “Sanity Check”, “Hung”, “Native Support”

2 Min Read
Computing

Huawei announces launch of first HarmonyOS PC on May 19 · TechNode

1 Min Read
//

World of Software is your one-stop website for the latest tech news and updates, follow us now to get the news that matters to you.

Quick Link

  • Privacy Policy
  • Terms of use
  • Advertise
  • Contact

Topics

  • Computing
  • Software
  • Press Release
  • Trending

Sign Up for Our Newsletter

Subscribe to our newsletter to get our newest articles instantly!

World of SoftwareWorld of Software
Follow US
Copyright © All Rights Reserved. World of Software.
Welcome Back!

Sign in to your account

Lost your password?