15 min left
0% complete
Building a Production-Grade User Actions System in React Native
Have you ever tapped “like” on Instagram or TikTok and instantly seen the heart fill — even before the network confirms it? That seamless, flicker-free interaction isn’t magic. It’s a carefully engine
Have you ever tapped “like” on Instagram or TikTok and instantly seen the heart fill — even before the network confirms it? That seamless, flicker-free interaction isn’t magic. It’s a carefully engineered user actions system that handles rapid taps, race conditions, network failures, and session persistence with surgical precision.
In this guide, you’ll learn how to build a production-ready user actions system in React Native — the same way top-tier social apps do it. You’ll implement likes, dislikes, and bookmarks that are fast, reliable, and robust against edge cases.
Whether your users tap once, five times in a row, or double-tap to like from across town, your app will respond correctly — every time.
Let’s build it step by step.
1. What Is a "User Action"?
A user action is a toggle operation a user performs on content:
| Action | Behavior | Mutually Exclusive? |
|---|---|---|
| Like | Marks content as liked | Yes — with dislike |
| Dislike | Marks content as disliked | Yes — with like |
| Bookmark / Favorite | Saves content | Independent |
These are toggles — tap once to activate, tap again to deactivate.
The challenge? Humans are fast. A real user can like → unlike → like again in under a second. If your app doesn’t handle this well, you’ll see flickering, delayed feedback, or lost actions.
We’re going to eliminate all of that.
2. The Architecture at a Glance
USER TAP
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ useItemActions Hook │
│ │
│ 1. Optimistic update: UI changes IMMEDIATELY │
│ 2. Generate actionId using Date.now() │
│ 3. Send mutation to backend (GraphQL) │
│ 4. On success: verify if stale, sync if needed │
│ 5. On error: rollback UI to pre-tap state │
└─────────────────────────────┬───────────────────────────────────┘
│
┌────────────┴────────────┐
▼ ▼
┌────────────────────┐ ┌──────────────────────────┐
│ React Query Cache │ │ Backend (GraphQL API) │
│ │ │ │
│ ['itemActions', │ │ toggleLikeItem( │
│ itemId] │ │ itemId: String! │
│ { │ │ actionId: Long! │
│ isLiked, │◄───│ ): ToggleLikeResult │
│ isDisliked, │ │ { │
│ isBookmarked, │ │ isLiked: Boolean! │
│ likesCount │ │ lastActionId: Long! │
│ } │ │ } │
└────────────────────┘ └──────────────────────────┘Key Tools
- React Query: Manages cache, enables optimistic updates, and handles mutations
- GraphQL: Typed API layer for consistent responses
- React Native Gesture Handler (RNGH): Detects double-taps
- Reanimated: Handles animations and shares state between UI and JS threads
3. The Backend Contract — The Foundation
Before writing frontend code, you need a backend that enforces action ordering and idempotency.
The core idea: every mutation includes a unique actionId, and the backend only processes actions with a higher actionId than the last stored one.
3.1 The actionId Field
Your GraphQL mutation must include actionId: Long!:
mutation ToggleLikeItem($itemId: String!, $actionId: Long!) {
toggleLikeItem(itemId: $itemId, actionId: $actionId) {
isLiked
lastActionId
}
}actionId is a 64-bit integer (e.g., Unix timestamp in milliseconds) generated on the frontend.
3.2 How the Backend Validates Requests
The backend maintains a table to track the latest actionId per user and entity:
CREATE TABLE UserEntityActionVersions (
UserId VARCHAR(128) NOT NULL,
EntityType VARCHAR(128) NOT NULL, -- "Item" or "Coupon"
EntityId VARCHAR(128) NOT NULL, -- e.g., "item-456"
LastActionId BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (UserId, EntityType, EntityId)
);When a request arrives:
If actionId <= LastActionId → STALE → reject, return current state
If actionId > LastActionId → PROCESS → update state and LastActionIdThis is stale rejection — the backend ignores outdated requests but still returns the current state.
3.3 Response Shape
All responses return HTTP 200, even for stale rejections:
{
"data": {
"toggleLikeItem": {
"isLiked": true,
"lastActionId": 1740000000007
}
}
}Detecting Stale Rejection on the Frontend
Compare the sent actionId with lastActionId in the response:
// Processed
response.lastActionId === actionId → success, optimistic state confirmed
// Stale-rejected
response.lastActionId !== actionId → use server state to correct UI3.4 Scope of actionId
The actionId is scoped per (userId, entityType, entityId):
(user-123, Item, item-456) → shared counter for like/dislike/bookmark
(user-123, Coupon, coupon-789) → separate counterSince actionId is a monotonic timestamp, it naturally supports multiple action types.
4. Why "Single Click" Often Fails — And How to Fix It
The Problem
Early implementations used a sequential integer counter starting at 0 each app launch:
// BAD: resets every session
let actionId = 0;But the backend remembered the last `actionId` from the previous session, say 7.
When the user taps “like” in the new session:
- Sent
actionId = 1 - Backend compares:
1 ≤ 7→ stale, rejects silently
The UI might show the like (due to optimistic update), but the backend doesn’t persist it. Next app launch: action is lost.
Only after ~7 rapid clicks does actionId = 8 > 7, and the backend finally processes it.
The Solution: Use Timestamps as actionId
Instead of a reset counter, use Unix timestamps (Date.now()):
let lastIssuedActionId = 0;
function getNextActionId(itemId: string, actionType: ActionType): number {
const now = Date.now();
// Ensure strict monotonicity
lastIssuedActionId = now > lastIssuedActionId ? now : lastIssuedActionId + 1;
const actionId = lastIssuedActionId;
getActionIdMap(actionType).set(itemId, actionId);
return actionId;
}Why this works:
- Time moves forward → new
actionId> any stored value - No need to sync state with the backend on startup
- Survives app restarts seamlessly
Sub-millisecond safety: If two taps occur in the same millisecond, the +1 fallback ensures unique IDs.
5. Optimistic Updates — Instant UI Feedback
Users expect instant feedback when tapping. Waiting 300–800ms for a network response feels sluggish. Optimistic updates fix that.
How It Works
- Tap → UI updates immediately
- Network request runs in the background
- If it fails → UI reverts
- If it stale-rejects → UI syncs to server truth
Implementation with React Query
const likeMutation = useMutation({
mutationFn: async (actionId: number) => {
const data = await requestGraphQL(ToggleLikeItemDocument, { itemId, actionId });
return { data: data.toggleLikeItem, actionId };
},
onMutate: async (actionId) => {
await queryClient.cancelQueries({ queryKey: getItemActionsKey(itemId) });
const snapshot = queryClient.getQueryData<ItemActionState>(getItemActionsKey(itemId)) ?? DEFAULT_STATE;
const nextIsLiked = !snapshot.isLiked;
queryClient.setQueryData(getItemActionsKey(itemId), {
...snapshot,
isLiked: nextIsLiked,
isDisliked: nextIsLiked ? false : snapshot.isDisliked,
likesCount: nextIsLiked
? snapshot.likesCount + 1
: Math.max(0, snapshot.likesCount - 1),
});
return { snapshot, actionId };
},
onSuccess: (result, actionId) => {
if (actionId !== getCurrentActionId(itemId, 'like')) return;
const d = result.data;
if (!d || Number(d.lastActionId) === actionId) return;
queryClient.setQueryData(getItemActionsKey(itemId), (prev) => {
const s = prev ?? DEFAULT_STATE;
if (s.isLiked === d.isLiked) return s;
return {
...s,
isLiked: d.isLiked,
likesCount: s.likesCount + (d.isLiked ? 1 : -1)
};
});
},
onError: (error, actionId, context) => {
if (actionId !== getCurrentActionId(itemId, 'like')) return;
if (context?.snapshot) {
queryClient.setQueryData(getItemActionsKey(itemId), context.snapshot);
}
showBackendInfo(error, { fallbackEn: 'Could not update like. Please try again.' });
},
});Key Points
onMutate: Snapshot + optimistic update (immediate UI change)onSuccess: Only update if response differs from optimistic state (stale rejection)onError: Restore snapshot → UI reverts, error shown
6. Preventing Race Conditions
The Problem
Without precautions:
Tap Like (T=0ms) → optimistic: liked
Tap Unlike (T=100ms) → optimistic: unliked
Response for Like arrives (T=500ms) → UI: liked ❌ (overwrites unlike)The Solution: Track Latest actionId Per Action Type
Use separate maps to track recently sent actionId values:
const likeActionIds = new Map<string, number>();
const dislikeActionIds = new Map<string, number>();
const bookmarkActionIds = new Map<string, number>();In onSuccess and onError, check:
if (actionId !== getCurrentActionId(itemId, 'like')) return;This ensures only the latest tap updates the UI.
Why Separate Maps?
Like and bookmark are independent. If a user bookmarks (ID=1000), then likes (ID=1001), and the bookmark response arrives later:
- Separate maps: bookmark response (1000) matches
bookmarkActionIds→ accepted - Shared map: like incremented to 1001 → 1000 < 1001 → rejected → data loss
7. Full Request Lifecycle — From Tap to Database
Here’s the complete journey of a like tap:
1. User taps Like
└─ handleLikePress()
2. Analytics (non-blocking)
└─ capture('item liked', { itemId })
3. Generate actionId
└─ getNextActionId(): 1740000000042
└─ likeActionIds.set(itemId, 1740000000042)
4. onMutate (synchronous)
└─ Snapshot state
└─ Optimistically set: isLiked = true, likesCount + 1
└─ UI updates instantly
5. Network request
└─ GraphQL: { itemId: "item-456", actionId: 1740000000042 }
└─ Backend: 1740000000042 > stored → PROCESS → return { isLiked: true, lastActionId: 1740000000042 }
6. onSuccess
└─ actionId matches latest → continue
└─ response.lastActionId === sent → processed successfully
└─ No cache update neededIf Network Fails
onError→ restore snapshot → UI reverts → error toast
If Stale-Rejected (rare with timestamps)
onSuccess→ comparelastActionId→ sync to server state
8. Instagram-Style Double-Tap to Like
Double-tapping the card should show a heart animation and like the item — just like Instagram. It should never unlike.
Gesture Setup
// ExploreCard.tsx
const doubleTapGesture = Gesture.Tap()
.numberOfTaps(2)
.onEnd(() => {
'worklet';
if (Date.now() - lastActionPressTime.value < 600) return;
runOnJS(handleDoubleTap)();
});
return (
<GestureDetector gesture={doubleTapGesture}>
<Animated.View>{children}</Animated.View>
</GestureDetector>
);Double-Tap Handler with Animation
const handleDoubleTapLike = useCallback(() => {
heartScale.value = withSequence(
withSpring(1.2, { damping: 10, stiffness: 200 }),
withTiming(0, { duration: 300 })
);
heartOpacity.value = withSequence(
withTiming(1, { duration: 100 }),
withTiming(0, { duration: 200 })
);
if (!isLiked) {
toggleLike();
}
}, [isLiked, heartScale, heartOpacity, toggleLike]);Fixing Accidental Double-Taps
If users double-tap a button, the gesture may fire unintentionally.
Solution: Use a shared value to track button presses.
const lastActionPressTime = useSharedValue(0);
const handleActionPress = useCallback(() => {
lastActionPressTime.value = Date.now();
}, [lastActionPressTime]);In EngagementOverlay, call onActionPress() first in every button handler:
const handleDislikePress = useCallback(() => {
onActionPress?.();
toggleDislike();
}, [onActionPress, toggleDislike]);Why 600ms? Genuine double-taps take ~250–350ms. Button taps are faster, so 600ms safely ignores accidental triggers.
9. Session Isolation — Preventing State Leaks
When a user logs out, their action state must be cleared so the next user doesn’t inherit it.
Cleanup on Logout
const signOut = async () => {
setUser(null);
await clearTokens();
resetItemActionsState();
resetCouponActionsState();
queryClient.clear();
};resetItemActionsState(): ClearslikeActionIds,bookmarkActionIds, etc.queryClient.clear(): Wipes React Query cache
On next login, fresh data seeds the cache correctly.
10. Why React Query Is the Single Source of Truth
Using global state (e.g., useState or useSyncExternalStore) caused cascading re-renders:
- 50 items → each state change notified all
- Result: “Maximum update depth exceeded”
React Query fixes this:
- Each item has its own key:
['itemActions', itemId] - Changes to one item don’t notify others
- No cascading
Cache Key Definition
export const getItemActionsKey = (itemId: string) => ['itemActions', itemId] as const;Subscribe to State
const { data: state = DEFAULT_STATE } = useQuery({
queryKey: getItemActionsKey(itemId),
queryFn: () => DEFAULT_STATE,
staleTime: Infinity,
gcTime: Infinity,
});React Query becomes your shared in-memory store.
11. Seeding Initial State from the Server
When loading a list of items, the server includes each item’s action state:
{
"id": "item-456",
"isLiked": true,
"isDisliked": false,
"isBookmarked": false,
"likesCount": 42
}Seed the cache only if not already set:
export function seedItemStats(item: ItemWithStats): void {
if (!item?.id) return;
const itemId = item.id;
if (queryClient.getQueryData(getItemActionsKey(itemId))) return;
const isLiked = item.isLiked ?? false;
let likesCount = item.likesCount ?? 0;
if (isLiked && likesCount === 0) likesCount = 1;
queryClient.setQueryData(getItemActionsKey(itemId), {
isLiked,
isDisliked: item.isDisliked ?? false,
isBookmarked: item.isBookmarked ?? false,
likesCount,
});
}This ensures user interactions are not overwritten by stale server data.
12. Testing: Verification Scenarios
After implementation, verify these cases:
| Scenario | Expected Result |
|---|---|
| Single click → restart app | State persists |
| Rapid taps (5x) | Final state matches last tap |
| Like then bookmark | Both actions saved |
| Airplane mode → tap | UI reverts, error shown |
| User A → logout → User B | No state leak |
| Double-tap background | Heart appears, item liked |
| Double-tap button | Only button action fires |
| New user → browse | No pre-liked items |
| Multiple sessions | First click always works |
13. Implementation Checklist
✅ Backend
lastActionId per (userId, entityType, entityId)actionId <= lastActionIdlastActionId and current state on all responses✅ Frontend: Action ID
Date.now() with Math.max(now, last+1) for monotonicityactionId per action type✅ Frontend: Optimistic Updates
onMutateonErroronSuccess if lastActionId !== actionId✅ Frontend: Session & Gestures
useSharedValue for worklet-thread timing14. Common Mistakes — And How to Avoid Them
❌ Sequential counter resets to 0
→ Use Date.now() with increment fallback
❌ Wait for network before UI update
→ Use optimistic updates with onMutate
❌ Override UI state directly with response
→ Only update on stale rejection
❌ Single counter for all action types
→ Use separate maps: likeActionIds, bookmarkActionIds
❌ useRef in worklets
→ Use useSharedValue
❌ Forget to clear state on logout
→ Reset maps and clear query cache
❌ Notify during render
→ Avoid manual external stores; use React Query
Final Thoughts
Building a reliable user actions system isn’t about complex algorithms — it’s about thoughtful design:
- Timestamps as `actionId` solve session-persistence issues
- Optimistic updates make your app feel fast
- Per-type `actionId` tracking prevents race conditions
- React Query gives you atomic, scalable state
- Double-tap gestures add polish — with proper guards
With this architecture, your app behaves like Instagram or TikTok: fast, resilient, and user-centric.
Now go build something amazing.