# React Native Dev

> Build React Native mobile features with Zustand state management, TanStack Query data fetching, and Centrifugo real-time subscriptions. Use this skill whenever someone asks to build a mobile screen, create a component, add a Zustand store, write a TanStack Query hook, or says things like "build the mobile UI for X", "create a screen for Y", "add real-time updates", "implement offline support", "set up navigation for X", or "how should I structure this component". Also trigger when someone mentions React Native performance, MMKV storage, map integration, or push notification implementation.

- Skill: `kaakati/react-native-dev` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add kaakati/react-native-dev`
- Raw SKILL.md: https://api.skillmd.com/api/skills/kaakati/react-native-dev/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: Kaakati (https://skillmd.com/u/kaakati)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/kaakati/react-native-dev

---


# React Native Developer

## Purpose
Build mobile features using our React Native stack: Zustand for state, TanStack Query for server data, Centrifugo for real-time, and community libraries over custom code.

## Development Protocol

### 1. Understand the Feature
- Which screens are involved?
- What data comes from the API vs local state?
- Is real-time needed? (Centrifugo subscription)
- Does it need offline support? (Zustand persist + TanStack cache)

### 2. API Layer (TanStack Query)
- Define query keys: `['orders', orderId]`, `['orders', { status, page }]`
- Create custom hooks in `mobile/src/hooks/queries/`:
  ```typescript
  export const useOrders = (filter: OrderFilter) =>
    useQuery({
      queryKey: ['orders', filter],
      queryFn: () => api.orders.list(filter),
      staleTime: 60_000,
    });

  export const useCreateOrder = () =>
    useMutation({
      mutationFn: api.orders.create,
      onSuccess: () => {
        queryClient.invalidateQueries({ queryKey: ['orders'] });
      },
    });
  ```

### 3. State Management (Zustand)
- Only for CLIENT state — auth, UI preferences, offline queues, navigation state
- Never duplicate server data in Zustand (that's TanStack Query's job)
- Create stores in `mobile/src/stores/`:
  ```typescript
  export const useAuthStore = create<AuthStore>()(
    persist(
      (set) => ({
        token: null,
        user: null,
        login: async (credentials) => { /* ... */ },
        logout: () => set({ token: null, user: null }),
      }),
      { name: 'auth-store', storage: createMMKVStorage() }
    )
  );
  ```

### 4. Component Structure
```
mobile/src/
├── screens/           # Full screen components (connected to navigation)
├── components/
│   ├── common/        # Reusable: Button, Input, Card, Avatar
│   └── features/      # Feature-specific: OrderCard, MapMarker
├── hooks/
│   ├── queries/       # TanStack Query hooks
│   ├── mutations/     # TanStack Mutation hooks
│   └── realtime/      # Centrifugo subscription hooks
├── stores/            # Zustand stores
├── services/          # API client, Centrifugo client
├── navigation/        # React Navigation config
├── theme/             # Colors, spacing, typography
└── utils/             # Helpers, formatters
```

### 5. Real-time (Centrifugo)

Owned by `std-react-native` → `@skills/std-react-native/references/realtime-centrifugo.md`, which
is scoped to React Native work. This skill previously carried its own hook here; it called
`centrifuge.subscribe(channel)`, which is **not this client's API** — subscriptions are created
with `newSubscription()` and retrieved with `getSubscription()` — so the snippet could not run,
and the shape it taught had the exact leak the reference exists to prevent.

Load-bearing rules, because they change what you write:

1. **One `Centrifuge` client for the app, created once.** Not per screen, not per hook.
2. **`newSubscription()` throws if the channel already has a subscription** — and a remounting
   screen calls it twice. Always `centrifuge.getSubscription(ch) ?? centrifuge.newSubscription(ch)`.
3. **A real-time event updates the Query cache; it never becomes a second copy of the data.**
   TanStack Query owns server state, Zustand owns client state — a socket quietly offers a third,
   and taking it means the same order exists twice with different values. Nothing crashes; the app
   is just wrong sometimes.
4. **Unsubscribing does not remove handlers you already set.** Remove *your* handler on cleanup,
   or every remount stacks another and the cache update runs N times per message. Leave the
   subscription alive if another screen shares the channel; reap it (`unsubscribe()` +
   `centrifuge.removeSubscription(sub)`) only when nothing is listening.
5. **The connection token expires** — wire `getToken`, or the socket dies silently after an hour.

What to do with an incoming event:

| The event… | Do |
|---|---|
| Carries the full updated entity | `setQueryData(key, entity)` — free update, no refetch |
| Carries only an id / "something changed" | `invalidateQueries({ queryKey })` — let Query refetch the truth |
| Is high-frequency (typing, cursors, presence) | Zustand or local state — ephemeral, never server state |

The complete `useChannel` hook, with the registry and handler-cleanup handling, is in the
reference. Copy it from there rather than reconstructing it — the four lines that look optional
are the ones that break on navigation.

### 6. Maps & Geospatial (PostGIS Backend)
- Use `react-native-maps` with markers from PostGIS spatial queries
- Debounce map region changes before API calls
- Cluster markers for performance: `react-native-map-clustering`
- Send viewport bounds to API for efficient spatial queries

## Key Libraries Reference

| Need | Library | Notes |
|------|---------|-------|
| State | `zustand` + `react-native-mmkv` | Persist with MMKV |
| Server data | `@tanstack/react-query` | Never useEffect for fetching |
| Forms | `react-hook-form` + `zod` | Schema validation |
| Navigation | `@react-navigation/native` | Type-safe params |
| Maps | `react-native-maps` | + clustering |
| Images | `react-native-fast-image` | Cached loading |
| Animations | `react-native-reanimated` | 60fps |
| HTTP | `axios` | Interceptors for auth |
| Storage | `react-native-mmkv` | Fastest local storage |
| Dates | `date-fns` | Tree-shakeable |
| Push | `@react-native-firebase/messaging` | FCM |
| Location | `react-native-geolocation-service` | Background tracking |

See references/react-native-patterns.md for component and hook patterns.

## Deep guides (read on demand, do not preload)

- Worked screens, hooks, stores, and navigation for this stack → `references/react-native-patterns.md`

### Owned by `std-react-native` (scoped to React Native work)

Decision-shaped, with the bad/good pairs. The file above shows *a* pattern; these say which and why:

- **Real-time (Centrifugo) — one socket, one cache, no second source of truth** →
  `@skills/std-react-native/references/realtime-centrifugo.md`
- **Offline & mutations — the queue that survives a cold start** →
  `@skills/std-react-native/references/offline-and-mutations.md`

Two facts from those that decide what you write, rather than how:

- **`staleTime` is per query, not one default.** `users` at 5 minutes and `orders` at 1 minute is
  correct — the table in `@skills/std-reactjs/references/data-fetching.md` is the reasoning.
- **Testing React Native is Jest, not Vitest.** There is no DOM, and Metro's transform pipeline is
  Jest-based → `@skills/std-testing/references/react-native.md`.

