← Back to list

Reactive Data Pipelines in React Native: TanStack Query + SQLite Sync at Scale

Building offline-first reactive architectures with Drizzle ORM, background sync, and cache invalidation

Sirsha Banerjee · 2026-05-27 08:11 · 50 claps · 3.5 min read
#react-native #tanstack-query #drizzle #reactive-database
Open on Medium ↗
Wiki topics: 🌐 · Web Development 📱 · Mobile Development 🔧 · Data Engineering 🏛️ · Architecture

Reactive Data Pipelines in React Native: TanStack Query + SQLite Sync at Scale

Building offline-first reactive architectures with Drizzle ORM, background sync, and cache invalidation

Most React Native apps fetch data the same way. A screen mounts → A useEffect fires → An API gets called → The result gets pushed into local state → Redux or context. This feels simple but as the app grows and we have situations like another screen needs the same data. You add polling. Then offline support becomes a requirement. Push notifications arrive. Background refresh appears. Locale changes need to re-fetch content.

Suddenly, every screen starts owning its own synchronization logic:

  • loading states,
  • retries,
  • stale data,
  • race conditions,
  • duplicated requests,
  • manual cache coordination.

And eventually, the synchronization logic becomes more complicated than the UI itself. I hit that point in my React Native application and stopped thinking about screens as “things that fetch data” and moved towards a reactive sync pipeline architecture where:

  • background tasks fetch remote data,
  • SQLite becomes the local source of truth,
  • TanStack Query handles reactivity,
  • and screens simply react to database changes.

No screen directly calls APIs anymore. The UI doesn’t coordinate synchronization. It just reacts.

The Core Philosophy

Data should not be fetched because a screen mounted.

It should be: synchronized, persisted, invalidated, and reactively consumed.

Instead of:

useEffect(() => {
  fetchData();
}, []);

…the system becomes:

Background Sync → SQLite → Cache Invalidation → Reactive Queries → UI Updates

The pipeline works like this:

Why Traditional Mobile Data Fetching Broke at Scale

The classic React Native pattern usually looks like this:

useEffect(() => {
  const fetch = async () => {
    setLoading(true);

    try {
      const data = await fetchFromAPI(orderId);
      setOrder(data);
    } catch (e) {
      setError(e);
    } finally {
      setLoading(false);
    }
  };

  fetch();

  const interval = setInterval(fetch, 60000);

  return () => clearInterval(interval);
}, [orderId]);

Layer 1 — The API Layer

Pure async functions. Nothing more.

export const fetchConsents = async (
  cookieId: string,
): Promise<ConsentResponse> => {
  return apiClient.get(`/consents/${cookieId}`);
};

No hooks. No state. No cache awareness. No React knowledge.

Layer 2 — SQLite as the Local Source of Truth

The application uses:

  • expo-sqlite
  • Drizzle ORM
  • a singleton database instance
  • Write-Ahead Logging (WAL)
export const getDatabase = () => {
  if (!databaseInstance) {
    const clientDatabase = openDatabaseSync(DATABASE_NAME);

    configureConnection(clientDatabase);

    databaseInstance = drizzle(clientDatabase, { schema });
  }

  return databaseInstance;
};

The database is shared everywhere:

  • React hooks,
  • sync tasks,
  • background jobs,
  • push notification handlers.

One database. One persistence layer. No competing state containers.

Layer 3 — Sync as a Query

This became the centerpiece of the architecture.

I built a small abstraction called useSyncQuery.

useQuery({
  queryKey: syncConfig.syncKey,
  queryFn: createWrappedSyncFn(syncConfig),

  staleTime: (result) =>
    result.state.data ? Infinity : 0,

  gcTime: Infinity,

  refetchInterval: syncConfig.pollInterval,
});

The Key Insight: Dynamic staleTime

Meaning:

  • successful syncs are never automatically stale,
  • failed syncs are immediately stale.

That means:

  • successful sync tasks don’t constantly re-run,
  • failed sync tasks retry naturally through TanStack Query behavior.

This allowed synchronization logic to become reactive instead of imperative.

Imperative Sync Triggers

Sometimes synchronization still needs manual triggers. Like push notification recovery, locale changes, pull-to-refresh, background events.

For those cases, I added runSyncTask.

queryClient.fetchQuery({
  queryKey: syncConfig.syncKey,
  queryFn: createWrappedSyncFn(syncConfig, false),
  staleTime: 0,
});

The important part: Even imperative triggers still go through TanStack Query.

That means, deduplication still works, concurrent sync requests collapse together, synchronization remains centralized.

Foreground polling

Controlled through Firebase Remote Config.

Background tasks

Using Expo Background Task APIs.

Event-driven sync

Push notifications. Locale changes. Manual refreshes. All of them converge on the same sync pipeline.

Cache Invalidation Becomes the Real Engine

Once data is written into SQLite, synchronization isn’t finished.

The critical step is:

queryClient.invalidateQueries(...)

After invalidation: active queries re-run → Drizzle re-reads SQLite → hooks receive updated data → screens re-render automatically.

Layer 4— Query Hooks Read Only from SQLite

useQuery({
  queryFn: () => getOrderById(orderKey),
  queryKey: orderDatabaseQueryKeys.orderById(
    orderKey.orderId,
    locale,
  ),
});

Notice what’s missing: no API calls, no fetch logic, no loading orchestration, no synchronization logic. The hook only reads SQLite. This separation massively simplified UI logic.

Screens Became Thin Controllers

The screens themselves became lightweight orchestration layers.

const { order, loadingState } =
  useOrderLoader(params.orderId);

return (
  <BoundContent
    data={DataItem}
    loadingState={loadingState}
  />
);

Offline-First by Default

Since screens always read SQLite:

  • cached data remains available offline,
  • UI continues functioning without network access,
  • synchronization resumes later automatically.

Instant Cached Reads

On app reopen:

  • SQLite data appears immediately,
  • TanStack Query returns cached state instantly,
  • background synchronization updates silently afterward.

Locale Changes Became Reactive

Changing language invalidates locale-aware query keys. The UI updates automatically. No manual refetch coordination.

Push Notification Recovery Became Trivial

If push notifications fail or get dropped:

  • runSyncTask() manually catches up synchronization.

Conclusion:

Instead of screens coordinating network state, background sync tasks persist data into SQLite, TanStack Query handles reactivity, and the UI simply reflects current local state.

The is a cleaner architecture that naturally supports offline-first behavior, background sync, cache consistency, and reactive updates without scattering synchronization logic across the UI.


메타데이터
post_id
4efe09d925d3
slug
reactive-data-pipelines-in-react-native-tanstack-query-sqlite-sync-at-scale-4efe09d925d3
url
https://medium.com/@_.sirsha/reactive-data-pipelines-in-react-native-tanstack-query-sqlite-sync-at-scale-4efe09d925d3
canonical_url
https://medium.com/@_.sirsha/reactive-data-pipelines-in-react-native-tanstack-query-sqlite-sync-at-scale-4efe09d925d3
author_url
https://medium.com/@_.sirsha
status
ok
fetched_at
2026-06-16 19:09:56