Урок 05

Офлайн-режим

Мобильные приложения часто работают без интернета. Нужно это учитывать.

NetInfo

Определяет состояние сети:

npx expo install @react-native-community/netinfo
import NetInfo from '@react-native-community/netinfo';

const unsubscribe = NetInfo.addEventListener((state) => {
    console.log('Connected:', state.isConnected);
    console.log('Type:', state.type); // 'wifi' | 'cellular' | 'none' | ...
});

return () => unsubscribe();

Хук онлайн-статуса

function useOnlineStatus() {
    const [online, setOnline] = useState(true);

    useEffect(() => {
        const unsub = NetInfo.addEventListener((state) => {
            setOnline(!!state.isConnected);
        });
        return () => unsub();
    }, []);

    return online;
}

Индикатор офлайн

function Screen() {
    const online = useOnlineStatus();

    return (
        <View style={{ flex: 1 }}>
            {!online && (
                <View style={styles.offlineBanner}>
                    <Text>Нет подключения к интернету</Text>
                </View>
            )}
            {/* контент */}
        </View>
    );
}

TanStack Query + офлайн

import { onlineManager } from '@tanstack/react-query';

onlineManager.setEventListener((setOnline) => {
    return NetInfo.addEventListener((state) => {
        setOnline(!!state.isConnected);
    });
});

Запросы не выполняются, пока нет сети. При появлении — выполняются автоматически.

Кеш для офлайн

Persist Query Client сохраняет данные между запусками:

<PersistQueryClientProvider
    client={queryClient}
    persistOptions={{ persister, maxAge: 24 * 60 * 60 * 1000 }}
>
    <App />
</PersistQueryClientProvider>

Пользователь открывает приложение офлайн — данные из кеша показываются сразу.

Мутации в офлайн

Очередь мутаций:

useMutation({
    mutationFn: createPost,
    onMutate: async (data) => {
        // оптимистично добавить
    },
});

TanStack Query сохраняет мутации и повторяет при появлении сети (с persistQueryClient + defaultOptions.mutations).

AsyncStorage для черновиков

function useDraft(key: string) {
    const [draft, setDraft] = useState('');

    useEffect(() => {
        AsyncStorage.getItem(`draft:${key}`).then((v) => v && setDraft(v));
    }, [key]);

    useEffect(() => {
        const timeout = setTimeout(() => {
            AsyncStorage.setItem(`draft:${key}`, draft);
        }, 500);
        return () => clearTimeout(timeout);
    }, [key, draft]);

    return [draft, setDraft] as const;
}

Синхронизация

Паттерн: локальные изменения → очередь → отправка при появлении сети.

type PendingChange = {
    id: string;
    type: 'create' | 'update' | 'delete';
    endpoint: string;
    data: unknown;
};

async function queueChange(change: PendingChange) {
    const queue = JSON.parse(await AsyncStorage.getItem('pending') ?? '[]');
    queue.push(change);
    await AsyncStorage.setItem('pending', JSON.stringify(queue));
}

async function flushQueue() {
    const queue: PendingChange[] = JSON.parse(await AsyncStorage.getItem('pending') ?? '[]');
    const remaining = [];

    for (const change of queue) {
        try {
            await api.request({
                method: change.type === 'delete' ? 'delete' : change.type === 'create' ? 'post' : 'put',
                url: change.endpoint,
                data: change.data,
            });
        } catch {
            remaining.push(change);
        }
    }

    await AsyncStorage.setItem('pending', JSON.stringify(remaining));
}

Оптимистичные обновления офлайн

const mutation = useMutation({
    mutationFn: (data: PostInput) => api.post('/posts', data),
    onMutate: async (data) => {
        await queryClient.cancelQueries({ queryKey: ['posts'] });
        const previous = queryClient.getQueryData(['posts']);
        const optimistic = { ...data, id: Date.now() };
        queryClient.setQueryData(['posts'], (old: Post[]) => [...old, optimistic]);
        return { previous };
    },
    onError: (_, __, context) => {
        queryClient.setQueryData(['posts'], context?.previous);
    },
    onSettled: () => {
        queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
});

Пользователь видит изменения сразу, даже офлайн.

Итоги

  • NetInfo для статуса сети
  • onlineManager для TanStack Query
  • Persist для кеша
  • Черновики в AsyncStorage
  • Очередь мутаций для синхронизации
  • Оптимистичные обновления