Skip to Content
GuidesCreating a Feature

Creating a Feature

This guide walks through creating a complete feature module from scratch. We will build a products feature as the example, covering types, API service, React Query hooks, screen, and translations.

Every feature follows the same directory structure and patterns. Once you have built one feature, every subsequent feature is the same process.

Feature Module Structure

Every feature lives in src/features/<name>/ and follows this layout:

src/features/products/ ├── components/ # ProductCard.tsx, ProductList.tsx ├── services/ # productService.ts ├── hooks/ # useProducts.ts ├── stores/ # productStore.ts (only if client state is needed) ├── types/ # index.ts ├── schemas/ # productSchema.ts (for forms) └── constants/ # index.ts

Not every feature needs every directory. Start with types/, services/, and hooks/ — add stores/, schemas/, and constants/ as needed.

Define types

Start with the data model. All types for the feature live in types/index.ts.

// src/features/products/types/index.ts export interface Product { id: string; name: string; price: number; imageUrl: string; category: string; description?: string; createdAt: string; } export interface CreateProductData { name: string; price: number; category: string; description?: string; } export interface UpdateProductData extends Partial<CreateProductData> {}

Never use any types. If the shape is unknown, use unknown and narrow with type guards.

Create the API service

The service layer handles all HTTP calls. Import the pre-configured Axios client from @/services/api.

// src/features/products/services/productService.ts import { api } from '@/services/api'; import type { Product, CreateProductData, UpdateProductData } from '../types'; export async function fetchProducts(): Promise<Product[]> { const { data } = await api.get<Product[]>('/products'); return data; } export async function fetchProduct(id: string): Promise<Product> { const { data } = await api.get<Product>(`/products/${id}`); return data; } export async function createProduct(payload: CreateProductData): Promise<Product> { const { data } = await api.post<Product>('/products', payload); return data; } export async function updateProduct( id: string, payload: UpdateProductData ): Promise<Product> { const { data } = await api.patch<Product>(`/products/${id}`, payload); return data; } export async function deleteProduct(id: string): Promise<void> { await api.delete(`/products/${id}`); }

The api client already handles:

  • Auth tokens — automatically attaches the Bearer token from useAuthStore
  • 401 responses — clears the session and redirects to login
  • Error formatting — extracts error messages from various response formats

Create React Query hooks

Use a query key factory to keep cache keys consistent and enable targeted invalidation.

// src/features/products/hooks/useProducts.ts import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { fetchProducts, fetchProduct, createProduct, updateProduct, deleteProduct, } from '../services/productService'; import type { CreateProductData, UpdateProductData } from '../types'; // Query key factory -- single source of truth for all product-related cache keys export const PRODUCT_KEYS = { all: ['products'] as const, list: () => [...PRODUCT_KEYS.all, 'list'] as const, detail: (id: string) => [...PRODUCT_KEYS.all, 'detail', id] as const, }; /** Fetch all products. */ export function useProducts() { return useQuery({ queryKey: PRODUCT_KEYS.list(), queryFn: fetchProducts, }); } /** Fetch a single product by ID. */ export function useProduct(id: string) { return useQuery({ queryKey: PRODUCT_KEYS.detail(id), queryFn: () => fetchProduct(id), enabled: !!id, }); } /** Create a new product and invalidate the list cache. */ export function useCreateProduct() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (data: CreateProductData) => createProduct(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: PRODUCT_KEYS.all }); }, }); } /** Update an existing product and invalidate relevant caches. */ export function useUpdateProduct() { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id, data }: { id: string; data: UpdateProductData }) => updateProduct(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: PRODUCT_KEYS.all }); }, }); } /** Delete a product and invalidate the list cache. */ export function useDeleteProduct() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (id: string) => deleteProduct(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: PRODUCT_KEYS.all }); }, }); }

Why the key factory pattern?

  • PRODUCT_KEYS.all lets you invalidate everything product-related at once
  • PRODUCT_KEYS.list() and PRODUCT_KEYS.detail(id) give you granular cache control
  • All keys share the same root, so invalidateQueries({ queryKey: PRODUCT_KEYS.all }) cascades to both lists and details

Create the screen

Screen files live in app/ and must use default exports (required by expo-router).

