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/: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/: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:
- One
Centrifuge client for the app, created once. Not per screen, not per hook.
newSubscription() throws if the channel already has a subscription — and a remounting
screen calls it twice. Always centrifuge.getSubscription(ch) ?? centrifuge.newSubscription(ch).
- 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.
- 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.
- 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.
1---2name: react-native-dev3description: 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.4---56# React Native Developer78## Purpose9Build 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.1011## Development Protocol1213### 1. Understand the Feature14- Which screens are involved?15- What data comes from the API vs local state?16- Is real-time needed? (Centrifugo subscription)17- Does it need offline support? (Zustand persist + TanStack cache)1819### 2. API Layer (TanStack Query)20- Define query keys: `['orders', orderId]`, `['orders', { status, page }]`21- Create custom hooks in `mobile/src/hooks/queries/`:22 ```typescript23 export const useOrders = (filter: OrderFilter) =>24 useQuery({25 queryKey: ['orders', filter],26 queryFn: () => api.orders.list(filter),27 staleTime: 60_000,28 });2930 export const useCreateOrder = () =>31 useMutation({32 mutationFn: api.orders.create,33 onSuccess: () => {34 queryClient.invalidateQueries({ queryKey: ['orders'] });35 },36 });37 ```3839### 3. State Management (Zustand)40- Only for CLIENT state — auth, UI preferences, offline queues, navigation state41- Never duplicate server data in Zustand (that's TanStack Query's job)42- Create stores in `mobile/src/stores/`:43 ```typescript44 export const useAuthStore = create<AuthStore>()(45 persist(46 (set) => ({47 token: null,48 user: null,49 login: async (credentials) => { /* ... */ },50 logout: () => set({ token: null, user: null }),51 }),52 { name: 'auth-store', storage: createMMKVStorage() }53 )54 );55 ```5657### 4. Component Structure58```59mobile/src/60├── screens/ # Full screen components (connected to navigation)61├── components/62│ ├── common/ # Reusable: Button, Input, Card, Avatar63│ └── features/ # Feature-specific: OrderCard, MapMarker64├── hooks/65│ ├── queries/ # TanStack Query hooks66│ ├── mutations/ # TanStack Mutation hooks67│ └── realtime/ # Centrifugo subscription hooks68├── stores/ # Zustand stores69├── services/ # API client, Centrifugo client70├── navigation/ # React Navigation config71├── theme/ # Colors, spacing, typography72└── utils/ # Helpers, formatters73```7475### 5. Real-time (Centrifugo)7677Owned by `std-react-native` → `@skills/std-react-native/references/realtime-centrifugo.md`, which78is scoped to React Native work. This skill previously carried its own hook here; it called79`centrifuge.subscribe(channel)`, which is **not this client's API** — subscriptions are created80with `newSubscription()` and retrieved with `getSubscription()` — so the snippet could not run,81and the shape it taught had the exact leak the reference exists to prevent.8283Load-bearing rules, because they change what you write:84851. **One `Centrifuge` client for the app, created once.** Not per screen, not per hook.862. **`newSubscription()` throws if the channel already has a subscription** — and a remounting87 screen calls it twice. Always `centrifuge.getSubscription(ch) ?? centrifuge.newSubscription(ch)`.883. **A real-time event updates the Query cache; it never becomes a second copy of the data.**89 TanStack Query owns server state, Zustand owns client state — a socket quietly offers a third,90 and taking it means the same order exists twice with different values. Nothing crashes; the app91 is just wrong sometimes.924. **Unsubscribing does not remove handlers you already set.** Remove *your* handler on cleanup,93 or every remount stacks another and the cache update runs N times per message. Leave the94 subscription alive if another screen shares the channel; reap it (`unsubscribe()` +95 `centrifuge.removeSubscription(sub)`) only when nothing is listening.965. **The connection token expires** — wire `getToken`, or the socket dies silently after an hour.9798What to do with an incoming event:99100| The event… | Do |101|---|---|102| Carries the full updated entity | `setQueryData(key, entity)` — free update, no refetch |103| Carries only an id / "something changed" | `invalidateQueries({ queryKey })` — let Query refetch the truth |104| Is high-frequency (typing, cursors, presence) | Zustand or local state — ephemeral, never server state |105106The complete `useChannel` hook, with the registry and handler-cleanup handling, is in the107reference. Copy it from there rather than reconstructing it — the four lines that look optional108are the ones that break on navigation.109110### 6. Maps & Geospatial (PostGIS Backend)111- Use `react-native-maps` with markers from PostGIS spatial queries112- Debounce map region changes before API calls113- Cluster markers for performance: `react-native-map-clustering`114- Send viewport bounds to API for efficient spatial queries115116## Key Libraries Reference117118| Need | Library | Notes |119|------|---------|-------|120| State | `zustand` + `react-native-mmkv` | Persist with MMKV |121| Server data | `@tanstack/react-query` | Never useEffect for fetching |122| Forms | `react-hook-form` + `zod` | Schema validation |123| Navigation | `@react-navigation/native` | Type-safe params |124| Maps | `react-native-maps` | + clustering |125| Images | `react-native-fast-image` | Cached loading |126| Animations | `react-native-reanimated` | 60fps |127| HTTP | `axios` | Interceptors for auth |128| Storage | `react-native-mmkv` | Fastest local storage |129| Dates | `date-fns` | Tree-shakeable |130| Push | `@react-native-firebase/messaging` | FCM |131| Location | `react-native-geolocation-service` | Background tracking |132133See references/react-native-patterns.md for component and hook patterns.134135## Deep guides (read on demand, do not preload)136137- Worked screens, hooks, stores, and navigation for this stack → `references/react-native-patterns.md`138139### Owned by `std-react-native` (scoped to React Native work)140141Decision-shaped, with the bad/good pairs. The file above shows *a* pattern; these say which and why:142143- **Real-time (Centrifugo) — one socket, one cache, no second source of truth** →144 `@skills/std-react-native/references/realtime-centrifugo.md`145- **Offline & mutations — the queue that survives a cold start** →146 `@skills/std-react-native/references/offline-and-mutations.md`147148Two facts from those that decide what you write, rather than how:149150- **`staleTime` is per query, not one default.** `users` at 5 minutes and `orders` at 1 minute is151 correct — the table in `@skills/std-reactjs/references/data-fetching.md` is the reasoning.152- **Testing React Native is Jest, not Vitest.** There is no DOM, and Metro's transform pipeline is153 Jest-based → `@skills/std-testing/references/react-native.md`.