Skip to content

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.ts

The axios client

lib/axios.ts:

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:

StatusBehaviour
401"Session expired. Please log in again."
403The 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:

ts
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:

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:

ts
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

ts
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:

ts
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:

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:

ts
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

ts
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

  1. api/<domain>.ts — the axios call.
  2. hooks/<domain>/keys.ts — a key for it.
  3. hooks/<domain>/use<Thing>.ts — the query or mutation.
  4. Use the hook. Never call api.* from a component.

Internal documentation — PlayPalz platform