0

15 min left

0% complete

Blog

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

·15 min read

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:

ActionBehaviorMutually Exclusive?
LikeMarks content as likedYes — with dislike
DislikeMarks content as dislikedYes — with like
Bookmark / FavoriteSaves contentIndependent

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 LastActionId

This 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 UI

3.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 counter

Since 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 ≤ 7stale, 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 needed

If Network Fails

  • onError → restore snapshot → UI reverts → error toast

If Stale-Rejected (rare with timestamps)

  • onSuccess → compare lastActionId → 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(): Clears likeActionIds, 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:

ScenarioExpected Result
Single click → restart appState persists
Rapid taps (5x)Final state matches last tap
Like then bookmarkBoth actions saved
Airplane mode → tapUI reverts, error shown
User A → logout → User BNo state leak
Double-tap backgroundHeart appears, item liked
Double-tap buttonOnly button action fires
New user → browseNo pre-liked items
Multiple sessionsFirst click always works

13. Implementation Checklist

✅ Backend

Store lastActionId per (userId, entityType, entityId)
Reject if actionId <= lastActionId
Return lastActionId and current state on all responses
Use HTTP 200 always (no error for stale requests)

✅ Frontend: Action ID

Use Date.now() with Math.max(now, last+1) for monotonicity
Track latest actionId per action type

✅ Frontend: Optimistic Updates

Cancel in-flight queries in onMutate
Snapshot state before optimistic update
Rollback in onError
Only sync in onSuccess if lastActionId !== actionId

✅ Frontend: Session & Gestures

Clear action maps and query cache on logout
Use useSharedValue for worklet-thread timing
Stamp button presses and guard in double-tap handler

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

Contents