# Pitfalls Research **Domain:** Adding side-by-side candidate comparison, setup impact preview, and drag-to-reorder ranking to an existing gear management app (GearBox v1.3) **Researched:** 2026-03-16 **Confidence:** HIGH (derived from direct codebase analysis of v1.2 + verified with dnd-kit GitHub issues, TanStack Query docs, and Baymard comparison UX research) --- ## Critical Pitfalls ### Pitfall 1: dnd-kit + React Query Cache Produces Visible Flicker on Drop **What goes wrong:** When ranking candidates via drag-to-reorder, the naive approach is to call `mutate()` in `onDragEnd` and apply an optimistic update with `setQueryData` in the `onMutate` callback. Despite this, the item visibly snaps back to its original position for a split second before settling in the new position. The user sees a "jump" on every successful drop, which makes the ranking feature feel broken even though the data is correct. **Why it happens:** dnd-kit's `SortableContext` derives its order from React state. When the order is stored in React Query's cache rather than local React state, there is a timing mismatch: dnd-kit reads the list order from the cache after the drop animation, but the cache update triggers a React re-render cycle that arrives one or two frames late. The drop animation briefly shows the item at its original position before the re-render reflects the new order. This is a known, documented issue in dnd-kit (GitHub Discussion #1522, Issue #921) that specifically affects React Query integrations. **How to avoid:** Use a `tempItems` local state (`useState(null)`) alongside React Query. On `onDragEnd`, immediately set `tempItems` to the reordered array before calling `mutate()`. Render the candidate list from `tempItems ?? queryData.candidates`. In mutation `onSettled`, set `tempItems` to `null` to hand back control to React Query. This approach: - Prevents the flicker because the component re-renders from synchronous local state immediately - Avoids `useEffect` syncing (which adds extra renders and is error-prone) - Stays consistent with the existing React Query + Zustand pattern in the codebase - Handles drag cancellation cleanly (reset `tempItems` on `onDragCancel`) Do not store the rank column data in the React Query `["threads", threadId]` cache key in a way that requires invalidation and refetch after reorder — this causes a round-trip delay that amplifies the flicker. **Warning signs:** - Item visibly snaps back to original position for ~1 frame on drop - `onDragEnd` calls `mutate()` but uses no local state bridge - `setQueryData` is the only state update on drag end **Phase to address:** Candidate ranking phase — the `tempItems` pattern must be designed before building the drag UI, not retrofitted after noticing the flicker. --- ### Pitfall 2: Rank Storage Using Integer Offsets Requires Bulk Writes **What goes wrong:** The most obvious approach for storing candidate rank order is adding a `sortOrder INTEGER` column to `thread_candidates` and storing 1, 2, 3... When the user drags candidate #2 to position #1, the naive fix is to update all subsequent candidates' `sortOrder` values to maintain contiguous integers. With 5 candidates, this is 5 UPDATE statements per drop. With rapid dragging, this creates a burst of writes where each intermediate position during the drag fires updates. If the app ever has threads with 10+ candidates (not uncommon for a serious gear decision), this becomes visible latency on every drag. **Why it happens:** Integer rank storage feels natural and maps directly to how arrays work. The developer adds `ORDER BY sort_order ASC` to the candidate query and calls it done. The performance problem is only discovered when testing with a realistic number of candidates and a fast drag gesture. **How to avoid:** Use a `sortOrder REAL` column (floating-point) with fractional indexing — when inserting between positions A and B, assign `(A + B) / 2`. This means a drag requires only a single UPDATE for the moved item. Only trigger a full renumber (resetting all candidates to integers 1000, 2000, 3000... or similar spaced values) when the float precision degrades (approximately after 50+ nested insertions, unlikely in practice for this app). Start values at 1000, 5000 increments to give ample room. For GearBox's use case (typically 2-8 candidates per thread), integer storage is workable, but the fractional approach is cleaner and avoids the bulk-write problem entirely. The added complexity is minimal: one line of math in the service layer. Regardless of storage strategy, add an index: `CREATE INDEX ON thread_candidates (thread_id, sort_order)`. **Warning signs:** - `sortOrder` column uses `integer()` type in Drizzle schema - Reorder service function issues multiple UPDATE statements in a loop - No transaction wrapping the bulk update - Each drag event (not just the final drop) triggers a service call **Phase to address:** Schema and service design phase for candidate ranking — the storage strategy must be chosen before building the sort UI, as changing from integer to fractional later requires a migration. --- ### Pitfall 3: Impact Preview Reads Stale Candidate Data **What goes wrong:** The impact preview shows "+450g / +$89" next to each candidate — what this candidate would add to the selected setup. The calculation is: `(candidate.weightGrams - null) + setup.totalWeight`. But the candidate card data comes from the `["threads", threadId]` query cache, while the setup totals come from a separate `["setups", setupId]` query cache. These caches can be out of sync: the user edits a candidate's weight in one tab, invalidating the threads cache, but if the setup was fetched earlier and has not been refetched, the "current setup weight" baseline in the delta is stale. The preview shows a delta calculated against the wrong baseline. The second failure mode: if `candidate.weightGrams` is `null` (not yet entered), displaying `+-- / +$89` is confusing. Users see "null delta" and assume the comparison is broken rather than understanding that the candidate has no weight data. **Why it happens:** Impact preview feels like pure computation — "just subtract two numbers." The developer writes it as a derived value from two props and does not think about cache coherence. The null case is often overlooked because the developer tests with complete candidate data. **How to avoid:** 1. Derive the delta from data already co-located in one cache entry where possible. The thread detail query (`["threads", threadId]`) returns all candidates; the setup query (`["setups", setupId]`) returns items with weight. Compute the delta in the component using both: `delta = candidate.weightGrams - replacedItemWeight` where `replacedItemWeight` is taken from the currently loaded setup data. 2. Use `useQuery` for setup data with the setup selector in the same component that renders the comparison, so both data sources are reactive. 3. Handle null weight explicitly: show "-- (no weight data)" not "--g" for candidates without weights. Make the null state visually distinct from a zero delta. 4. Do NOT make a server-side `/api/threads/:id/impact?setupId=:sid` endpoint that computes delta server-side — this creates a third cache entry to invalidate and adds network latency to what should be a purely client-side calculation. **Warning signs:** - Impact delta shows stale values after editing a candidate's weight - Null weight candidates show a numerical delta (treating null as 0) - Delta calculation is in a server route rather than a client-side derived value - Setup data is fetched via a different hook than the one used for candidate data, with no shared staleness boundary **Phase to address:** Impact preview phase — establish the data flow (client-side derived from two existing queries) before building the UI so the stale-cache problem cannot arise. --- ### Pitfall 4: Side-by-Side Comparison Breaks at Narrow Widths **What goes wrong:** The comparison view is built with a fixed two-or-three-column grid for the candidate cards. On a laptop at 1280px it looks great. On a narrower viewport or when the browser window is partially shrunk, the columns collapse to ~200px each, making the candidate name truncated, the weight/price badges unreadable, and the notes text invisible. The user cannot actually compare the candidates — the view that was supposed to help them decide becomes unusable. GearBox's existing design philosophy is mobile-responsive, but comparison tables are inherently wide. The tension is real: side-by-side requires horizontal space that mobile cannot provide. **Why it happens:** Comparison views are usually mocked at full desktop width. Responsiveness is added as an afterthought, and the "fix" is often to stack columns vertically on mobile — which defeats the entire purpose of side-by-side comparison. **How to avoid:** 1. Build the comparison view as a horizontally scrollable container on mobile (`overflow-x: auto`). Do not collapse to vertical stack — comparing stacked items is cognitively equivalent to switching between detail pages. 2. Limit the number of simultaneously compared candidates to 3 (or at most 4). Comparing 8 candidates side-by-side is unusable regardless of screen size. 3. Use a minimum column width (e.g., `min-width: 200px`) so the container scrolls horizontally before the column content becomes illegible. 4. Sticky first column for candidate names when scrolling horizontally, so the user always knows which column they are reading. 5. Test at 768px viewport width before considering the feature done. **Warning signs:** - Comparison grid uses percentage widths that collapse below 150px - No horizontal scroll on the comparison container - Mobile viewport shows columns stacked vertically - Candidate name or weight badges are truncated without tooltip **Phase to address:** Side-by-side comparison UI phase — responsive behavior must be designed in, not retrofitted. The minimum column width and scroll container decision shapes the entire component structure. --- ### Pitfall 5: Pros/Cons Fields Stored as Free Text in Column, Not Structured **What goes wrong:** Pros and cons per candidate are stored as two free-text columns: `pros TEXT` and `cons TEXT` on `thread_candidates`. The user types a multi-line blob of text into each field. The comparison view renders them as raw text blocks next to each other. Two problems emerge: - Formatting: the comparison view cannot render individual pro/con bullet points because the data is unstructured blobs - Length: one candidate has a 500-word "pros" essay; another has two words. The comparison columns have wildly unequal heights, making the side-by-side comparison visually chaotic and hard to scan The deeper problem: free text in a comparison context produces noise, not signal. Users write "it's really lightweight and packable and the color options are nice" when what the comparison view needs is scannable bullet points. **Why it happens:** Adding two text columns to `thread_candidates` is the simplest possible implementation. The developer tests it with neat, short text and it looks fine. The UX failure is only visible when a real user writes the way real users write. **How to avoid:** 1. Store pros/cons as newline-delimited strings, not markdown or JSON. The UI splits on newlines and renders each line as a bullet. Simple, no parsing, no migration complexity. 2. In the form, use a `