Working Offline

Detect connectivity, persist the query cache, queue writes until the network returns, and show which state the user is in.

SyntaxonlineManager.setEventListener((setOnline) => NetInfo.addEventListener((state) => setOnline(!!state.isConnected)))

A phone loses the network constantly: lifts, tunnels, hotel Wi-Fi that resolves nothing. Offline support is three separate jobs, and most apps only do the first.

Know the state

@react-native-community/netinfo reports it. NetInfo.fetch() resolves with the state now; NetInfo.addEventListener(handler) returns an unsubscribe function and fires on every change. The fields that matter are isConnected (an interface is up) and isInternetReachable (something answered).

Show data you already have

React Query keeps its cache in memory, so a cold launch offline shows nothing. createAsyncStoragePersister plus PersistQueryClientProvider writes that cache to AsyncStorage and restores it before the first render. Raise gcTime to match, or the cache is collected before it is ever written.

Queue the writes

React Query's default networkMode is 'online': offline mutations do not fail, they pause. Give the client an onlineManager event listener wired to NetInfo and they resume when the network returns. To survive an app restart, register the function with setMutationDefaults — functions cannot be serialised, so a restored mutation needs one to call — and run resumePausedMutations() once the cache has rehydrated.

Then say so

None of this helps if a tap looks like it did nothing. Show a banner for the connection and a pending marker on the row the user changed.

Example

Example · tsx
import AsyncStorage from '@react-native-async-storage/async-storage';
import NetInfo from '@react-native-community/netinfo';
import { QueryClient, onlineManager } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import Screens from './Screens'; // your own navigator or root screen

// There is no browser here, so tell React Query what "online" means on a phone.
onlineManager.setEventListener((setOnline) => {
  return NetInfo.addEventListener((state) => {
    setOnline(!!state.isConnected);
  });
});

const queryClient = new QueryClient({
  // Cached data must outlive the session or there is nothing to persist.
  defaultOptions: { queries: { gcTime: 1000 * 60 * 60 * 24 } },
});

// A paused mutation restored from storage has no function to call, so name one.
queryClient.setMutationDefaults(['notes'], {
  mutationFn: async (body: string) => {
    await fetch('https://api.example.com/notes', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ body }),
    });
  },
});

const persister = createAsyncStoragePersister({ storage: AsyncStorage });

export default function App() {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{ persister, maxAge: 1000 * 60 * 60 * 24 }}
      onSuccess={() => {
        // Runs after the cache is rehydrated: send anything queued last session.
        queryClient.resumePausedMutations();
      }}
    >
      <Screens />
    </PersistQueryClientProvider>
  );
}

When to use it

  • Open a list screen on a train and still show the rows that were loaded this morning.
  • Let someone write a note or tick a checkbox on a plane and have it send on landing.
  • Stop showing an error screen on flaky Wi-Fi when cached data would do.
  • Tell the user plainly that three of their changes have not reached the server yet.

More examples

An offline banner

On screen: nothing at all while connected, then a dark red strip across the top with one line of white text. Mount it above your navigator so it covers every screen. Two details are deliberate: the comparisons are against false rather than falsy, because both fields can be null while unknown, and the effect returns the unsubscribe function so the listener dies with the component.

Example · tsx
import { useEffect, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import NetInfo from '@react-native-community/netinfo';

export default function OfflineBanner() {
  const [offline, setOffline] = useState(false);

  useEffect(() => {
    // fetch() gives the state now; the listener gives every change after.
    NetInfo.fetch().then((state) => setOffline(state.isConnected === false));
    const unsubscribe = NetInfo.addEventListener((state) => {
      setOffline(state.isConnected === false || state.isInternetReachable === false);
    });
    return unsubscribe; // cleanup: stop listening when this unmounts
  }, []);

  if (!offline) {
    return null;
  }

  return (
    <View style={styles.bar}>
      <Text style={styles.text}>Offline. Changes will send when you reconnect.</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  bar: { backgroundColor: '#8a1f11', paddingHorizontal: 16, paddingVertical: 8 },
  text: { color: '#ffffff', fontSize: 13, textAlign: 'center' },
});

A mutation that waits for the network

On screen: a blue button, with the grey hint appearing beside it while a write is parked. isPaused is the flag to render from — offline, the mutation is neither loading nor failed, and showing an error for it would be a lie. flexDirection is set to row explicitly here because the default in React Native is column, unlike a browser's flex container.

Example · tsx
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useMutation } from '@tanstack/react-query';

export default function AddNote() {
  // No mutationFn here: it comes from setMutationDefaults for this key,
  // which is what lets a queued write survive an app restart.
  const addNote = useMutation<void, Error, string>({ mutationKey: ['notes'] });

  return (
    <View style={styles.row}>
      <Pressable style={styles.button} onPress={() => addNote.mutate('Buy milk')}>
        <Text style={styles.buttonText}>Add note</Text>
      </Pressable>
      {addNote.isPaused ? <Text style={styles.hint}>Queued until you are online</Text> : null}
    </View>
  );
}

const styles = StyleSheet.create({
  row: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 16 },
  button: { backgroundColor: '#1f6feb', paddingHorizontal: 16, paddingVertical: 10, borderRadius: 8 },
  buttonText: { color: '#ffffff', fontSize: 16 },
  hint: { fontSize: 13, color: '#6e7781' },
});

Refetch when the app comes back

React Query's refetch-on-focus was written for a browser tab, and there is no window focus event on a phone. AppState reports 'active', 'background' and 'inactive' instead; forwarding it to focusManager means stale data refreshes when someone returns to the app after an hour in their pocket. Call this hook once near the root. The Platform.OS guard matters only if the same code also builds for web, where the browser handles focus itself.

Example · tsx
import { useEffect } from 'react';
import { AppState, Platform } from 'react-native';
import type { AppStateStatus } from 'react-native';
import { focusManager } from '@tanstack/react-query';

function onAppStateChange(status: AppStateStatus) {
  if (Platform.OS !== 'web') {
    focusManager.setFocused(status === 'active');
  }
}

export function useAppFocusRefetch() {
  useEffect(() => {
    const subscription = AppState.addEventListener('change', onAppStateChange);
    return () => subscription.remove();
  }, []);
}

Discussion

  • Be the first to comment on this lesson.