Skip to content
Back to skills

Frontend State Management

ASecurity

SocialApp user frontend state management — TanStack Query, Zustand, IndexedDB persistence, cache invalidation, multi-account isolation

  • 3 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 16, 2026
ai-agentstypescriptgoreactapifrontendbackendperformance

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 16, 2026

npx -y skills add RaNDoM6913/claude-code-superkit --skill frontend-state-management --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Frontend State Management?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Frontend State Management
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/random6913-frontend-state-management/badge)](https://www.skillsdirectory.com/skills/random6913-frontend-state-management)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: frontend-state-management
description: SocialApp user frontend state management — TanStack Query, Zustand, IndexedDB persistence, cache invalidation, multi-account isolation
user-invocable: false
---

# Frontend State Management

State management in the user frontend follows a strict split: **server state** lives in TanStack Query, **client-only state** lives in Zustand. Never mix the two.

## TanStack Query Patterns

### Query Key Factory

All query keys are centralized in `src/api/query-keys.ts`:

```typescript
export const queryKeys = {
  me: ["me"] as const,
  entitlements: ["entitlements"] as const,
  likesIncoming: ["likes", "incoming"] as const,
  matches: ["matches"] as const,
  notifications: ["notifications"] as const,
  privacySettings: ["settings", "privacy"] as const,
  notificationSettings: ["settings", "notifications"] as const,
  profile: ["profile"] as const,
  blockedUsers: ["blocked"] as const,
  blockedContacts: ["blockedContacts"] as const,
  travel: ["travel"] as const,
  feed: ["feed"] as const,
  storeCatalog: ["store", "catalog"] as const,
};
```

Rules:
- **Always** use `queryKeys.xxx` — never inline string arrays
- Keys use `as const` for type-safe matching in `invalidateQueries`
- Hierarchical keys: `["settings", "privacy"]` lets you invalidate all settings with `{ queryKey: ["settings"] }`
- Add new keys here when adding new API endpoints — keep alphabetical within groups

### Query Client Config

Location: `src/api/query-client.ts`

```typescript
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,       // 30s — data considered fresh
      gcTime: 24 * 60 * 60_000, // 24h — keep in memory/IDB across sessions
      retry: 1,                 // single retry on failure
      refetchOnWindowFocus: false, // TG mini app manages own lifecycle
    },
  },
});
```

### Cache Invalidation After Mutations

Pattern: API function performs mutation, then invalidates related queries.

```typescript
// src/api/profile.ts
export async function updateProfile(payload: ProfileCoreRequest): Promise<ProfileCoreResponse> {
  const result = await requestJSON<ProfileCoreResponse, ProfileCoreRequest>("/v1/profile/core", {
    method: "POST",
    body: payload,
  });
  // Invalidate stale caches — profile data changed, /me might too
  void queryClient.invalidateQueries({ queryKey: queryKeys.profile });
  void queryClient.invalidateQueries({ queryKey: queryKeys.me });
  return result;
}
```

Rules:
- Use `void` prefix — fire-and-forget invalidation (don't block the return)
- Invalidate **all related keys** — e.g. `postRevealOne` invalidates `likesIncoming` + `entitlements` + `me`
- Invalidation is always in the API function (not the component) — ensures consistency regardless of call site

### When to use `useQuery` vs manual fetch

| Use `useQuery` | Use manual `requestJSON` |
|----------------|--------------------------|
| Data displayed on screen (feed, profile, settings) | One-off mutations (swipe, reveal, delete) |
| Needs caching, refetching, loading/error states | Fire-and-forget actions |
| Multiple components share the same data | Response is only used for side-effects |
| Data should survive across navigations | Data isn't displayed (e.g., POST /v1/events) |

### Anti-Patterns

**1. Querying inside event handlers** — BAD:
```typescript
// WRONG: creates a new query on every click
const handleClick = async () => {
  const data = await queryClient.fetchQuery({ queryKey: queryKeys.profile, queryFn: fetchProfile });
};
```
Fix: use `useQuery` in the component, read from cache.

**2. Missing error boundaries** — BAD:
```typescript
// WRONG: unhandled query errors crash the app
const { data } = useQuery({ queryKey: queryKeys.feed, queryFn: getFeed });
```
Fix: always wrap query-consuming subtrees in `<ErrorBoundary>`.

**3. Stale closures in callbacks** — BAD:
```typescript
// WRONG: onClick captures stale `data` from previous render
const { data } = useQuery({ queryKey: queryKeys.me, queryFn: fetchMe });
const handleAction = () => doSomething(data); // stale if data refetched
```
Fix: read `data` from the latest render, or use `queryClient.getQueryData(queryKeys.me)`.

**4. Importing queryClient in components** — BAD:
```typescript
// WRONG: imperative cache reads scattered across components
import { queryClient } from "@/api/query-client";
const cached = queryClient.getQueryData(queryKeys.me);
```
Fix: use `useQuery` hook — it subscribes to updates. Direct `queryClient` access belongs in API functions only.

## Zustand Patterns

### Navigation Store

Single store for all client-only navigation state. Location: `src/stores/navigation.ts`.

```typescript
export const useNavigationStore = create<NavigationState>((set, get) => ({
  // State
  settingsScreen: null,
  showNotificationsInbox: false,
  selectedMatch: null,
  likesCount: 0,
  matchesCount: 0,

  // Actions — verb + noun naming
  openSettings: (screen) => set({ settingsScreen: screen }),
  closeSettings: () => set({ settingsScreen: null, storeInitialProductKey: null }),
  selectMatch: (match) => set({ selectedMatch: match }),
  clearMatch: () => set({ selectedMatch: null }),
  setLikesCounts: (likes, matches) => set({ likesCount: likes, matchesCount: matches }),

  // Computed — use `get()` for derived state
  closeTopOverlay: () => {
    const s = get();
    if (s.settingsScreen) { set({ settingsScreen: null }); return true; }
    if (s.showNotificationsInbox) { set({ showNotificationsInbox: false }); return true; }
    return false;
  },

  hasOverlay: () => {
    const s = get();
    return !!(s.settingsScreen || s.showNotificationsInbox || s.showProfileSettingsModal);
  },

  // Reset (called on user switch)
  reset: () => set({ settingsScreen: null, selectedMatch: null, likesCount: 0, matchesCount: 0 }),
}));
```

### Selector Memoization

**Always** use selectors. Never destructure the entire store.

```typescript
// GOOD: only re-renders when settingsScreen changes
const settingsScreen = useNavigationStore((s) => s.settingsScreen);
const openSettings = useNavigationStore((s) => s.openSettings);

// BAD: re-renders on ANY store change
const { settingsScreen, openSettings } = useNavigationStore();
```

For multiple selectors that should be a single subscription, use `useShallow`:
```typescript
import { useShallow } from "zustand/react/shallow";

const { likesCount, matchesCount } = useNavigationStore(
  useShallow((s) => ({ likesCount: s.likesCount, matchesCount: s.matchesCount }))
);
```

### Action Naming Convention

| Pattern | Examples |
|---------|----------|
| `open` + noun | `openSettings`, `openStore`, `openNotifications`, `openMenu` |
| `close` + noun | `closeSettings`, `closeNotifications`, `closeMenu` |
| `select` + noun | `selectMatch` |
| `clear` + noun | `clearMatch` |
| `set` + noun | `setLikesViewMode`, `setLikesCounts`, `setNotificationUnreadCount` |

### When Zustand vs When TanStack Query

| Zustand | TanStack Query |
|---------|---------------|
| Current screen / tab | User profile from API |
| UI overlay open/closed | Feed candidates from API |
| Selected match for detail view | Entitlements snapshot |
| Likes view mode (likes/matches) | Privacy settings from API |
| Badge counts (derived from API, but displayed in UI) | Notification list |
| Form draft state | Store catalog |

Rule of thumb: if the data **comes from the server**, use TanStack Query. If it's **UI-only state** that the server doesn't know about, use Zustand.

## IndexedDB Persistence

### Setup

Location: `src/api/query-persister.ts`

```typescript
import { get, set, del } from "idb-keyval";
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";

const IDB_KEY = "app-query-cache";

export const idbPersister: Persister = {
  persistClient: async (client) => { await set(IDB_KEY, client); },
  restoreClient: async () => { return await get<PersistedClient>(IDB_KEY); },
  removeClient: async () => { await del(IDB_KEY); },
};
```

Key points:
- Uses `idb-keyval` (tiny, promise-based IndexedDB wrapper)
- ~100MB+ storage quota (vs 5MB localStorage)
- Async — doesn't block the main thread
- Single IDB key stores the entire persisted query cache
- `gcTime: 24h` keeps cached data across app restarts within a day

### Wiring in App

```typescript
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { queryClient } from "./api/query-client";
import { idbPersister } from "./api/query-persister";

<PersistQueryClientProvider client={queryClient} persistOptions={{ persister: idbPersister }}>
  {children}
</PersistQueryClientProvider>
```

## Multi-Account Isolation

Location: `src/api/user-guard.ts`

When a different Telegram account logs in on the same device, **all cached data must be purged** to prevent data leakage.

```typescript
export function guardUserSwitch(userId: number): void {
  const saved = localStorage.getItem("app.last_user_id");
  const savedId = saved ? parseInt(saved, 10) : null;

  if (savedId !== null && !isNaN(savedId) && savedId !== userId) {
    purgeAllUserData();
  }

  localStorage.setItem("app.last_user_id", String(userId));
}
```

Purge sequence:
1. Preserve `myapp.device_id` (per-device, not per-user)
2. Clear all `localStorage`
3. Restore `device_id`
4. Clear `sessionStorage`
5. Clear IndexedDB TanStack Query cache (`idbPersister.removeClient()`)
6. Clear in-memory TanStack Query cache (`queryClient.clear()`)
7. Reset analytics session
8. Reset Zustand navigation store (`useNavigationStore.getState().reset()`)

**Must be called BEFORE writing new auth state** — so old auth tokens are cleared.

## Optimistic Updates with Rollback

Use `useMutation` with `onMutate` / `onError` / `onSettled` for instant UI feedback on user actions (swipes, likes, blocks, settings toggles). The pattern: cancel in-flight queries, snapshot the cache, apply the optimistic update, and roll back from the snapshot on error.

### Standard Pattern

```typescript
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/api/query-keys";
import { hapticNotification } from "@/haptics";

const queryClient = useQueryClient();

const swipeMutation = useMutation({
  mutationFn: (action: SwipeAction) => postSwipe(action),

  onMutate: async (action) => {
    // 1. Cancel any in-flight refetches so they don't overwrite our optimistic update
    await queryClient.cancelQueries({ queryKey: queryKeys.feed });

    // 2. Snapshot current cache for rollback
    const previousFeed = queryClient.getQueryData(queryKeys.feed);

    // 3. Optimistic update — remove swiped card from feed
    queryClient.setQueryData(queryKeys.feed, (old: FeedResponse | undefined) => {
      if (!old) return old;
      return { ...old, candidates: old.candidates.filter((c) => c.id !== action.candidateId) };
    });

    // 4. Return snapshot as context for onError rollback
    return { previousFeed };
  },

  onError: (_err, _action, context) => {
    // Rollback to snapshot
    if (context?.previousFeed) {
      queryClient.setQueryData(queryKeys.feed, context.previousFeed);
    }
    // Haptic error feedback — user feels the rollback
    hapticNotification("error");
  },

  onSettled: () => {
    // Always refetch to ensure server truth, whether success or error
    void queryClient.invalidateQueries({ queryKey: queryKeys.feed });
  },
});
```

### Rules

- **Always cancel queries first** (`cancelQueries`) — prevents race conditions where a refetch overwrites the optimistic state
- **Snapshot before mutating** — store `getQueryData` result in `onMutate` return value
- **Rollback on error** — restore from context, never leave stale optimistic data
- **Haptic feedback on error** — `hapticNotification("error")` so the user knows the action failed
- **Invalidate on settled** — regardless of success/failure, re-sync with server truth
- **Keep optimistic updates simple** — only modify the minimum cache keys needed; do not reconstruct complex derived state

### When to Use Optimistic Updates

| Use optimistic | Use await-then-update |
|---|---|
| Swipe like/nope/superlike (instant feel) | Profile core update (complex validation) |
| Block/unblock user (toggle) | Photo upload (needs server processing) |
| Mark notification read (toggle) | Store purchase (payment confirmation needed) |
| Settings toggle (boolean flip) | Account deletion (irreversible) |

### Anti-Pattern: Optimistic Without Rollback

```typescript
// WRONG: no rollback path — on error, UI shows stale optimistic state forever
onMutate: async () => {
  queryClient.setQueryData(queryKeys.feed, (old) => /* mutate */);
  // missing: no snapshot returned, no onError handler
},
```

## Offline Mutation Queue

For resilient mutations when network is unreliable (common in TG WebApp on mobile):

### networkMode Configuration

```typescript
// Per-mutation: queue mutations when offline, execute when back online
const likeMutation = useMutation({
  mutationFn: postLike,
  networkMode: "offlineFirst",  // queue if offline, retry when online
  retry: 3,
  retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000), // exponential: 1s, 2s, 4s... max 30s
});
```

### Connectivity Detection

**Do NOT rely on `navigator.onLine`** — it is unreliable in Telegram WebView (reports `true` even when the mini app has no actual connectivity).

Use a heartbeat check instead:

```typescript
const HEARTBEAT_URL = "/healthz"; // lightweight backend endpoint
const HEARTBEAT_INTERVAL = 15_000; // 15s

let isOnline = true;

async function checkConnectivity(): Promise<boolean> {
  try {
    const res = await fetch(HEARTBEAT_URL, {
      method: "HEAD",
      cache: "no-store",
      signal: AbortSignal.timeout(5000),
    });
    isOnline = res.ok;
  } catch {
    isOnline = false;
  }
  return isOnline;
}

// Poll on interval — use sparingly, only when offline mutations matter
setInterval(checkConnectivity, HEARTBEAT_INTERVAL);
```

### Rules

- `networkMode: "offlineFirst"` — mutations are queued in memory when offline, auto-retried when connectivity returns
- Exponential backoff with ceiling: `Math.min(1000 * 2 ** attempt, 30_000)`
- **Never use `navigator.onLine`** for connectivity checks in TG WebView
- Heartbeat to `/healthz` (HEAD, no-store, 5s timeout) for real connectivity signal
- Queued mutations are lost on page close — acceptable for non-critical actions (swipes, reads); critical actions (purchases) should show explicit retry UI

## refetchOnWindowFocus Strategy

### Global Default: Disabled

```typescript
// src/api/query-client.ts
defaultOptions: {
  queries: {
    refetchOnWindowFocus: false, // TG mini app focus events are noisy
  },
},
```

**Why**: Telegram WebApp fires `visibilitychange` and `focus` events frequently (keyboard open/close, notification panel, app switcher). Refetching on every focus event causes unnecessary network traffic and UI flicker.

### Exception: Notifications

Notifications need fresh data when the user returns to the app:

```typescript
// In the notifications query
useQuery({
  queryKey: queryKeys.notifications,
  queryFn: fetchNotifications,
  refetchOnWindowFocus: true,  // override global default
  staleTime: 10_000,           // shorter stale time (10s) for notifications
});
```

### When to Override

| Override to `true` | Keep `false` (default) |
|---|---|
| Notifications (user expects fresh count) | Feed (pre-fetched, user-paced) |
| Entitlements after purchase flow return | Profile (user-initiated edits only) |
| | Settings (rarely changed externally) |
| | Likes/matches (push-driven, not poll-driven) |

## Tiered Photo Prefetching

Feed performance depends on having the next cards ready before the user swipes. Use a 3-tier prefetching strategy with decreasing priority.

### Tier 1: Next 3 Card Data via `prefetchQuery`

Prefetch full card data (profile, photos array, thumbhashes) for the next 3 candidates in the feed queue. This runs as a TanStack Query prefetch — cached and deduplicated automatically.

```typescript
// Called after feed data loads or after each swipe
function prefetchNextCards(candidates: MatchCard[], currentIndex: number): void {
  const next3 = candidates.slice(currentIndex + 1, currentIndex + 4);

  for (const card of next3) {
    queryClient.prefetchQuery({
      queryKey: ["candidate", card.id],
      queryFn: () => fetchCandidate(card.id),
      staleTime: 5 * 60_000, // 5min — candidate data rarely changes
    });
  }
}
```

### Tier 2: Next Card Primary Photo via `new Image()`

After Tier 1 data is cached, preload the **primary photo** (index 0) of the next card into the browser image cache. This ensures the first visible photo renders instantly on swipe.

```typescript
function preloadNextCardPhoto(candidates: MatchCard[], currentIndex: number): void {
  const nextCard = candidates[currentIndex + 1];
  if (!nextCard?.photos?.[0]?.url) return;

  const img = new Image();
  img.src = nextCard.photos[0].url;
  // No need to append to DOM — browser caches the decoded image
}
```

### Tier 3: Next+1 Photo via `requestIdleCallback`

When the browser is idle, preload the primary photo of the card after next (currentIndex + 2). Uses `requestIdleCallback` to avoid competing with swipe animations and main-thread work.

```typescript
function preloadNextPlusOnePhoto(candidates: MatchCard[], currentIndex: number): void {
  const card = candidates[currentIndex + 2];
  if (!card?.photos?.[0]?.url) return;

  const preload = () => {
    const img = new Image();
    img.src = card.photos[0].url;
  };

  if ("requestIdleCallback" in window) {
    requestIdleCallback(preload, { timeout: 3000 }); // 3s max wait
  } else {
    setTimeout(preload, 200); // fallback for environments without rIC
  }
}
```

### Orchestration

Call all three tiers after each swipe and on initial feed load:

```typescript
function onFeedUpdate(candidates: MatchCard[], currentIndex: number): void {
  // Tier 1: data prefetch (highest priority — TanStack Query handles dedup)
  prefetchNextCards(candidates, currentIndex);

  // Tier 2: next card primary photo (high priority — user will see this next)
  preloadNextCardPhoto(candidates, currentIndex);

  // Tier 3: next+1 photo (low priority — idle time only)
  preloadNextPlusOnePhoto(candidates, currentIndex);
}
```

### Rules

- **Tier 1** runs on every swipe and initial load — TanStack Query deduplicates automatically
- **Tier 2** uses `new Image()` — browser-native, no library needed, cached by URL
- **Tier 3** uses `requestIdleCallback` with a 3s timeout fallback — never blocks swipe animations
- **ThumbHash first**: while photos load, ThumbHash placeholders (`ThumbHashImage` component) provide instant LQIP
- **Presigned URLs**: all photo URLs are presigned S3 URLs with TTL — prefetched images remain valid for the session but may expire across sessions
- **Cleanup**: no explicit cleanup needed — browser image cache and TanStack Query gcTime handle eviction

### Anti-Pattern: Eager Full Prefetch

```typescript
// WRONG: prefetching ALL photos for ALL remaining candidates
// This wastes bandwidth and competes with the current card's rendering
for (const card of candidates) {
  for (const photo of card.photos) {
    new Image().src = photo.url; // floods the network queue
  }
}
```

Fix: only prefetch the primary photo (index 0) for the next 1-2 cards. Secondary photos load on-demand when the user opens the detail view.

## gcTime / maxAge / buster Alignment

**Rule**: Persister `maxAge` MUST be >= QueryClient `gcTime`. Otherwise IndexedDB drops data before QueryClient expects, causing empty/inconsistent state on restore.

```typescript
// query-client.ts
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,          // 30s
      gcTime: 24 * 60 * 60_000,   // 24h
    },
  },
});

// query-persister.ts — maxAge >= gcTime
const persister = createIDBPersister({
  maxAge: 24 * 60 * 60_000,       // 24h — matches gcTime
  buster: 'v1',                    // bump on breaking API changes to force cache reset
});
```

**`buster`**: a version string. When you change it (e.g., `v1` → `v2`), all cached data is invalidated. Use after:
- Breaking API response format changes
- Major schema migrations
- Removing/renaming query keys

## Zustand useShallow Standard

**Rule**: Always use `useShallow` when selecting multiple fields from a Zustand store. Without it, the component re-renders on ANY store change, even to fields it doesn't use.

```typescript
// CORRECT — only re-renders when selected fields change
import { useShallow } from 'zustand/shallow';

const { currentScreen, likesCount } = useNavigationStore(
  useShallow((s) => ({
    currentScreen: s.currentScreen,
    likesCount: s.likesCount,
  }))
);

// WRONG — re-renders on every store update (matchesCount, overlays, etc.)
const { currentScreen, likesCount } = useNavigationStore();

// EXCEPTION — single field selection is fine without useShallow
const currentScreen = useNavigationStore((s) => s.currentScreen);
```

**When useShallow is NOT needed**:
- Selecting a single primitive field via selector
- Selecting a single action (functions are referentially stable)

## Key Files

| File | Purpose |
|------|---------|
| `src/api/query-keys.ts` | Centralized query key factory |
| `src/api/query-client.ts` | TanStack Query client config (staleTime, gcTime, retry) |
| `src/api/query-persister.ts` | IndexedDB persister via idb-keyval |
| `src/api/user-guard.ts` | Multi-account isolation (purge on user switch) |
| `src/stores/navigation.ts` | Zustand navigation store (screens, overlays, badges) |
| `src/api/http.ts` | Base HTTP client (`requestJSON`) |
| `src/api/*.ts` | Per-domain API functions with cache invalidation |

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.