// app/(main)/(tabs)/products.tsx import { FlatList } from 'react-native'; import { useTranslation } from 'react-i18next'; import { StyleSheet } from 'react-native-unistyles'; import { ScreenContainer } from '@/common/components/ScreenContainer'; import { EmptyState } from '@/common/components/EmptyState'; import { Loading } from '@/common/components/Loading'; import { Text } from '@/common/components/Text'; import { useProducts } from '@/features/products/hooks/useProducts'; import { ProductCard } from '@/features/products/components/ProductCard'; export default function ProductsScreen() { const { t } = useTranslation(); const { data: products, isLoading, error, refetch } = useProducts(); if (isLoading) return <Loading fullScreen />; if (error) { return ( <ScreenContainer> <EmptyState title={t('errors.generic')} message={error.message} actionLabel={t('actions.retry')} onAction={refetch} /> </ScreenContainer> ); } return ( <ScreenContainer> <Text variant="h1" style={styles.title}> {t('products.title')} </Text> <FlatList data={products} keyExtractor={(item) => item.id} renderItem={({ item }) => <ProductCard product={item} />} ListEmptyComponent={ <EmptyState title={t('products.empty')} /> } contentContainerStyle={styles.list} /> </ScreenContainer> ); } const styles = StyleSheet.create((theme) => ({ title: { marginBottom: theme.metrics.spacingV.p16, }, list: { gap: theme.metrics.spacingV.p16, paddingBottom: theme.metrics.spacingV.p32, }, }));

Note the imports: StyleSheet comes from react-native-unistyles (not react-native), and Text comes from @/common/components/Text (not react-native). These are the two most common AI agent mistakes.

Add a feature component

Create components within the feature’s components/ directory.

// src/features/products/components/ProductCard.tsx import { View } from 'react-native'; import { StyleSheet } from 'react-native-unistyles'; import { Card } from '@/common/components/Card'; import { Text } from '@/common/components/Text'; import type { Product } from '../types'; interface ProductCardProps { product: Product; onPress?: () => void; } export function ProductCard({ product, onPress }: ProductCardProps) { return ( <Card onPress={onPress} accessibilityRole="button" accessibilityLabel={product.name}> <View style={styles.content}> <Text variant="h3">{product.name}</Text> <Text variant="bodySmall" color="secondary"> {product.category} </Text> <Text variant="body" style={styles.price}> ${product.price.toFixed(2)} </Text> </View> </Card> ); } const styles = StyleSheet.create((theme) => ({ content: { gap: theme.metrics.spacingV.p4, }, price: { color: theme.colors.brand.primary, fontWeight: '600', }, }));

Add i18n translations

Always update both locale files at the same time. Missing keys in either file will show raw key strings to users.

{ "products": { "title": "Products", "empty": "No products found", "addNew": "Add Product", "saved": "Product saved successfully", "deleted": "Product deleted" } }

These keys are added to src/i18n/locales/en.json and src/i18n/locales/ar.json respectively.

Register the tab (if applicable)

If your feature has a tab screen, register it in the tabs layout:

// app/(main)/(tabs)/_layout.tsx import { Tabs } from 'expo-router'; import { useTranslation } from 'react-i18next'; import { TabBar } from '@/common/components/TabBar'; export default function TabLayout() { const { t } = useTranslation(); return ( <Tabs tabBar={(props) => <TabBar {...props} />} screenOptions={{ headerShown: false }} > <Tabs.Screen name="index" options={{ title: t('tabs.home') }} /> <Tabs.Screen name="products" options={{ title: t('tabs.products') }} /> <Tabs.Screen name="settings" options={{ title: t('tabs.settings') }} /> </Tabs> ); }

Add the tab label to your translations:

// en.json { "tabs": { "products": "Products" } } // ar.json { "tabs": { "products": "المنتجات" } }

Checklist

After completing all steps, verify your feature:

  • Types defined in src/features/products/types/index.ts
  • Service functions in src/features/products/services/productService.ts
  • Query key factory + hooks in src/features/products/hooks/useProducts.ts
  • Screen created in app/ with export default
  • i18n keys added to both en.json and ar.json
  • All styles use StyleSheet.create((theme) => ({...})) from react-native-unistyles
  • All user-facing text uses t() from useTranslation()
  • npm run validate passes with no errors
Last updated on