// Design Patterns · SOLID Principles · Quick Diagrams · React Native Deep Dive
// Read these first — understand the SHAPE of each pattern before diving into code
| Pattern | Category | Core Idea | RN Real-World Use | Key Methods |
|---|---|---|---|---|
| Singleton | Creational | One instance, global access | Axios client, AsyncStorage wrapper, FCM manager | getInstance(), constructor check |
| Factory | Creational | Centralised object creation | Platform buttons, storage selector, payment gateway | createX(), switch(type) |
| Proxy | Structural | Intercept object access | Encrypted storage, API logger, image cache | get trap, set trap |
| Observer | Behavioral | Subject notifies observers | Cart store, keyboard events, AppState | subscribe, unsubscribe, notify |
| Pub/Sub | Behavioral | Loose-coupled via broker | Cross-screen events, Redux, deep links | publish(topic), subscribe(topic) |
| Container-Presentation | React/RN | Logic vs UI separation | Screen (smart) + UI Component (dumb) | props passing, no state in presentation |
| Custom Hook | React/RN | Reusable stateful logic | useLocation, useBluetooth, useNetInfo | useState, useEffect, cleanup |
| Provider | React/RN | Global data without prop drilling | Theme, Auth, i18n, Navigation | createContext, Provider, useContext |
| Render Prop | React/RN | Share logic via function prop | PermissionGate, FlatList renderItem | render={fn}, children as function |
| Composition | Structural | Build from small pieces | Compound components, HOCs, wrappers | Card.Header, Card.Body, Card.Footer |
Think of a country's President — there can only be ONE at a time. If you ask for "the president", you always get the same person. Even if 10 people ask, they all refer to the same person. That's Singleton.
Ensures a class has only one instance ever created, and provides a global access point to it.
Constructor checks ClassName.instance — if it exists, returns it. Otherwise creates and stores it.
Use #field (ES2022) to prevent external mutation of the instance's internal state.
JS is single-threaded so race conditions aren't an issue. In React Native, module caching handles singleton naturally via import.
imported module is cached — so an exported object is already a singleton by nature.MyClass.instance), not on the prototype, so it survives across new calls.this — used to return the old instance.// ✅ Module-level singleton (simplest RN approach) import axios, { AxiosInstance } from 'axios'; import AsyncStorage from '@react-native-async-storage/async-storage'; class ApiClient { private static instance: ApiClient; private http: AxiosInstance; private constructor() { // Private constructor — blocks `new ApiClient()` from outside this.http = axios.create({ baseURL: 'https://api.myapp.com/v1', timeout: 10000, headers: { 'Content-Type': 'application/json' }, }); // Add request interceptor — attach token on every request this.http.interceptors.request.use(async (config) => { const token = await AsyncStorage.getItem('@auth_token'); if (token) config.headers.Authorization = `Bearer ${token}`; return config; }); // Add response interceptor — handle 401 globally this.http.interceptors.response.use( res => res, async err => { if (err.response?.status === 401) { await AsyncStorage.removeItem('@auth_token'); // Redirect to login — navigate globally } return Promise.reject(err); } ); } // 🔑 Only way to get the instance public static getInstance(): ApiClient { if (!ApiClient.instance) { ApiClient.instance = new ApiClient(); } return ApiClient.instance; } get<T>(url: string) { return this.http.get<T>(url); } post<T>(url: string, data: any) { return this.http.post<T>(url, data); } put<T>(url: string, data: any) { return this.http.put<T>(url, data); } del<T>(url: string) { return this.http.delete<T>(url); } } // Usage — same instance everywhere in app const api = ApiClient.getInstance(); const users = await api.get<User[]>('/users'); const profile = await api.post('/profile', { name: 'John' });
Think of a restaurant kitchen — you say "I want a burger" (factory call). The kitchen (factory) figures out whether to make a veggie burger or chicken burger based on your preference. You don't care HOW it's made, you just get a burger.
Centralises object creation. The what is decided at runtime, not compile time.
Constructor always makes the same type. Factory decides which type based on a condition.
Factory returns different instances each call. Singleton always returns the same one.
create() method — better for complex hierarchies).switch(type) with a registry Map for O(1) lookup and easy extension without changing factory code.Platform.OS === 'ios' → IOSButton, else AndroidButtonsensitive=true → SecureStore, else AsyncStorage, or MMKV for perf__DEV__// Storage interface — all implementations must follow this interface StorageDriver { get(key: string): Promise<string | null>; set(key: string, val: string): Promise<void>; remove(key: string): Promise<void>; } // Three concrete implementations class AsyncStorageDriver implements StorageDriver { /* ... */ } class MMKVDriver implements StorageDriver { /* ... */ } class SecureStoreDriver implements StorageDriver { /* ... */ } // ✅ Factory with registry Map (better than switch) type StorageType = 'async' | 'mmkv' | 'secure'; const registry: Record<StorageType, () => StorageDriver> = { async: () => new AsyncStorageDriver(), mmkv: () => new MMKVDriver(), secure: () => new SecureStoreDriver(), }; function createStorage(type: StorageType): StorageDriver { const factory = registry[type]; if (!factory) throw new Error(`Unknown storage type: ${type}`); return factory(); } // Usage — decision made at call site, implementation hidden const tokenStorage = createStorage('secure'); // biometric protect const cacheStorage = createStorage('mmkv'); // speed priority const prefStorage = createStorage('async'); // general purpose // All use same interface — consumer code is identical await tokenStorage.set('jwt', token); const jwt = await tokenStorage.get('jwt');
Think of a hotel receptionist — you never talk to the hotel owner directly. The receptionist (proxy) intercepts your request, checks if you're a guest, logs your visit, and then forwards it. They can even deny access if needed.
A surrogate that controls access to another object. Add behaviour (logging, caching, validation) without touching original.
new Proxy(target, handler) — handler defines traps: get, set, has, apply, construct, etc.
Fires when any property is READ from the proxy. Can return modified values, log access, or throw for forbidden keys.
Fires when any property is WRITTEN. Can validate types, encrypt values, log mutations. Must return true to confirm write.
get(target, prop, receiver) — intercept property readset(target, prop, value, receiver) — intercept property write, must return truehas(target, prop) — intercept in operator ('name' in obj)deleteProperty(target, prop) — intercept delete obj.nameapply(target, thisArg, args) — intercept function calls (proxy on a function)Reflect.get(target, prop) inside traps to maintain default behaviour cleanly.set, decrypts on get// Proxy that logs API calls in dev and rate-limits in prod function createApiProxy(apiService: ApiService): ApiService { const callCounts = new Map<string, { count: number; resetAt: number }>(); const RATE_LIMIT = 10; // max 10 calls per endpoint per minute return new Proxy(apiService, { get(target, prop: keyof ApiService) { const original = target[prop]; if (typeof original !== 'function') return original; return async (url: string, ...args: any[]) => { // 📊 Rate limit check const key = `${String(prop)}:${url}`; const now = Date.now(); const entry = callCounts.get(key) || { count: 0, resetAt: now + 60000 }; if (now > entry.resetAt) { entry.count = 0; entry.resetAt = now + 60000; } if (entry.count >= RATE_LIMIT) throw new Error(`Rate limit: ${key}`); entry.count++; callCounts.set(key, entry); // 🔍 Dev logging if (__DEV__) { console.log(`[API] ${String(prop).toUpperCase()} ${url}`, args[0] || ''); const start = Date.now(); const result = await original.call(target, url, ...args); console.log(`[API] ✓ ${Date.now() - start}ms`, result); return result; } return original.call(target, url, ...args); }; }, }); } // Usage — transparent to all consumers const api = createApiProxy(ApiClient.getInstance()); // Logs in dev, rate-limited in prod — zero changes to consumer
Think of a YouTube channel — when you subscribe, you get notified on every new video. The channel (subject) maintains a list of subscribers (observers). Any change (new video) notifies ALL subscribers automatically.
Holds state. Maintains observer list. Calls notify() on every state change. Also called Observable.
Has an update(data) method. Gets called by Subject. Can subscribe/unsubscribe at any time.
Always unsubscribe in useEffect cleanup. Forgetting this = components update after unmount = crash.
Observer: tight coupling, synchronous, single-app. Pub/Sub: loose (via broker), async, cross-app.
store.subscribe(fn) is classic Observer.AppState.addEventListener, Keyboard.addListener, NetInfo.addEventListener all return cleanup functions — always call them.AppState.addEventListener('change', fn) — foreground/background transitionsKeyboard.addListener('keyboardDidShow', fn) — keyboard height trackingNetInfo.addEventListener(fn) — online/offline detectionnavigation.addListener('focus', fn) — screen focus eventsDimensions.addEventListener('change', fn) — orientation changes// Generic Observable — can be reused for any store class Observable<T> { private observers: Set<(data: T) => void> = new Set(); protected state: T; constructor(initialState: T) { this.state = initialState; } subscribe(fn: (data: T) => void): () => void { this.observers.add(fn); fn(this.state); // immediately emit current state return () => this.observers.delete(fn); // returns cleanup } protected setState(updater: (prev: T) => T) { this.state = updater(this.state); this.observers.forEach(fn => fn(this.state)); // notify all } getState() { return this.state; } } // ✅ Concrete Cart Store extending Observable interface CartState { items: CartItem[]; total: number; } class CartStore extends Observable<CartState> { constructor() { super({ items: [], total: 0 }); } addItem(item: CartItem) { this.setState(prev => ({ items: [...prev.items, item], total: prev.total + item.price, })); } removeItem(id: string) { this.setState(prev => { const item = prev.items.find(i => i.id === id); return { items: prev.items.filter(i => i.id !== id), total: prev.total - (item?.price || 0), }; }); } } export const cartStore = new CartStore(); // singleton instance // ✅ React Native hook to use Observable store function useCart() { const [cartState, setCartState] = useState(cartStore.getState()); useEffect(() => { const unsubscribe = cartStore.subscribe(setCartState); return unsubscribe; // cleanup prevents memory leak }, []); return { ...cartState, addItem: cartStore.addItem.bind(cartStore) }; } // Usage in any component — all sync automatically function CartBadge() { const { items } = useCart(); return <Badge count={items.length} />; }
Think of a newspaper subscription — The journalist (publisher) writes articles and gives them to the newspaper office (broker/event bus). Readers (subscribers) subscribe to topics like "Sports" or "Tech". The journalist has NO IDEA who the readers are. Readers have NO IDEA who the journalist is.
Sends message to broker with a topic key. Knows nothing about subscribers. Fully decoupled.
Stores topic→subscribers map. Routes published messages. The ONLY shared dependency.
Registers interest in a topic with the broker. Receives matching messages. Doesn't know the publisher.
dispatch(action) = publish, useSelector(fn) = subscribe. Store = broker.// Typed EventBus for React Native — type-safe Pub/Sub type EventMap = { 'cart:add': { productId: string; qty: number; price: number }; 'cart:remove': { productId: string }; 'auth:login': { userId: string; token: string }; 'auth:logout': void; 'nav:deeplink': { screen: string; params: Record<string, any> }; }; class TypedEventBus { private topics = new Map<string, Set<Function>>(); subscribe<K extends keyof EventMap>( topic: K, fn: (payload: EventMap[K]) => void ): () => void { if (!this.topics.has(topic)) this.topics.set(topic, new Set()); this.topics.get(topic)!.add(fn); return () => this.topics.get(topic)?.delete(fn); // cleanup fn } publish<K extends keyof EventMap>(topic: K, payload: EventMap[K]) { this.topics.get(topic)?.forEach(fn => fn(payload)); } } export const bus = new TypedEventBus(); // ProductScreen.tsx — Publisher (knows nothing about TabBar) const handleAddToCart = () => { bus.publish('cart:add', { productId: 'p-42', qty: 1, price: 29.99 }); }; // TabBar.tsx — Subscriber (knows nothing about ProductScreen) useEffect(() => { const unsub = bus.subscribe('cart:add', ({ qty }) => { setBadgeCount(prev => prev + qty); }); return unsub; // cleanup }, []);
Think of a waiter and chef — the chef (container) knows all the business: ingredients, cooking process, timing. The waiter (presentation) only knows how to serve the plate. Swap the chef without retraining the waiter.
Has useState, useEffect. Fetches data, handles errors, loading states. Passes everything down as props.
Pure function of props. No internal state (or only UI state like hover). Receives callbacks — never creates them.
useUserList()). Screen consumes hook + renders presentation.screens/HomeScreen.tsx → Container: fetches, manages state, handles navcomponents/UserCard.tsx → Presentation: pure UI from propscomponents/UserList.tsx → Presentation: renders FlatList from array prophooks/useUserList.ts → Modern container logic extracted to hook// ✅ Hook = modern container (replaces class container) function useUserList() { const [users, setUsers] = useState<User[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); useEffect(() => { const load = async () => { try { const { data } = await api.get<User[]>('/users'); setUsers(data); } catch (e) { setError('Failed to load users'); } finally { setLoading(false); } }; load(); }, []); const refresh = () => { setLoading(true); }; return { users, loading, error, refresh }; } // ✅ Screen = thin orchestration layer function UserListScreen() { const { users, loading, error, refresh } = useUserList(); return <UserListUI users={users} loading={loading} error={error} onRefresh={refresh} />; } // ✅ Presentation = pure UI — no logic at all function UserListUI({ users, loading, error, onRefresh }: Props) { if (loading) return <ActivityIndicator size="large" />; if (error) return <ErrorView message={error} onRetry={onRefresh} />; return ( <FlatList data={users} keyExtractor={u => u.id} renderItem={({ item }) => <UserCard user={item} />} refreshControl={<RefreshControl refreshing={false} onRefresh={onRefresh} />} /> ); }
Think of electrical outlets — the wiring (hook logic: useState, useEffect, cleanup) is hidden in the wall. Any device (component) just plugs in and gets power. 10 rooms can use the same wiring pattern without duplicating it.
Must start with use. Only call at top level (no loops/conditions). Only inside function components or other hooks.
Any stateful logic: API calls, subscriptions, timers, device sensors, permissions, keyboard state, etc.
useUsers(), useProduct(id) — fetch + cache + error + loadinguseCamera(), useLocation(), useBluetooth() — native module abstractionuseKeyboard(), useOrientation(), useSafeArea()useToggle(), useDebounce(val, ms), usePrevious(val)useEffect — subscriptions, timers, event listeners.useCamera() — permission request + camera ref + photo captureuseLocation() — GPS permission + current coords + watchPosition cleanupuseNetworkStatus() — NetInfo subscription + online/offline/typeuseKeyboard() — keyboard height for adjusting scroll view paddinguseBackHandler() — Android hardware back button overrideuseAppState() — foreground/background/inactive trackinguseDeepLink() — handle incoming URLs / universal linksimport * as Location from 'expo-location'; interface LocationState { coords: Location.LocationObject | null; error: string | null; loading: boolean; permissionStatus: 'granted' | 'denied' | 'undetermined'; } function useLocation(watchMode = false) { const [state, setState] = useState<LocationState>({ coords: null, error: null, loading: true, permissionStatus: 'undetermined', }); useEffect(() => { let subscription: Location.LocationSubscription | null = null; const init = async () => { // 1️⃣ Request permission const { status } = await Location.requestForegroundPermissionsAsync(); setState(s => ({ ...s, permissionStatus: status })); if (status !== 'granted') { setState(s => ({ ...s, loading: false, error: 'Permission denied' })); return; } if (watchMode) { // 2️⃣ Watch mode — continuous updates subscription = await Location.watchPositionAsync( { accuracy: Location.Accuracy.High, distanceInterval: 10 }, (location) => setState(s => ({ ...s, coords: location, loading: false })) ); } else { // 2️⃣ One-shot — get once const location = await Location.getCurrentPositionAsync({}); setState(s => ({ ...s, coords: location, loading: false })); } }; init(); // 3️⃣ Cleanup — stop watching on unmount return () => { subscription?..remove(); }; }, [watchMode]); return state; } // Usage function MapScreen() { const { coords, loading, error } = useLocation(true); // watch mode if (loading) return <ActivityIndicator />; if (error) return <Text>{error}</Text>; return <MapView region={{ lat: coords?.coords.latitude, ... }} />; }
Think of WiFi in a building — the router (Provider) is set up once at the top. Every room (component) can connect to it anywhere in the building without running a cable through every floor (prop drilling).
createContext() → <Context.Provider value={...}> → any descendant calls useContext().
All consumers re-render when Provider value changes. Split contexts by update frequency to avoid unnecessary re-renders.
AuthContext (rarely changes) vs UIContext (frequent changes).useMemo to prevent unnecessary re-renders of all consumers.useTheme() instead of exposing raw useContext(ThemeContext) — hides implementation detail.createContext(defaultValue) — important for testing components in isolation.<GestureHandlerRootView> — required for react-native-gesture-handler<SafeAreaProvider> — notch/island safe area context<ThemeProvider> — dark/light mode, design tokens<AuthProvider> — user session, login/logout, token refresh<LocaleProvider> — language, RTL support<NavigationContainer> — itself is a Provider!interface AuthState { user: User | null; token: string | null; isLoading: boolean; login(email: string, password: string): Promise<void>; logout(): Promise<void>; } const AuthContext = React.createContext<AuthState>(null!); export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState<User | null>(null); const [token, setToken] = useState<string | null>(null); const [isLoading, setIsLoading] = useState(true); // Restore session on app launch useEffect(() => { const restore = async () => { const savedToken = await SecureStore.getItemAsync('token'); if (savedToken) { const userData = await api.get<User>('/me'); setUser(userData.data); setToken(savedToken); } setIsLoading(false); }; restore(); }, []); const login = async (email: string, password: string) => { const { data } = await api.post('/auth/login', { email, password }); await SecureStore.setItemAsync('token', data.token); setUser(data.user); setToken(data.token); }; const logout = async () => { await SecureStore.deleteItemAsync('token'); setUser(null); setToken(null); }; // Memoize to prevent unnecessary re-renders const value = useMemo( () => ({ user, token, isLoading, login, logout }), [user, token, isLoading] ); return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; } // Custom hook — safe & semantic export const useAuth = () => { const ctx = React.useContext(AuthContext); if (!ctx) throw new Error('useAuth must be used inside AuthProvider'); return ctx; };
A chef should only cook. Not cook + take orders + clean tables + manage billing. When you need to change the menu, only the chef is affected — not the waiter or cleaner.
<UserAvatar>, <UserName>, <FollowButton> — not one massive <UserProfile>useAuth(), useCart(), useProducts() — not one useEverything()userService.ts (API calls only), userStore.ts (state only), UserScreen.tsx (UI only)formatPrice(n), validateEmail(s), parseDate(d) — pure, single-purpose functionsuseEffects doing different things, or handles both API + UI + navigation — it violates SRP❌ OrderScreen: fetches orders, formats prices, handles payment, shows toast, navigates on success, tracks analytics — 6 responsibilities
✅ OrderScreen → orchestrates:
useOrders() — data fetchingOrderList — renders listpaymentService.charge() — payment logicuseAnalytics().track() — analyticsA power strip — you can plug in new devices (extend) without rewiring the strip (modify). The strip's core doesn't change when you add a new device.
<Button variant="primary" | "outline" | "ghost"> — new variant added via props + StyleSheet map, not by opening Button.tsxwithAuth(Component) adds auth-checking without touching Component❌ Bad: if(type==='primary') ... else if(type==='outline') ... inside Button — every new type requires opening the file
✅ Good:
const styles = { primary: {...}, outline: {...}, ghost: {...} }return <Pressable style={[base, styles[variant]]} />If you order a vehicle, you should be able to drive whatever shows up — car, truck, bus. They all steer, accelerate, brake. A vehicle that doesn't steer would violate LSP.
PrimaryButton, IconButton, GhostButton) must accept the same base props: onPress, disabled, titleuseAsyncStorage() and useMMKV() both return {get, set, remove} — components don't care which is injectedIconButton that ignores the disabled prop — screens using Button type would break when receiving IconButton// Base contract — all button variants MUST follow this interface ButtonProps { title: string; onPress: () => void; disabled?: boolean; loading?: boolean; testID?: string; } // ✅ All variants implement the contract function PrimaryButton({ title, onPress, disabled, loading }: ButtonProps) { return <Pressable onPress={!disabled && !loading ? onPress : undefined}>...</Pressable>; } function GhostButton({ title, onPress, disabled, loading }: ButtonProps) { // Must honour disabled + loading — or it violates LSP! return <Pressable onPress={!disabled && !loading ? onPress : undefined}>...</Pressable>; } // Screen is agnostic — any ButtonProps-conforming component works function SubmitArea({ ButtonComponent = PrimaryButton }: { ButtonComponent?: React.FC<ButtonProps> }) { return <ButtonComponent title="Submit" onPress={handleSubmit} />; // SubmitArea works with ANY button variant — LSP guaranteed }
A printer + scanner + fax machine vs three separate devices. If you only need to print, you shouldn't have to depend on (and break when) the scanner breaks. Separate interfaces for separate needs.
<UserAvatar name={user.name} url={user.avatarUrl} /> not <UserAvatar user={entireUser} />ProductCardProps should not include addToCart if some cards are display-onlyuser object passed → re-renders on ANY user field change (even unrelated ones)name + avatarUrl passed → only re-renders when name or avatar actually changesReact.memo(UserCard) for maximum optimisationuseCallback for function props to stabilise referencesYour laptop charger has a standard plug (abstraction). You can use it in any country with an adapter (implementation). The laptop doesn't care if it's UK or US power — it depends on the plug standard, not the wall socket.
interface StorageService — inject AsyncStorage or MMKV or MockStorage without changing consuming codeinterface Analytics { track(event, props): void } — swap Firebase for Amplitude for tests without code changesuseStorage(), don't import AsyncStorage directlyMockStorageService — no real storage, no disk I/O, fast tests// 1️⃣ Abstraction (interface) — the contract interface AnalyticsService { track(event: string, props?: Record<string, any>): void; identify(userId: string, traits?: Record<string, any>): void; screen(name: string): void; } // 2️⃣ Concrete: Firebase (production) class FirebaseAnalytics implements AnalyticsService { track(event, props) { analytics().logEvent(event, props); } identify(userId) { analytics().setUserId(userId); } screen(name) { analytics().logScreenView({ screen_name: name }); } } // 3️⃣ Concrete: Console logger (development) class DevAnalytics implements AnalyticsService { track(event, props) { console.log('📊 track', event, props); } identify(userId) { console.log('👤 identify', userId); } screen(name) { console.log('📱 screen', name); } } // 4️⃣ Concrete: Mock (testing) class MockAnalytics implements AnalyticsService { calls: any[] = []; track(event, props) { this.calls.push({ type: 'track', event, props }); } identify(userId) { this.calls.push({ type: 'identify', userId }); } screen(name) { this.calls.push({ type: 'screen', name }); } } // 5️⃣ Inject via context — components never import concrete class const AnalyticsContext = createContext<AnalyticsService>(null!); export const useAnalytics = () => useContext(AnalyticsContext); // App.tsx — pick implementation at app boundary <AnalyticsContext.Provider value={__DEV__ ? new DevAnalytics() : new FirebaseAnalytics()}> <App /> </AnalyticsContext.Provider> // Any component — uses abstraction, zero coupling to Firebase function ProductScreen() { const analytics = useAnalytics(); useEffect(() => { analytics.screen('ProductScreen'); }, []); const handleBuy = () => { analytics.track('purchase_clicked', { productId }); }; }
| Letter | Full Name | RN Violation Sign | RN Fix | Test Signal |
|---|---|---|---|---|
| S | Single Responsibility | 500+ line screen, multiple useEffects for different concerns | Custom hooks + sub-components + service files | Easy to unit test each piece |
| O | Open/Closed | if/else chain grows every time you add a feature | Variant props + StyleSheet map + registry pattern | Add feature without touching existing code |
| L | Liskov Substitution | Variant component ignores base props (disabled, onPress) | TypeScript interface for all variants + enforce contract | Swap variants in Storybook — all behave same |
| I | Interface Segregation | Passing full object when only 1–2 fields are used | Destructure and pass only needed primitives | Component re-renders only on relevant changes |
| D | Dependency Inversion | Direct import of AsyncStorage, Firebase inside component | Interface + Context injection + swap at app boundary | Tests use MockService — no real API/storage |