React Native course Β· Module 4: State and API data

Project: Atlantis Vault - Complete Application

4 min read Β·
In this lesson5

Let's combine all the techniques we have learned into a complete project!

Project Structure

src/
β”œβ”€β”€ api/
β”‚   β”œβ”€β”€ client.ts         # Axios configuration
β”‚   └── treasureService.ts
β”œβ”€β”€ stores/
β”‚   β”œβ”€β”€ authStore.ts      # Zustand - authentication
β”‚   └── settingsStore.ts  # Zustand - settings
β”œβ”€β”€ hooks/
β”‚   β”œβ”€β”€ useTreasures.ts   # React Query hooks
β”‚   └── useAuth.ts
β”œβ”€β”€ screens/
β”‚   β”œβ”€β”€ HomeScreen.tsx
β”‚   β”œβ”€β”€ TreasureListScreen.tsx
β”‚   └── TreasureDetailScreen.tsx
└── components/
    β”œβ”€β”€ TreasureCard.tsx
    └── LoadingSpinner.tsx

API Client with Token Refresh

// api/client.ts
import axios from 'axios';
import { useAuthStore } from '@/stores/authStore';

const apiClient = axios.create({
  baseURL: 'https://api.atlantis.app',
  timeout: 15000,
});

// Request interceptor
apiClient.interceptors.request.use(async (config) => {
  const token = useAuthStore.getState().token;
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// Response interceptor with refresh token
apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        const refreshToken = useAuthStore.getState().refreshToken;
        const response = await axios.post(
          'https://api.atlantis.app/auth/refresh',
          { refreshToken }
        );

        const { token, refreshToken: newRefreshToken } = response.data;
        useAuthStore.getState().setTokens(token, newRefreshToken);

        originalRequest.headers.Authorization = `Bearer ${token}`;
        return apiClient(originalRequest);
      } catch (refreshError) {
        useAuthStore.getState().logout();
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  }
);

export default apiClient;

Auth Store with Zustand + Persist

// stores/authStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';

interface User {
  id: string;
  email: string;
  name: string;
}

interface AuthStore {
  user: User | null;
  token: string | null;
  refreshToken: string | null;
  isAuthenticated: boolean;
  setUser: (user: User) => void;
  setTokens: (token: string, refreshToken: string) => void;
  logout: () => void;
}

export const useAuthStore = create<AuthStore>()(
  persist(
    (set) => ({
      user: null,
      token: null,
      refreshToken: null,
      isAuthenticated: false,

      setUser: (user) => set({ user, isAuthenticated: true }),

      setTokens: (token, refreshToken) => set({ token, refreshToken }),

      logout: () =>
        set({
          user: null,
          token: null,
          refreshToken: null,
          isAuthenticated: false,
        }),
    }),
    {
      name: 'auth-storage',
      storage: createJSONStorage(() => AsyncStorage),
    }
  )
);

Custom Hooks with React Query

// hooks/useTreasures.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { treasureService, Treasure } from '@/api/treasureService';

export function useTreasures() {
  return useQuery({
    queryKey: ['treasures'],
    queryFn: treasureService.getAll,
  });
}

export function useTreasure(id: string) {
  return useQuery({
    queryKey: ['treasure', id],
    queryFn: () => treasureService.getById(id),
    enabled: !!id,
  });
}

export function useCreateTreasure() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: treasureService.create,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['treasures'] });
    },
  });
}

export function useDeleteTreasure() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: treasureService.delete,
    onMutate: async (id) => {
      await queryClient.cancelQueries({ queryKey: ['treasures'] });
      const previous = queryClient.getQueryData(['treasures']);
      queryClient.setQueryData(['treasures'], (old: Treasure[] | undefined) =>
        old?.filter((t) => t.id !== id)
      );
      return { previous };
    },
    onError: (err, id, context) => {
      queryClient.setQueryData(['treasures'], context?.previous);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['treasures'] });
    },
  });
}

Screen Using All Techniques

// screens/TreasureListScreen.tsx
import { useTreasures, useDeleteTreasure } from '@/hooks/useTreasures';
import { useAuthStore } from '@/stores/authStore';

export function TreasureListScreen() {
  const { user } = useAuthStore();
  const { data: treasures, isLoading, error, refetch, isRefetching } = useTreasures();
  const deleteMutation = useDeleteTreasure();

  const handleDelete = (id: string) => {
    Alert.alert(
      'Confirm',
      'Are you sure you want to delete this treasure?',
      [
        { text: 'Cancel', style: 'cancel' },
        {
          text: 'Delete',
          style: 'destructive',
          onPress: () => deleteMutation.mutate(id),
        },
      ]
    );
  };

  if (isLoading) {
    return <LoadingSpinner />;
  }

  if (error) {
    return (
      <ErrorView
        message={error.message}
        onRetry={refetch}
      />
    );
  }

  return (
    <View style={styles.container}>
      <Text style={styles.welcome}>Welcome, {user?.name}!</Text>

      <FlatList
        data={treasures}
        refreshing={isRefetching}
        onRefresh={refetch}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => (
          <TreasureCard
            treasure={item}
            onDelete={() => handleDelete(item.id)}
          />
        )}
        ListEmptyComponent={
          <Text style={styles.empty}>No treasures in the collection</Text>
        }
      />
    </View>
  );
}

This project combines Zustand for local state (auth, settings), React Query for server state, and Axios for API communication!

Code for this lesson: store.ts
import { create } from 'zustand';

interface AuthStore {
  user: string | null;
  token: string | null;
  // TODO: add a login(user, token) action that sets both fields
  login: (user: string, token: string) => void;
  logout: () => void;
}

export const useAuthStore = create<AuthStore>((set) => ({
  user: null,
  token: null,
  login: (user, token) => set({ user, token }),
  logout: () => set({ user: null, token: null }),
}));

Docs entries used in this lesson

Spotted a mistake in this lesson?

Hands-on tasks in the game

  • Code editor

    Complete the code integrating all techniques.

Useful articles