Data Layer
Three layers, strictly separated: an axios module per domain, a TanStack Query hook per operation, and a query-key factory per domain. No Redux, no global store for server data.
components → hooks/<domain>/use*.ts → api/<domain>.ts → axios → REST API
↑
hooks/<domain>/keys.tsThe axios client
lib/axios.ts:
export const api = axios.create({ baseURL: Env.apiUrl });
api.interceptors.request.use(async (config) => {
const token = await AsyncStorage.getItem("token");
if (token) config.headers.Authorization = `Bearer ${token}`;
const isMultipart = config.data instanceof FormData
|| config.headers["Content-Type"] === "multipart/form-data";
if (!isMultipart) {
config.headers["Content-Type"] = config.headers["Content-Type"] ?? "application/json";
}
return config;
});Two things it handles so nothing else has to: attaching the token on every request, and not forcing a JSON content type on FormData — axios must set the multipart boundary itself.
The response interceptor turns error classes into user-visible snackbars:
| Status | Behaviour |
|---|---|
| 401 | "Session expired. Please log in again." |
| 403 | The server's message |
| ≥ 500 | "Something went wrong. Please try again." |
Errors are still rejected afterwards, so hooks can handle them too.
The token lives in AsyncStorage
AsyncStorage is unencrypted. expo-secure-store is already a dependency — moving the token there is a small, contained improvement.
API modules
One file per domain in api/, each a thin wrapper returning res.data:
export const fetchFeed = async (params: Params): Promise<FeedResponse> =>
api.get("/feed", { params }).then((res) => res.data);
export const getPost = async (id: string) =>
api.get(`/posts/${id}`).then((res) => res.data);Modules: auth, availability, channel, conversation, discover, feed, game, link, livestream, notifications, post, product, pushNotifications, reaction, search, sessions, settings, sponsor, subscription, trending, user, wallet.
Keep these free of React. No hooks, no state, no error handling beyond what the interceptor does.
Query client
lib/query-client.ts:
new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnReconnect: true,
refetchOnWindowFocus: false,
staleTime: 1000 * 60, // 1 minute
},
},
});refetchOnWindowFocus: false is correct for mobile — the concept maps poorly, and enabling it causes a refetch storm every time the app foregrounds. refetchOnReconnect: true covers the case that matters instead.
Query keys
Every domain has a keys.ts factory. Never inline a key array:
export const feedKeys = {
all: ["feed"] as const,
main: () => [...feedKeys.all] as const,
user: (userId: string) => ["user-feed", userId] as const,
userAll: () => ["user-feed"] as const,
discover: () => ["discover"] as const,
};
export const postKeys = {
all: ["post"] as const,
detail: (postId: string) => [...postKeys.all, postId] as const,
comments: (postId: string) => ["postComments", postId] as const,
};The factory is what makes invalidation reliable — queryClient.invalidateQueries({ queryKey: postKeys.all }) clears every post query, and nobody has to remember the string.
Hooks
Grouped by domain under hooks/:
hooks/
├── feed/keys.ts
├── profile/ keys.ts, useProfile, useMyProfile, useUpdateBio, useChangeAvatar, …
├── channel/ comment/ conversation/ links/ livestream/
├── notifications/ product/ reaction/ search/ sessions/
├── settings/ subscriptions/ wallet/ availability/
└── useFeed.ts useTrending.ts useSearch.ts useFollow.ts …Infinite lists
export const useFeed = () =>
useInfiniteQuery({
queryKey: feedKeys.main(),
queryFn: ({ pageParam }) => fetchFeed(pageParam ?? {}),
initialPageParam: {},
getNextPageParam: (lastPage) =>
lastPage.hasMore && lastPage.nextCursor
? { cursorId: lastPage.nextCursor.id, cursorCreatedAt: lastPage.nextCursor.createdAt }
: undefined,
staleTime: 1000 * 5,
});The cursor shape mirrors the API's compound { id, createdAt }. Feed queries override the global staleTime down to 5 seconds because freshness matters more there.
Refreshing without losing position
useRefreshFeed fetches only what is newer than the top item, and tells the server which posts are already on screen:
await fetchFeed({
refreshSinceId: newest.id,
refreshSinceCreatedAt: newest.createdAt,
excludePostIds: pages.flatMap((p) => p.items.map((x) => x.id)),
});That is what refreshSince and excludePostIds on GET /feed exist for.
Realtime integration
Socket events patch the query cache directly rather than triggering a refetch — hooks/useRealtimeMediaProcessing.ts:
socket.on("media:processing:update", (p) => {
queryClient.setQueryData(feedKeys.main(), patchFeedMediaItem(p.postId, p.media));
if (userId) queryClient.setQueryData(feedKeys.user(userId), patchFeedMediaItem(p.postId, p.media));
queryClient.setQueryData(postKeys.detail(p.postId), patchPostDetailMediaItem(p.media));
});Instant, and it costs no request. The patch helpers live in lib/media.ts.
Polling fallback
useMediaProcessingPoll re-fetches while media is pending, with jitter and only while the app is foregrounded:
const POLL_INTERVAL_MS = 15000;
const POLL_JITTER_MS = 5000;
refetchInterval: pending && isActive
? () => POLL_INTERVAL_MS + Math.random() * POLL_JITTER_MS
: false,The jitter prevents every client that posted at once from polling in lockstep, and useAppActive stops the poll in the background.
Its comment describes it as covering "missed/dropped socket events" — in practice it is currently the only delivery path for image-ready updates in production, because of the ogun prefix bug. See Media Pipeline.
Mutations
export const useSubscribe = () =>
useMutation({ mutationFn: (pkg: RCPackage) => RevenueCatService.purchasePackage(pkg) });Invalidate the affected keys in onSuccess. For anything the user should see immediately (a like, a follow), use an optimistic update with onMutate / onError rollback.
RevenueCatService is partly a mock
useSubscribe calls RevenueCatService.purchasePackage(), and the file's own comment notes it is a mock to be replaced when wiring the real SDK, plus a TODO to call the API afterwards to create the CreatorSubscription record. react-native-purchases is installed and RevenueCatProvider is mounted, so the integration is in progress. Check lib/revenueCat/ before assuming a purchase path is live. Partial
Adding an endpoint to the app
api/<domain>.ts— the axios call.hooks/<domain>/keys.ts— a key for it.hooks/<domain>/use<Thing>.ts— the query or mutation.- Use the hook. Never call
api.*from a component.
