Skip to content

02 - Data & State Patterns


1. Structural Sharing

What: When creating a new version of a data structure, REUSE the parts that didn't change instead of deep cloning everything. Only the changed path gets new references.

Original:                     After updating user.address.city:

      state                         newState (new ref)
      /   \                         /      \
   user   posts              newUser(new)  posts (SAME ref, reused)
   / \                        / \
name  address              name  newAddress (new ref)
       |                   (same) |
      city                      city = "Mumbai" (new)

Why it matters:

  • React.memo and selectors compare by REFERENCE (===)
  • If posts has the same reference, components using posts DON'T re-render
  • Without structural sharing, a deep clone gives posts a new reference → unnecessary re-render

Who uses it:

  • Immer (used by Redux Toolkit) — produces structurally shared updates
  • TanStack Query — structurally shares query results between refetches
  • Zustand — when you spread state { ...state, user: newUser } this IS structural sharing
tsx
// This is structural sharing (spread creates new object, but keeps unchanged refs)
set((state) => ({
  ...state,                    // posts, settings, etc. keep same reference
  user: {
    ...state.user,             // name, email keep same reference
    address: {
      ...state.user.address,   // zip keeps same reference
      city: 'Mumbai',          // only city is new
    },
  },
}));

2. Immutable Data Patterns

What: Never mutate existing data. Always create new copies with changes applied. This is fundamental to how React detects changes.

Why React needs immutability:

tsx
// BAD: Mutation — React can't detect the change
const handleClick = () => {
  user.name = 'New Name';  // same object reference
  setUser(user);            // React sees same ref → skips re-render!
};

// GOOD: Immutable update — new reference
const handleClick = () => {
  setUser({ ...user, name: 'New Name' }); // new object → React re-renders
};

Nested updates are verbose — that's why Immer exists:

tsx
// Without Immer (manual spreading)
setState({
  ...state,
  users: state.users.map(u =>
    u.id === targetId
      ? { ...u, address: { ...u.address, city: 'Mumbai' } }
      : u
  ),
});

// With Immer (via Redux Toolkit or standalone)
setState(produce(state, (draft) => {
  const user = draft.users.find(u => u.id === targetId);
  if (user) user.address.city = 'Mumbai'; // looks like mutation, but isn't
}));

Immer under the hood: Creates a Proxy around the draft. Tracks which properties you touch. Only creates new objects for the touched path (structural sharing).


3. Referential Equality

What: Two values are "referentially equal" if they point to the exact same memory location. In JS: === for objects checks reference, not content.

js
const a = { name: 'Alice' };
const b = { name: 'Alice' };
const c = a;

a === b  // false (different objects in memory, even though same content)
a === c  // true  (same reference)

Why it matters in React:

  • React.memo uses === to compare props
  • useEffect dependency array uses ===
  • useMemo/useCallback dependency array uses ===
  • Zustand selectors use === to decide re-renders
tsx
// BUG: New object every render → useEffect runs every render
function Component({ userId }: { userId: string }) {
  const options = { includeDeleted: false }; // new ref every render!

  useEffect(() => {
    fetchUser(userId, options);
  }, [userId, options]); // options is always "new" → infinite loop
}

// FIX: Stable reference
function Component({ userId }: { userId: string }) {
  const options = useMemo(() => ({ includeDeleted: false }), []);

  useEffect(() => {
    fetchUser(userId, options);
  }, [userId, options]); // options is stable now
}

4. Memoization Pitfalls

What: useMemo, useCallback, and React.memo are tools for optimization, but they have traps.

Pitfall 1: Inline objects/arrays break memo

tsx
// Memo is USELESS here
const MemoChild = memo(({ style }: { style: CSSProperties }) => <div style={style} />);

function Parent() {
  return <MemoChild style={{ color: 'red' }} />; // new object every render!
  // MemoChild re-renders every time because style is always a new reference
}

// Fix: hoist or memoize the object
const style = { color: 'red' }; // outside component, stable
function Parent() {
  return <MemoChild style={style} />;
}

Pitfall 2: Memoization has cost

tsx
// UNNECESSARY: Simple computation, memo overhead > computation cost
const doubled = useMemo(() => count * 2, [count]); // just do: const doubled = count * 2;

// WORTHWHILE: Expensive computation
const sorted = useMemo(() =>
  transactions.sort((a, b) => b.amount - a.amount), // O(n log n)
  [transactions]
);

Pitfall 3: Missing dependencies

tsx
// BUG: stale value — count is captured at memo creation
const increment = useCallback(() => {
  setCount(count + 1); // always uses the count from when callback was created
}, []); // missing count dependency!

// FIX: Use updater function
const increment = useCallback(() => {
  setCount(prev => prev + 1); // always uses latest
}, []); // no dependency needed

Pitfall 4: Children prop breaks memo

tsx
// Memo is useless — children is always a new JSX element
const MemoCard = memo(({ children }: { children: ReactNode }) => <div>{children}</div>);

