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.memoand selectors compare by REFERENCE (===)- If
postshas the same reference, components usingpostsDON'T re-render - Without structural sharing, a deep clone gives
postsa 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
// 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:
// 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:
// 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.
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.memouses===to compare propsuseEffectdependency array uses===useMemo/useCallbackdependency array uses===- Zustand selectors use
===to decide re-renders
// 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
// 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
// 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
// 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 neededPitfall 4: Children prop breaks memo
// 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.
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
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
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)
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]); // re-creates interval every time count changes — works but wasteful6. 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
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)
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
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
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
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 timeWith a state machine library (XState)
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:
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:
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
// 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 conflicts12. 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// 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.