CtrlK
BlogDocsLog inGet started
Tessl Logo

react-native-project-starter

Invalid
This skill can't be scored yet
Validation errors are blocking scoring. Review and fix them to unlock Quality, Impact and Security scores. See what needs fixing →
SKILL.md
Quality
Evals
Security

React Native Project Starter

Scaffold a production-ready React Native 0.76+ app with Expo SDK 52+, Expo Router for file-based navigation, Zustand for state management, TypeScript, EAS Build, platform-specific code, and AsyncStorage.

Prerequisites

  • Node.js >= 20.x
  • npm >= 10.x (or pnpm)
  • Expo CLI (npx expo)
  • EAS CLI (npm install -g eas-cli)
  • iOS: Xcode 16+ (macOS only)
  • Android: Android Studio with SDK 35+

Scaffold Command

npx create-expo-app@latest <project-name> --template tabs
cd <project-name>

# Install core dependencies
npx expo install expo-router expo-linking expo-constants expo-status-bar
npx expo install @react-native-async-storage/async-storage
npx expo install expo-secure-store
npx expo install expo-splash-screen expo-image

# State management
npm install zustand

# HTTP client
npm install axios

# Form handling
npm install react-hook-form zod @hookform/resolvers

# Dev dependencies
npm install -D @types/react @types/react-native

Project Structure

app/
  _layout.tsx                  # Root layout — providers, fonts, splash screen
  (tabs)/
    _layout.tsx                # Tab navigator layout
    index.tsx                  # Home tab — @page "/"
    profile.tsx                # Profile tab
  (auth)/
    _layout.tsx                # Auth flow layout (no tabs)
    login.tsx                  # Login screen
    register.tsx               # Register screen
  users/
    [id].tsx                   # Dynamic route — /users/:id
    index.tsx                  # User list — /users
  +not-found.tsx               # 404 screen
  +html.tsx                    # Custom HTML wrapper (web only)
components/
  ui/
    Button.tsx                 # Reusable UI components
    Input.tsx
    Card.tsx
    LoadingSpinner.tsx
  UserCard.tsx                 # Domain-specific components
  UserForm.tsx
hooks/
  useAuth.ts                   # Authentication hook
  useUsers.ts                  # Data fetching hook
stores/
  authStore.ts                 # Zustand auth store
  userStore.ts                 # Zustand user store
services/
  api.ts                       # Axios instance with interceptors
  auth.ts                      # Auth API calls
  users.ts                     # User API calls
lib/
  storage.ts                   # AsyncStorage wrapper
  constants.ts                 # App constants
  types.ts                     # Shared TypeScript types
assets/
  images/
  fonts/

Key Conventions

  • Expo Router uses file-based routing — file structure in app/ defines navigation
  • Layouts (_layout.tsx) wrap child routes and define navigation structure (tabs, stacks, drawers)
  • Parenthesized folders (group) create route groups without affecting the URL
  • Dynamic routes use [param].tsx syntax, accessed via useLocalSearchParams()
  • Zustand stores are plain hooks — no providers, no boilerplate
  • Platform-specific code via .ios.tsx / .android.tsx suffixes or Platform.select()
  • Use expo-secure-store for tokens/secrets, AsyncStorage for non-sensitive data
  • All API calls in services/ — components never call fetch/axios directly
  • TypeScript strict mode — no any types
  • Use expo-image instead of <Image> from React Native for better performance and caching

Essential Patterns

Root Layout — app/_layout.tsx

import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { useEffect } from 'react';
import * as SplashScreen from 'expo-splash-screen';
import { useFonts } from 'expo-font';

SplashScreen.preventAutoHideAsync();

export default function RootLayout() {
  const [fontsLoaded] = useFonts({
    'Inter-Regular': require('../assets/fonts/Inter-Regular.ttf'),
    'Inter-Bold': require('../assets/fonts/Inter-Bold.ttf'),
  });

  useEffect(() => {
    if (fontsLoaded) {
      SplashScreen.hideAsync();
    }
  }, [fontsLoaded]);

  if (!fontsLoaded) return null;

  return (
    <>
      <StatusBar style="auto" />
      <Stack screenOptions={{ headerShown: false }}>
        <Stack.Screen name="(tabs)" />
        <Stack.Screen name="(auth)" />
        <Stack.Screen name="users/[id]" options={{ headerShown: true, title: 'User' }} />
        <Stack.Screen name="+not-found" />
      </Stack>
    </>
  );
}

Tab Layout — app/(tabs)/_layout.tsx

import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';

