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.tsNot 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.alllets you invalidate everything product-related at oncePRODUCT_KEYS.list()andPRODUCT_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.
en.json
{
"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/withexport default - i18n keys added to both
en.jsonandar.json - All styles use
StyleSheet.create((theme) => ({...}))fromreact-native-unistyles - All user-facing text uses
t()fromuseTranslation() -
npm run validatepasses with no errors