<MemoCard>
  <p>Hello</p>  {/* new JSX element every render */}
</MemoCard>

5. Stale Closure Problem

What: A function "closes over" a variable's value at the time it was created. If the variable changes later, the function still sees the old value.

tsx
function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // ALWAYS logs 0! Stale closure.
      setCount(count + 1); // ALWAYS sets to 1! (0 + 1)
    }, 1000);
    return () => clearInterval(id);
  }, []); // empty deps → effect created once with count=0, never updated

  return <p>{count}</p>; // shows 1 forever
}

The interval callback captured count = 0 in its closure. Even though count changes in state, the callback still sees 0.

Fix 1: Updater function

tsx
useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => prev + 1); // doesn't depend on closed-over count
  }, 1000);
  return () => clearInterval(id);
}, []);

Fix 2: useRef for latest value

tsx
const countRef = useRef(count);
countRef.current = count; // always up to date

useEffect(() => {
  const id = setInterval(() => {
    console.log(countRef.current); // always current value
  }, 1000);
  return () => clearInterval(id);
}, []);

Fix 3: Include in deps (but careful with intervals)

tsx
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id);
}, [count]); // re-creates interval every time count changes — works but wasteful

6. Race Conditions in UI State

What: When multiple async operations compete and the "wrong" one finishes last, showing stale data.

User types "ab" then quickly types "abc":

Request 1: search("ab")  → takes 500ms → returns results for "ab"
Request 2: search("abc") → takes 200ms → returns results for "abc"

Timeline:
  t=0ms   search("ab") starts
  t=100ms search("abc") starts
  t=300ms search("abc") returns → UI shows "abc" results ✓
  t=500ms search("ab") returns → UI shows "ab" results ✗ ← WRONG! (stale)

Fix 1: AbortController

tsx
useEffect(() => {
  const controller = new AbortController();

  fetch(`/search?q=${query}`, { signal: controller.signal })
    .then(res => res.json())
    .then(data => setResults(data))
    .catch(err => {
      if (err.name !== 'AbortError') throw err;
    });

  return () => controller.abort(); // cancels previous request
}, [query]);

Fix 2: Ignore stale responses (boolean flag)

tsx
useEffect(() => {
  let cancelled = false;

  fetchResults(query).then(data => {
    if (!cancelled) setResults(data); // only update if still relevant
  });

  return () => { cancelled = true; };
}, [query]);

Fix 3: TanStack Query handles this automatically

tsx
const { data } = useQuery({
  queryKey: ['search', query],
  queryFn: () => fetchResults(query),
  // TanStack Query ignores responses from outdated queryKeys
});

7. Finite State Modeling

What: Model your UI states as a finite state machine where only defined transitions are possible. Prevents impossible states.

Without FSM: Boolean soup

tsx
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [data, setData] = useState(null);
// Can isLoading AND isError be true at the same time? Who knows!

With FSM: Discriminated union

tsx
type State =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: Payment[] }
  | { status: 'error'; error: string };

// IMPOSSIBLE to be loading AND have an error simultaneously
// TypeScript enforces this at compile time

With a state machine library (XState)

tsx
const paymentMachine = createMachine({
  initial: 'idle',
  states: {
    idle: { on: { SUBMIT: 'processing' } },
    processing: {
      on: {
        SUCCESS: 'completed',
        FAILURE: 'failed',
      },
    },
    completed: { type: 'final' },
    failed: { on: { RETRY: 'processing' } },
  },
});
// Can't go from 'idle' to 'completed' — must go through 'processing'

8. Event Sourcing in Frontend

What: Instead of storing current state directly, store a LOG of events (actions) that produced the state. Current state is derived by replaying events.

Events log:
1. { type: 'ITEM_ADDED', item: { id: 1, name: 'Widget', price: 100 } }
2. { type: 'ITEM_ADDED', item: { id: 2, name: 'Gadget', price: 200 } }
3. { type: 'ITEM_REMOVED', itemId: 1 }
4. { type: 'DISCOUNT_APPLIED', percent: 10 }

Current state = replay(events) → { items: [Gadget], total: 180 }

Where it shows up in frontend:

  • Redux IS event sourcing (actions = events, reducer = event handler)
  • Undo/redo — replay events up to N-1 for undo, replay all for redo
  • Collaborative editing — each user's changes are events, merged on server
  • Offline sync — queue events while offline, replay on reconnect

Undo/redo example:

tsx
const [history, setHistory] = useState<Action[]>([]);
const [pointer, setPointer] = useState(-1);

function undo() {
  setPointer(p => Math.max(p - 1, -1));
}

function redo() {
  setPointer(p => Math.min(p + 1, history.length - 1));
}

// Current state = replay history[0..pointer]
const currentState = history.slice(0, pointer + 1).reduce(reducer, initialState);

9. Optimistic UI Rollback Strategy

What: Update the UI immediately (before server confirms), then rollback if the server rejects the change.