export default function TabLayout() {
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: '#007AFF',
        headerShown: true,
      }}
    >
      <Tabs.Screen
        name="index"
        options={{
          title: 'Home',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="home" size={size} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="profile"
        options={{
          title: 'Profile',
          tabBarIcon: ({ color, size }) => (
            <Ionicons name="person" size={size} color={color} />
          ),
        }}
      />
    </Tabs>
  );
}

Screen with Data Fetching — app/users/index.tsx

import { View, FlatList, StyleSheet, ActivityIndicator, Pressable } from 'react-native';
import { Link } from 'expo-router';
import { useUsers } from '../../hooks/useUsers';
import { UserCard } from '../../components/UserCard';

export default function UsersScreen() {
  const { users, isLoading, error, refresh } = useUsers();

  if (isLoading) {
    return (
      <View style={styles.center}>
        <ActivityIndicator size="large" />
      </View>
    );
  }

  return (
    <FlatList
      data={users}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => (
        <Link href={`/users/${item.id}`} asChild>
          <Pressable>
            <UserCard user={item} />
          </Pressable>
        </Link>
      )}
      onRefresh={refresh}
      refreshing={isLoading}
      contentContainerStyle={styles.list}
    />
  );
}

const styles = StyleSheet.create({
  center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  list: { padding: 16, gap: 12 },
});

Dynamic Route — app/users/[id].tsx

import { View, Text, StyleSheet } from 'react-native';
import { useLocalSearchParams, Stack } from 'expo-router';
import { useEffect, useState } from 'react';
import { userService } from '../../services/users';
import type { User } from '../../lib/types';

export default function UserDetailScreen() {
  const { id } = useLocalSearchParams<{ id: string }>();
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    userService.getById(id).then(setUser);
  }, [id]);

  if (!user) return null;

  return (
    <>
      <Stack.Screen options={{ title: user.name }} />
      <View style={styles.container}>
        <Text style={styles.name}>{user.name}</Text>
        <Text style={styles.email}>{user.email}</Text>
      </View>
    </>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 16 },
  name: { fontSize: 24, fontWeight: 'bold' },
  email: { fontSize: 16, color: '#666', marginTop: 4 },
});

Zustand Store — stores/authStore.ts

import { create } from 'zustand';
import * as SecureStore from 'expo-secure-store';
import { authService } from '../services/auth';

interface AuthState {
  token: string | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
  loadToken: () => Promise<void>;
}

export const useAuthStore = create<AuthState>((set) => ({
  token: null,
  isAuthenticated: false,
  isLoading: true,

  login: async (email, password) => {
    const { token } = await authService.login(email, password);
    await SecureStore.setItemAsync('auth_token', token);
    set({ token, isAuthenticated: true });
  },

  logout: async () => {
    await SecureStore.deleteItemAsync('auth_token');
    set({ token: null, isAuthenticated: false });
  },

  loadToken: async () => {
    const token = await SecureStore.getItemAsync('auth_token');
    set({ token, isAuthenticated: !!token, isLoading: false });
  },
}));

API Service — services/api.ts

import axios from 'axios';
import * as SecureStore from 'expo-secure-store';
import Constants from 'expo-constants';

const API_URL = Constants.expoConfig?.extra?.apiUrl ?? 'http://localhost:8080';

export const api = axios.create({
  baseURL: `${API_URL}/api`,
  timeout: 10000,
  headers: { 'Content-Type': 'application/json' },
});

// Attach auth token to every request
api.interceptors.request.use(async (config) => {
  const token = await SecureStore.getItemAsync('auth_token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// Handle 401 globally
api.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      await SecureStore.deleteItemAsync('auth_token');
      // Navigate to login — handled by auth state listener
    }
    return Promise.reject(error);
  }
);

Domain Service — services/users.ts

import { api } from './api';
import type { User, CreateUserRequest } from '../lib/types';

export const userService = {
  getAll: async (): Promise<User[]> => {
    const { data } = await api.get('/users');
    return data.data;
  },

  getById: async (id: string): Promise<User> => {
    const { data } = await api.get(`/users/${id}`);
    return data.data;
  },

  create: async (body: CreateUserRequest): Promise<User> => {
    const { data } = await api.post('/users', body);
    return data.data;
  },

  update: async (id: string, body: Partial<CreateUserRequest>): Promise<User> => {
    const { data } = await api.put(`/users/${id}`, body);
    return data.data;
  },

  delete: async (id: string): Promise<void> => {
    await api.delete(`/users/${id}`);
  },
};

Custom Hook — hooks/useUsers.ts

import { useEffect, useState, useCallback } from 'react';
import { userService } from '../services/users';
import type { User } from '../lib/types';

