Introduction
Expo is an open-source framework that simplifies React Native mobile app development by handling complex native configurations. In 2026, with SDK 52+, it excels in native performance, AI integration, and cloud builds via EAS. Why use it? It cuts development time by 50% thanks to Expo Router for file-based navigation, universal modules, and instant hot-reload. This intermediate tutorial walks you through building a todo-list app with authentication, REST API, and persistence. You'll learn to structure a scalable project, manage state, call secure APIs, and deploy to App Store/Google Play. Ideal for React devs with basic RN knowledge who want to level up. (128 words)
Prerequisites
- Node.js 20+ and npm/yarn/pnpm
- Knowledge of React and React Native
- Free Expo account (expo.dev)
- Expo Go app on iOS/Android for testing
- VS Code editor with Expo Tools extension
Project Installation and Setup
npm create expo-app TodoApp --template
cd TodoApp
npx expo install expo-router react-native-safe-area-context react-native-screens expo-linking expo-constants expo-status-bar
npx expo install @expo/vector-icons
npm install @tanstack/react-query
npx expo install expo-secure-store expo-auth-sessionThis command creates an Expo project with the default template, installs Expo Router for modern navigation, and essential dependencies like React Query for async state management, SecureStore for secure persistence, and AuthSession for OAuth auth. Avoid blank templates to save time on initial config.
Initial Expo Router Setup
Expo Router uses a file-based structure: each file in app/ becomes a screen. Update app.json to enable schemes and splash screen. Run npx expo start to test in Expo Go. The basic app shows a home screen.
Root Layout with Navigation
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
export default function RootLayout() {
return (
<>
<StatusBar style="dark" />
<Stack screenOptions={{ headerStyle: { backgroundColor: '#f4511e' } }}>
<Stack.Screen name="index" options={{ title: 'Accueil' }} />
<Stack.Screen name="login" options={{ title: 'Connexion' }} />
<Stack.Screen name="todos" options={{ title: 'Mes Todos' }} />
</Stack>
</>
);
}This layout sets up a Stack navigation with custom options like header color. It includes StatusBar for native integration. Pitfall: Forget expo-status-bar and the app crashes on Android; always test on real devices.
Login Screen with AuthSession
import { useEffect } from 'react';
import { View, Text, Button, StyleSheet } from 'react-native';
import * as WebBrowser from 'expo-auth-session/providers/google';
import { makeRedirectUri } from 'expo-auth-session';
import { useRouter } from 'expo-router';
const discovery = { authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', tokenEndpoint: 'https://oauth2.googleapis.com/token' };
export default function Login() {
const router = useRouter();
const [request, response, promptAsync] = WebBrowser.useAuthRequest({ clientId: 'VOTRE_GOOGLE_CLIENT_ID', scopes: ['profile', 'email'], redirectUri: makeRedirectUri() }, discovery);
useEffect(() => {
if (response?.type === 'success') {
const { authentication } = response;
// TODO: Stocker token avec SecureStore
router.push('/todos');
}
}, [response]);
return (
<View style={styles.container}>
<Text>Connectez-vous avec Google</Text>
<Button title="Se connecter" disabled={!request} onPress={() => promptAsync()} />
</View>
);
}
const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center' } });This screen implements Google auth via AuthSession with dynamic redirectUri. Replace 'VOTRE_GOOGLE_CLIENT_ID' with yours (created in Google Cloud Console). Caution: Configure authorized URIs in Google Console to avoid 403 errors.
State Management and API Calls with React Query
React Query handles queries and mutations for todos. Add a provider in _layout.tsx and a custom hook to fetch from a JSONPlaceholder API.
QueryClient Provider and Todos Screen
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { View, Text, FlatList, TextInput, Button, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
type Todo = { id: number; title: string; completed: boolean };
export default function Todos() {
const router = useRouter();
const queryClient = useQueryClient();
const [newTodo, setNewTodo] = React.useState('');
const { data: todos = [], isLoading } = useQuery<Todo[]>({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=10');
return res.json();
},
});
const mutation = useMutation({
mutationFn: (title: string) => fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
body: JSON.stringify({ title, completed: false }),
headers: { 'Content-Type': 'application/json' },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
setNewTodo('');
},
});
if (isLoading) return <Text>Chargement...</Text>;
return (
<View style={styles.container}>
<TextInput style={styles.input} value={newTodo} onChangeText={setNewTodo} placeholder="Nouveau todo" />
<Button title="Ajouter" onPress={() => mutation.mutate(newTodo)} />
<FlatList
data={todos}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => <Text style={styles.todo}>{item.title}</Text>}
/>
<Button title="Déconnexion" onPress={() => router.push('/login')} />
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 20 },
input: { borderWidth: 1, padding: 10, marginBottom: 10 },
todo: { padding: 10, borderBottomWidth: 1 },
});This screen fetches todos via React Query with a mutation to add new ones. FlatList optimizes rendering. Integrate QueryClientProvider in _layout.tsx (not shown for brevity). Pitfall: Without invalidateQueries, the UI won't refresh after mutations.
Secure Persistence with SecureStore
import * as SecureStore from 'expo-secure-store';
import { useEffect, useState } from 'react';
export function useAuth() {
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
SecureStore.getItemAsync('authToken').then(setToken);
}, []);
const login = async (newToken: string) => {
await SecureStore.setItemAsync('authToken', newToken);
setToken(newToken);
};
const logout = async () => {
await SecureStore.deleteItemAsync('authToken');
setToken(null);
};
return { token, login, logout };
}This custom hook manages auth tokens securely with SecureStore (Keychain equivalent). Use it in login.tsx to store after auth. On Android, enable useAndroidKeyStore in config for FIPS compliance.
EAS Configuration for Builds and Submission
npx eas login
npx eas build:configure
# Éditez eas.json
npx eas build --platform all --profile preview
npx eas submit --platform iosEAS CLI handles cloud builds without needing Xcode or Android Studio. Create eas.json with profiles (preview/production). First build generates credentials; reuse them. Skip local builds for better scalability.
Best Practices
- Use TypeScript everywhere: Catches 80% of runtime bugs with prop and API types.
- Adopt Expo Router: Declarative navigation, SEO-ready for web.
- Cache with React Query: Stale-while-revalidate for offline-first apps.
- Secure tokens: Always use SecureStore, never AsyncStorage.
- Test on devices: Expo Go for dev, EAS for prod.
Common Errors to Avoid
- Forgetting
expo install: Native libs crash without prebuild. - Ignoring schemes in app.json: Auth redirects fail.
- No error boundaries: One failed query crashes the whole app.
- Builds without credentials: EAS blocks on iOS provisioning.
Next Steps
- Official docs: Expo.dev
- Advanced: Integrate Supabase for realtime backend.
- Training: Check our Learni React Native courses
- Example GitHub repo: github.com/learni-dev/expo-todo-app-2026