Without optimistic UI:
Click "Like" → spinner → wait 500ms → server confirms → UI updates
(Feels slow)

With optimistic UI:
Click "Like" → UI shows liked immediately → server confirms in background
If server fails → rollback UI to unliked + show error
(Feels instant)

Implementation pattern:

tsx
const likeMutation = useMutation({
  mutationFn: (postId: string) => api.likePost(postId),

  onMutate: async (postId) => {
    // Cancel any outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['posts'] });

    // Snapshot previous value
    const previousPosts = queryClient.getQueryData(['posts']);

    // Optimistically update
    queryClient.setQueryData(['posts'], (old: Post[]) =>
      old.map(post =>
        post.id === postId ? { ...post, liked: true, likes: post.likes + 1 } : post
      )
    );

    return { previousPosts }; // context for rollback
  },

  onError: (err, postId, context) => {
    // ROLLBACK on failure
    queryClient.setQueryData(['posts'], context?.previousPosts);
    toast.error('Failed to like post');
  },

  onSettled: () => {
    // Refetch to ensure server state
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  },
});

Key considerations:

  • Always save snapshot for rollback
  • Show error feedback to user on failure
  • Invalidate query on settle (success OR error) to sync with server truth
  • Be careful with dependent data (e.g., liking a post might affect a "liked posts" list elsewhere)

10. Offline Conflict Resolution

What: When a user makes changes offline and syncs later, their changes may conflict with changes made by others while they were offline.

Strategies:

Last Write Wins (LWW)

Server has: name = "Alice" (updated at t=100)
User offline changes: name = "Bob" (at t=90)
On sync: server keeps "Alice" (t=100 > t=90)

Simple but loses data. Used when conflicts are rare.

Client Wins / Server Wins

Simple policies — one side always wins. Used in most mobile apps.

Manual Resolution

"Conflict detected: Server has 'Alice', you changed to 'Bob'. Which to keep?"

Best UX for important data. Git merge conflicts are this model.

Operational Transform (OT)

Used by Google Docs. Transforms operations against each other so both can be applied. Complex to implement correctly.

CRDTs (see next section)

Mathematical guarantee of conflict-free merging. Best for collaborative features.


11. CRDT Basics for Collaboration

What: Conflict-free Replicated Data Types. Data structures that can be modified independently on multiple devices and ALWAYS merge correctly without conflicts.

Key property: Merge is commutative, associative, and idempotent. Order of operations doesn't matter. Applying the same operation twice is safe.

Simple example: G-Counter (Grow-only Counter)

Each user has their own counter:
  Alice: 3
  Bob:   5
  Carol: 2

Merge = take max of each user's counter
Total = sum all = 3 + 5 + 2 = 10

Alice increments: Alice: 4
Merge: Alice: max(3,4)=4, Bob: 5, Carol: 2
Total = 11

No conflicts possible — only grows, and max() always converges.

Common CRDT types:

  • G-Counter: Grow-only counter (likes, view counts)
  • PN-Counter: Positive-negative counter (can increment and decrement)
  • LWW-Register: Last-writer-wins for single values
  • OR-Set: Observed-Remove Set (add/remove items, concurrent adds don't conflict)
  • YATA/RGA: For collaborative text editing (used by Yjs)

Frontend libraries:

  • Yjs — most popular, supports text, arrays, maps
  • Automerge — JSON-like CRDT
  • Used in: Figma (custom CRDT), Notion, Linear
tsx
// Yjs example
import * as Y from 'yjs';

const doc = new Y.Doc();
const yText = doc.getText('shared-document');

yText.insert(0, 'Hello ');  // Alice types
// On Bob's machine, this merges automatically without conflicts

12. WebRTC

What: Web Real-Time Communication. Peer-to-peer protocol for audio, video, and arbitrary data directly between browsers, without going through a server.

Use cases: Video calls, screen sharing, P2P file transfer, multiplayer games, collaborative editing.

Connection flow (simplified):

1. Alice creates an "offer" (SDP — session description)
2. Alice sends offer to Bob via signaling server (WebSocket/HTTP)
3. Bob receives offer, creates an "answer" (SDP)
4. Bob sends answer back via signaling server
5. Both exchange ICE candidates (network paths to reach each other)
6. Direct P2P connection established — signaling server no longer needed
tsx
// Simplified WebRTC data channel
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });

// Create data channel
const channel = pc.createDataChannel('chat');
channel.onmessage = (e) => console.log('Received:', e.data);
channel.onopen = () => channel.send('Hello peer!');

// Create and send offer (via your signaling server)
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
sendToSignalingServer({ type: 'offer', sdp: offer });

STUN vs TURN:

  • STUN: Helps discover your public IP (free, lightweight)
  • TURN: Relays traffic when P2P isn't possible (NAT/firewall issues). Costs bandwidth. ~85% of connections work P2P, ~15% need TURN.

Frontend interview preparation reference.