export function useUsers() {
  const [users, setUsers] = useState<User[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const fetch = useCallback(async () => {
    setIsLoading(true);
    setError(null);
    try {
      const data = await userService.getAll();
      setUsers(data);
    } catch (e) {
      setError(e instanceof Error ? e.message : 'Failed to fetch users');
    } finally {
      setIsLoading(false);
    }
  }, []);

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

  return { users, isLoading, error, refresh: fetch };
}

Platform-Specific Code

// components/ui/Button.tsx — shared
import { Platform, Pressable, Text, StyleSheet } from 'react-native';

interface ButtonProps {
  title: string;
  onPress: () => void;
  variant?: 'primary' | 'secondary';
}

export function Button({ title, onPress, variant = 'primary' }: ButtonProps) {
  return (
    <Pressable
      onPress={onPress}
      style={({ pressed }) => [
        styles.button,
        variant === 'primary' ? styles.primary : styles.secondary,
        pressed && styles.pressed,
        // Platform-specific shadow
        Platform.select({
          ios: styles.iosShadow,
          android: { elevation: 4 },
        }),
      ]}
    >
      <Text style={[styles.text, variant === 'secondary' && styles.secondaryText]}>
        {title}
      </Text>
    </Pressable>
  );
}

const styles = StyleSheet.create({
  button: { paddingVertical: 12, paddingHorizontal: 24, borderRadius: 8, alignItems: 'center' },
  primary: { backgroundColor: '#007AFF' },
  secondary: { backgroundColor: 'transparent', borderWidth: 1, borderColor: '#007AFF' },
  pressed: { opacity: 0.7 },
  text: { color: '#fff', fontSize: 16, fontWeight: '600' },
  secondaryText: { color: '#007AFF' },
  iosShadow: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 4 },
});

Types — lib/types.ts

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

export interface CreateUserRequest {
  email: string;
  name: string;
  password: string;
}

export interface AuthResponse {
  token: string;
  user: User;
}

AsyncStorage Wrapper — lib/storage.ts

import AsyncStorage from '@react-native-async-storage/async-storage';

export const storage = {
  get: async <T>(key: string): Promise<T | null> => {
    const value = await AsyncStorage.getItem(key);
    return value ? JSON.parse(value) : null;
  },

  set: async <T>(key: string, value: T): Promise<void> => {
    await AsyncStorage.setItem(key, JSON.stringify(value));
  },

  remove: async (key: string): Promise<void> => {
    await AsyncStorage.removeItem(key);
  },

  clear: async (): Promise<void> => {
    await AsyncStorage.clear();
  },
};

Common Commands

# Start development server
npx expo start

# Run on specific platform
npx expo run:ios
npx expo run:android

# Start with tunnel (for physical device on different network)
npx expo start --tunnel

# Clear cache
npx expo start --clear

# Install Expo-compatible package
npx expo install <package-name>

# EAS Build
eas build --platform ios
eas build --platform android
eas build --platform all

# EAS Submit to stores
eas submit --platform ios
eas submit --platform android

# EAS Update (OTA updates)
eas update --branch production --message "Bug fix"

# Prebuild native projects (if ejecting from managed workflow)
npx expo prebuild

# Lint
npx expo lint

# TypeScript check
npx tsc --noEmit

Integration Notes

  • Navigation: Expo Router is file-based. Layouts define navigation structure (Stack, Tabs, Drawer). Use <Link> for declarative navigation, router.push() / router.replace() for imperative.
  • State Management: Zustand for global state — stores are just hooks. For server state (API data), consider @tanstack/react-query alongside Zustand.
  • Auth Flow: Use route groups (auth) and (tabs) with a root layout that redirects based on useAuthStore().isAuthenticated. Expo Router supports redirect in layout components.
  • Storage: expo-secure-store for tokens and secrets (uses Keychain on iOS, Keystore on Android). AsyncStorage for non-sensitive data (preferences, cached data).
  • EAS Build: Cloud builds via eas build. Configure in eas.json with profiles (development, preview, production). Development builds include dev tools, production builds are store-ready.
  • OTA Updates: eas update pushes JS bundle updates without app store review. Configure update channels per build profile.
  • Platform Code: Use .ios.tsx / .android.tsx file suffixes for large platform differences. Use Platform.OS or Platform.select() for small differences.
  • Testing: Use jest with @testing-library/react-native for component tests. Use detox for E2E tests on simulators/emulators.
  • Firebase: For authentication, Firestore, push notifications, and analytics, add the Firebase SDK: @react-native-firebase/app + feature packages (e.g., @react-native-firebase/auth, @react-native-firebase/firestore).
  • Offline-First: For offline-first, consider WatermelonDB or MMKV for local persistence. WatermelonDB provides a full sync-capable relational database; MMKV is ideal for fast key-value storage.
Repository
achreftlili/deep-dev-skills
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.