This is the single source of truth for all coding conventions in this project. Every AI agent, contributor, and tool MUST follow these rules exactly. When in doubt, look at an existing file and match its patterns.
- Naming Conventions
- File Organization and Imports
- Component Architecture
- Styling Rules
- State Management
- API and Data Fetching
- Forms and Validation
- i18n Requirements
- Error Handling
- Testing
- Git Workflow
| Item | Convention | Example |
|---|---|---|
| Component directory | PascalCase | src/common/components/Button/ |
| Component file | PascalCase.tsx | Button.tsx |
| Component styles | PascalCase.styles.ts | Button.styles.ts |
| Component types | PascalCase.types.ts | Button.types.ts |
| Component barrel | lowercase | index.ts |
| Hook file | camelCase, starts with use |
useBottomPadding.ts |
| Store file | camelCase, ends with Store |
authStore.ts |
| Service file | camelCase, ends with Service |
authService.ts |
| Schema file | camelCase, ends with Schema |
loginSchema.ts |
| Screen file (app/) | lowercase with hyphens | forgot-password.tsx |
| Layout file (app/) | underscore prefix | _layout.tsx |
| Constants file | camelCase | constants.ts |
| i18n locale files | lowercase | en.json, ar.json |
- Component names are always PascalCase:
Button,UserProfileCard,SearchBar - Component props interfaces are named
{ComponentName}Props:ButtonProps,CardProps - Named exports for components, never default exports from component files
- Exception: Expo Router screen files in
app/MUST use default exports
- Exception: Expo Router screen files in
// CORRECT - named export in src/common/components/
export function Button({ ... }: ButtonProps) { ... }
// CORRECT - default export in app/ screen files
export default function HomeScreen() { ... }
// WRONG - default export from component
export default function Button() { ... }- Always starts with
use:useAuthStore,useNetworkStatus,useBottomPadding - Hook files named to match the exported hook:
useNetworkStatus.ts - Return objects (not arrays) unless it is a classic two-element state tuple
// CORRECT
export function useScreenDimensions() {
return { width, height, isLandscape };
}
// WRONG - returning unnamed array from custom hook
export function useScreenDimensions() {
return [width, height];
}- Zustand stores exported as hooks:
useAuthStore,useMyFeatureStore - State interface named
{Feature}State:AuthState,MyFeatureState - File named
{feature}Store.ts:authStore.ts
- Interfaces for object shapes:
interface ButtonProps - Type aliases for unions, primitives, and mapped types:
type ButtonVariant = 'primary' | 'secondary' - All type-only imports use
import type:import type { ButtonProps } from './Button.types' - No
anytypes. Useunknownand narrow, or define a proper type.
- SCREAMING_SNAKE_CASE for true constants:
STORAGE_KEYS,AVATAR_SIZES - camelCase for config objects:
breakpoints,spacing
Always use path aliases. Never use relative paths that climb more than one level.
// CORRECT
import { Button } from '@/common/components/Button';
import { useAuthStore } from '@/providers/auth/authStore';
import { apiClient } from '@/services/api';
// WRONG - climbing relative paths
import { Button } from '../../../common/components/Button';| Alias | Resolves To |
|---|---|
@/* |
src/* |
~/* |
app/* |
Imports are grouped in this order with a blank line between each group:
- React and React Native core
- Expo and third-party libraries
- Internal
@/path alias imports (types last) - Relative imports within the same feature/directory (types last)
import { useState } from 'react';
import { View, Pressable } from 'react-native';
import { useTranslation } from 'react-i18next';
import { StyleSheet } from 'react-native-unistyles';
import { Button } from '@/common/components/Button';
import { useAuthStore } from '@/providers/auth/authStore';
import type { MyProps } from '@/types';
import { localHelper } from './helpers';
import type { LocalType } from './types';Every component directory has an index.ts that re-exports the public API:
// src/common/components/Button/index.ts
export { Button } from './Button';
export type { ButtonProps, ButtonVariant, ButtonSize } from './Button.types';Import from the barrel, not the implementation file:
// CORRECT
import { Button } from '@/common/components/Button';
// WRONG
import { Button } from '@/common/components/Button/Button';ComponentName/
├── ComponentName.tsx # Required: component implementation
├── ComponentName.types.ts # Optional: props and related types
├── ComponentName.styles.ts # Optional: if styles are substantial
└── index.ts # Required: barrel export
// src/common/components/MyComponent/MyComponent.tsx
import { View } from 'react-native';
import { StyleSheet } from 'react-native-unistyles';
import { Text } from '@/common/components/Text';
import type { MyComponentProps } from './MyComponent.types';
export function MyComponent({ title, children, style }: MyComponentProps) {
return (
<View style={[styles.container, style]}>
<Text variant="h3">{title}</Text>
{children}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
padding: theme.metrics.spacing.p16,
backgroundColor: theme.colors.background.surface,
borderRadius: theme.metrics.borderRadius.md,
},
}));src/features/<feature>/
├── components/ # Feature-specific UI components
├── services/ # API calls (no React, pure async functions)
├── hooks/ # Custom hooks for this feature
├── stores/ # Zustand stores
├── types/ # TypeScript types and interfaces
├── schemas/ # Zod validation schemas
└── constants/ # Feature-level constants
- All interactive elements must have
accessibilityRole - All interactive elements must have
accessibilityLabel - Use
accessibilityStatefor disabled, busy, selected states
<Pressable
accessibilityRole="button"
accessibilityLabel={t('actions.submit')}
accessibilityState={{ disabled, busy: loading }}
>- Never use inline styles. All styles go in
StyleSheet.create. - Never use color literals. All colors come from
theme.colors.*. - Never hardcode numeric spacing. Use
theme.metrics.spacing.*or the helper functions.
All component styles MUST be in a separate .styles.ts file. Use the unistyles variants API for any style that changes based on props:
// Component.styles.ts
import { StyleSheet, type UnistylesVariants } from 'react-native-unistyles';
export const styles = StyleSheet.create((theme) => ({
container: {
borderRadius: theme.metrics.borderRadius.lg,
// Variants define style variations declaratively
variants: {
variant: {
primary: { backgroundColor: theme.colors.brand.primary },
secondary: { backgroundColor: theme.colors.background.surfaceAlt },
outline: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: theme.colors.border.default,
},
},
size: {
sm: { padding: theme.metrics.spacing.p8 },
md: { padding: theme.metrics.spacing.p12 },
lg: { padding: theme.metrics.spacing.p16 },
},
disabled: {
true: { opacity: 0.5 },
},
},
},
}));
export type MyComponentStyleVariants = UnistylesVariants<typeof styles>;// Component.tsx - call useVariants once, then use styles directly
styles.useVariants({ variant, size, disabled });
return <View style={styles.container} />;Key rules:
- Call
styles.useVariants(...)once per render, before accessing any style - Use
compoundVariantsfor styles that depend on multiple variant combinations - Use boolean variants (
true/falsekeys) for toggleable states likedisabled,focused,error - Use
miniRuntime(second arg) for safe area insets and screen dimensions:const styles = StyleSheet.create((theme, rt) => ({ container: { paddingTop: rt.insets.top }, }));
- Use breakpoint-responsive values for tablet adaptation:
padding: { xs: theme.metrics.spacing.p12, md: theme.metrics.spacing.p24 }
Import from @/theme/metrics:
import { rf, hs, vs } from '@/theme/metrics';
// rf(n) - responsive font size (scales with screen width)
// hs(n) - horizontal scale (scales with screen width)
// vs(n) - vertical scale (scales with screen height)Use these helpers in StyleSheet.create callbacks for dynamic values not available from theme tokens:
const styles = StyleSheet.create((theme) => ({
customElement: {
width: hs(120),
height: vs(48),
fontSize: rf(14),
},
}));theme.colors.brand.* - primary, secondary, tertiary, primaryVariant, secondaryVariant
theme.colors.background.* - app, surface, surfaceAlt, section, elevated, input, disabled, modal
theme.colors.text.* - primary, secondary, tertiary, muted, inverse, accent, link, linkHover
theme.colors.border.* - default, subtle, strong, focus, disabled
theme.colors.icon.* - primary, secondary, tertiary, muted, inverse, accent
theme.colors.state.* - success, successBg, warning, warningBg, error, errorBg, info, infoBg
theme.colors.overlay.* - modal, pressed, hover, focus, ripple, shadow
theme.colors.gradient.* - primary[], secondary[], accent[], success[], highlight[]
theme.colors.shadow.* - color, elevation, elevationSmall, elevationMedium, elevationLarge
theme.colors.mode - 'light' | 'dark'
theme.metrics.spacing.* - p4 through p120 (horizontal via hs())
theme.metrics.spacingV.* - p4 through p120 (vertical via vs())
theme.metrics.fontSize.* - xxs, xs, sm, md, lg, xl, 2xl, 3xl, 4xl, 5xl, 6xl
theme.metrics.borderRadius.* - xs(4), sm(6), md(8), lg(12), xl(16), full(999)
theme.metrics.iconSize.* - xs(14), sm(16), md(18), lg(20), xl(24)
theme.fonts.size.* - xxs through 6xl (responsive via rf())
- Server/async state:
@tanstack/react-query- data from APIs, loading states, caching - Client/UI state: Zustand - user preferences, auth session, UI toggles
// src/features/myFeature/stores/myFeatureStore.ts
import { create } from 'zustand';
import type { MyItem } from '../types';
interface MyFeatureState {
// State shape
items: MyItem[];
selectedId: string | null;
// Actions
setItems: (items: MyItem[]) => void;
selectItem: (id: string | null) => void;
addItem: (item: MyItem) => void;
removeItem: (id: string) => void;
reset: () => void;
}
const initialState = {
items: [],
selectedId: null,
};
export const useMyFeatureStore = create<MyFeatureState>((set) => ({
...initialState,
setItems: (items) => set({ items }),
selectItem: (id) => set({ selectedId: id }),
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (id) => set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
reset: () => set(initialState),
}));Always use selectors - never subscribe to the whole store:
// CORRECT - subscribes only to what is needed
const items = useMyFeatureStore((s) => s.items);
const addItem = useMyFeatureStore((s) => s.addItem);
// WRONG - subscribes to all state, causes unnecessary re-renders
const store = useMyFeatureStore();Auth is managed exclusively by useAuthStore in src/providers/auth/authStore.ts. Never create a React context for auth. Never manage session state locally in a component.
import { useAuthStore } from '@/providers/auth/authStore';
// Reading auth state
const user = useAuthStore((s) => s.user);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isLoading = useAuthStore((s) => s.isLoading);
// From outside React (e.g., in Axios interceptors)
const session = useAuthStore.getState().session;All HTTP requests go through src/services/api/client.ts. Import the api instance:
import { api } from '@/services/api';The client automatically attaches the Bearer token from useAuthStore and handles 401 responses by calling clearSession().
Place all query/mutation hooks in src/features/<feature>/hooks/:
// src/features/products/hooks/useProducts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/services/api';
import type { Product, CreateProductData } from '../types';
export const PRODUCT_KEYS = {
all: ['products'] as const,
list: (filters?: object) => [...PRODUCT_KEYS.all, 'list', filters] as const,
detail: (id: string) => [...PRODUCT_KEYS.all, 'detail', id] as const,
};
export function useProducts(filters?: object) {
return useQuery({
queryKey: PRODUCT_KEYS.list(filters),
queryFn: () => api.get<Product[]>('/products', { params: filters }).then((r) => r.data),
});
}
export function useProduct(id: string) {
return useQuery({
queryKey: PRODUCT_KEYS.detail(id),
queryFn: () => api.get<Product>(`/products/${id}`).then((r) => r.data),
enabled: !!id,
});
}
export function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateProductData) =>
api.post<Product>('/products', data).then((r) => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: PRODUCT_KEYS.all });
},
});
}Use factory functions for query keys, not inline arrays. This enables precise cache invalidation.
Define Zod schemas before writing form components:
// src/features/myFeature/schemas/myFormSchema.ts
import { z } from 'zod/v4';
export const myFormSchema = z.object({
name: z.string().min(1, 'validation.required'),
email: z.email('validation.emailInvalid'),
age: z.number().min(0, 'validation.ageMin').max(120, 'validation.ageMax'),
bio: z.string().max(500, 'validation.bioMax').optional(),
});
export type MyFormData = z.infer<typeof myFormSchema>;Validation message strings are i18n keys, not raw text.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { FormField } from '@/common/components/FormField';
import { Input } from '@/common/components/Input';
import { Button } from '@/common/components/Button';
import { myFormSchema, type MyFormData } from '../schemas/myFormSchema';
export function MyForm() {
const { t } = useTranslation();
const { control, handleSubmit, formState: { isSubmitting } } = useForm<MyFormData>({
resolver: zodResolver(myFormSchema),
defaultValues: { name: '', email: '' },
});
const onSubmit = async (data: MyFormData) => {
// handle submission
};
return (
<>
<FormField name="email" control={control} label={t('fields.email')} required>
<Input keyboardType="email-address" autoCapitalize="none" />
</FormField>
<Button
title={t('actions.submit')}
onPress={handleSubmit(onSubmit)}
loading={isSubmitting}
/>
</>
);
}No hardcoded strings in JSX or component logic. This is a hard rule.
// CORRECT
const { t } = useTranslation();
<Text>{t('home.welcome')}</Text>
// WRONG
<Text>Welcome to the app!</Text>Both files must be updated together:
src/i18n/locales/en.json- English (primary)src/i18n/locales/ar.json- Arabic (RTL)
Keys use dot notation and semantic naming:
{
"home": {
"welcome": "Welcome",
"subtitle": "Get started below"
},
"auth": {
"login": "Sign In",
"logout": "Sign Out",
"email": "Email address",
"password": "Password"
},
"actions": {
"submit": "Submit",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete"
},
"validation": {
"required": "This field is required",
"emailInvalid": "Enter a valid email address",
"passwordMin": "Password must be at least 8 characters"
},
"errors": {
"generic": "Something went wrong. Please try again.",
"network": "Network error. Check your connection."
}
}RTL is handled at the app level in app/_layout.tsx. Do not manually flip layouts. Use flexDirection values normally - the RTL mirroring is applied globally.
Use ErrorBoundary from src/common/components/ErrorBoundary to wrap sections that might throw:
import { ErrorBoundary } from '@/common/components/ErrorBoundary';
<ErrorBoundary>
<MyRiskyComponent />
</ErrorBoundary>Use try/catch in service functions and mutation handlers:
// In a service function - throw the error, let the caller handle it
export async function fetchUser(id: string): Promise<User> {
const { data, error } = await supabase.from('users').select('*').eq('id', id).single();
if (error) throw error;
return data;
}
// In a mutation handler - handle errors where they affect UX
const mutation = useMutation({
mutationFn: createUser,
onError: (error: Error) => {
// Show user-facing error via toast or state
console.error('[createUser]', error.message);
},
});The Axios client in src/services/api/client.ts normalizes all API errors to Error with a human-readable message. Catch Error not AxiosError in calling code.
The storage utilities return a result object. Always check .success:
const result = getItem<string>(STORAGE_KEYS.preferences.language);
if (result.success && result.data) {
// use result.data
} else {
// handle error or null
}Tests live next to the files they test:
Button/
├── Button.tsx
├── Button.test.tsx # Unit test
└── index.ts
Integration tests for features live in src/features/<feature>/__tests__/.
// Component test
import { render, screen, fireEvent } from '@testing-library/react-native';
import { Button } from './Button';
describe('Button', () => {
it('renders with title', () => {
render(<Button title="Submit" onPress={jest.fn()} />);
expect(screen.getByText('Submit')).toBeTruthy();
});
it('shows loading indicator when loading', () => {
render(<Button title="Submit" loading onPress={jest.fn()} />);
expect(screen.getByRole('button')).toHaveAccessibilityState({ busy: true });
});
it('calls onPress when pressed', () => {
const onPress = jest.fn();
render(<Button title="Submit" onPress={onPress} />);
fireEvent.press(screen.getByRole('button'));
expect(onPress).toHaveBeenCalledTimes(1);
});
});npm test # Run all tests
npm run test:coverage # Coverage report- Shared UI components: aim for 80%+ coverage
- Feature services and hooks: aim for 70%+ coverage
- Utility functions: 100% coverage
<type>(<scope>): <subject>
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
Scope: The feature or area being changed: auth, button, theme, i18n, api
Examples:
feat(auth): add forgot password screen
fix(button): correct disabled state color in dark mode
docs(conventions): add i18n section
test(button): add accessibility tests
refactor(api): extract error normalization to helper
feat/<short-description>
fix/<short-description>
chore/<short-description>
Examples: feat/product-listing, fix/rtl-badge-layout, chore/upgrade-expo-55
The project runs validate (type-check + lint + format) via Husky. All checks must pass before committing.
npm run validate # Run manually before committingThese patterns are banned from new code. Automated hooks block them for Claude Code; all contributors and agents must avoid them.
| Forbidden Pattern | Alternative |
|---|---|
// eslint-disable-next-line |
Fix the lint violation in the code |
/* eslint-disable */ |
Fix all violations in the file, or restructure |
// @ts-ignore |
Use proper types, narrowing, or @ts-expect-error with a description |
// @ts-nocheck |
Type-check every file. Fix the errors. |
// @ts-expect-error (bare, no description) |
Add a description: // @ts-expect-error -- reason |
| Forbidden Pattern | Alternative |
|---|---|
: any |
Define a proper type or use unknown with type guards |
as any |
Use a specific type assertion (as SpecificType) or narrow first |
<any> |
Use a generic parameter (<T>) or a concrete type |
| Forbidden Pattern | Alternative |
|---|---|
git commit --no-verify |
Fix the pre-commit hook errors (run npm run validate) |
git push --no-verify |
Fix the pre-push hook errors |
The following pre-approved usages exist in the codebase and are grandfathered in:
| File | Usage | Reason |
|---|---|---|
jest.setup.ts:1 |
/* eslint-disable @typescript-eslint/no-require-imports */ |
Jest setup requires CommonJS require() for mocking |
src/utils/storage/storage.ts:38 |
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion |
MMKV instance is guaranteed initialized at this point |
src/utils/storage/storage.ts:170 |
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion |
MMKV instance is guaranteed initialized at this point |
No new exceptions should be added without explicit approval from the project maintainer.