DEV Community

Cover image for The polish pass: smart add, pull-to-refresh, sticky headers, tighter archive
Ibukun Demehin
Ibukun Demehin

Posted on

The polish pass: smart add, pull-to-refresh, sticky headers, tighter archive

Nothing in this post is bigger than a few dozen lines. There's a 12-line parser, a 22-line hook, a one-prop fix, and a bug whose entire cure was deleting a component. None of them is a feature. Nobody will ever screenshot them for a launch tweet.

But the gap between "works in the demo" and "feels like an app" is made of exactly this — and I shipped it as one deliberate polish pass: three commits, ~850 lines, one afternoon that ran past midnight.

TL;DR — Batch your polish instead of fixing papercuts drive-by: you're already in the files, and small changes gain momentum together. Along the way: typed input gets a regex, not an LLM ("2 milk" → Milk ×2); re-adding an item merges instead of duplicating; "clear completed" is a bulk soft-delete; pull-to-refresh is a reusable React Query hook; stickySectionHeadersEnabled is quietly iOS-only by default; and a keyboard-dismiss wrapper was eating Android's first scroll touch.

(Part 12 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)

Why a "pass" and not drive-by fixes

Every one of these annoyances had been on a list for days. I could have fixed each the moment I noticed it — mid-feature, context-switching every time. Instead they queued up until there was a batch worth doing, and then the batch went fast: the second fix in a file costs a fraction of the first, because you're already in there with the file's model in your head. Polish also compounds perceptually — one fixed papercut is invisible, six at once makes the app suddenly feel finished.

Here's the montage.

"2 milk" → Milk ×2 — a regex, not an LLM

The app's whole party trick is voice input parsed by Claude — rambling speech in, structured items out. So when I added quantity support to the typed capture bar, there was a lazy option sitting right there: send typed text through the same Lambda.

No. Typed input isn't rambling speech — it's a pattern you can write down. The entire parser:

const title = (s: string) => s.replace(/\b\w/g, (c) => c.toUpperCase());

/** "2 milk" → { name: "Milk", quantity: 2 }; anything else → quantity 1. Local, no Claude. */
export function parseManualItem(raw: string): { name: string; quantity: number } {
  const text = raw.trim().replace(/\s+/g, " ");
  const m = text.match(/^(\d{1,2})\s+(.+)$/);
  if (m) {
    const qty = Math.min(Math.max(parseInt(m[1], 10), 1), 99);
    return { name: title(m[2]), quantity: qty };
  }
  return { name: title(text), quantity: 1 };
}
Enter fullscreen mode Exit fullscreen mode

Twelve lines, zero latency, zero cost, works offline. An LLM is for ambiguity; a regex is for a pattern. Reaching for the model here would have added a network round-trip to every keystroke-and-enter — to do a worse job than ^(\d{1,2})\s+(.+)$.

And the add itself got one more brain cell: if an unchecked item with the same name is already on the list, don't spawn a duplicate row — bump its quantity:

const existing = items.find(
  (i) => !i.checked && i.name.trim().toLowerCase() === parsed.name.toLowerCase(),
);
if (existing) {
  updateItem.mutate({
    id: existing.id,
    listId: activeList.id,
    quantity: (existing.quantity ?? 1) + parsed.quantity,
  });
} else {
  addItems.mutate({ listId: activeList.id, items: [parsed] });
}
Enter fullscreen mode Exit fullscreen mode

Type "2 milk" when Milk ×1 is already there and you get Milk ×3, not two milk rows arguing with each other. Note the !i.checked — if you already bought milk this trip, a new "milk" is a new need, not an increment of the done one.

Clear completed — a bulk delete that isn't a delete

A finished shop leaves a graveyard of checked items. One "Clear completed" action now sweeps them — behind a destructive-confirm dialog, because it's a bulk operation on the user's data:

// Bulk "clear completed": soft-delete every checked item at once.
const clearChecked = useMutation({
  mutationFn: async ({ listId, ids }: { listId: string; ids: string[] }) => {
    const userId = await currentUserId();
    const now = new Date().toISOString();
    await Promise.all(
      ids.map((id) =>
        client.models.ShoppingItem.update({
          id, _deleted: true, _deletedAt: now, _deletedBy: userId,
        }),
      ),
    );
    return { listId };
  },
});
Enter fullscreen mode Exit fullscreen mode

It's the house soft-delete convention — flip flags, never .delete() — doing quiet double duty: those "cleared" rows are precisely the purchase history that powers the frequently-bought chips from Part 10. Clearing your list feeds the feature that refills it.

Tap to edit, and an archive that behaves

Two structural touches, no code excerpts needed. Items became tappable: a row now opens an edit sheet with the full field set — name, quantity, unit, category, note — so a mis-parse or a changed mind doesn't mean delete-and-retype.

The edit-item sheet: tap any row to change its name, quantity, unit, category, or note

And archiving got tightened into an actual lifecycle: archived lists leave the switcher for a restorable Archived section (one tap to bring one back), and the active-list picker learned to never settle on an archived list — previously it could quietly select one after a sync, which is exactly the kind of state you only hit on a Tuesday in the store.

Pull-to-refresh, in brand colors

The app is offline-first with a persisted cache, so screens don't refetch on focus — which means users need a manual "give me the latest" gesture. Pull-to-refresh is the muscle-memory answer, and it's two tiny reusable pieces. A hook that refetches whichever React Query keys a screen cares about:

export function usePullRefresh(queryKeys: readonly (readonly unknown[])[]) {
  const qc = useQueryClient();
  const [refreshing, setRefreshing] = useState(false);
  const onRefresh = useCallback(async () => {
    setRefreshing(true);
    try {
      await Promise.all(
        queryKeys.map((key) => qc.refetchQueries({ queryKey: key as unknown[] })),
      );
    } finally {
      setRefreshing(false);
    }
  }, [qc, queryKeys]);
  return { refreshing, onRefresh };
}
Enter fullscreen mode Exit fullscreen mode

…and a BrandRefreshControl that tints the spinner teal in light mode and the brighter dark-mode teal after sunset, so even the loading indicator is on the design system. Forty-five lines total, already used on two screens.

Two Android ghosts

Both of the actual bugs in this pass were Android-shaped.

Sticky section headers are an iOS-only default. My aisle headers ("🥦 PRODUCE") pinned while scrolling on iOS and just… scrolled away on Android. The reason is one of RN's quieter platform splits: SectionList sticks headers by default on iOS only. Opting in is one prop — but the moment a header actually pins, you discover the second half of the bug: a transparent header floats over the items scrolling behind it, text bleeding through text. The fix is giving the header an opaque page-colored band, and moving the section's top spacing from margin (outside the background) to padding (inside it):

 <SectionList
   sections={sections}
+  stickySectionHeadersEnabled
   ...
-  <Text className="mb-1 ml-1.5 mt-3 font-nunito-bold ...">
-    {section.emoji} {section.displayName}
-  </Text>
+  <View className="bg-page pb-1 pt-3 dark:bg-page-dark">
+    <Text className="ml-1.5 font-nunito-bold ...">
+      {section.emoji} {section.displayName}
+    </Text>
+  </View>
Enter fullscreen mode Exit fullscreen mode

Seven lines added, and the list finally scrolls like the ones in apps you pay for:

Mid-scroll on Android: the aisle header pinned at the top on its opaque band while items scroll cleanly behind it

The keyboard-dismiss wrapper was stealing first touches. When the capture bar first landed, the whole screen got wrapped in TouchableWithoutFeedback onPress={Keyboard.dismiss} — the standard trick, because React Native doesn't blur inputs when you tap outside them. On Android it had a side effect I'd been blaming on everything else: with the keyboard up, the first scroll gesture on the list did nothing — swallowed by the tap-catcher deciding whether that touch was a press. The fix was pure deletion: remove the wrapper, dismiss the keyboard at explicit moments (submitting, opening a sheet) instead of ambiently. The best kind of bug fix is negative lines. Every screen-wide convenience wrapper is a gesture-thief suspect until proven innocent.

What I took away

  • Batch polish into a pass. Papercuts queue cheaply and fix cheaply together; the second change in a file is nearly free, and six fixes at once move the "feels finished" needle in a way one never does.
  • Size the tool to the job. Voice gets Claude; "2 milk" gets a 12-line regex. If the input is a pattern, parse the pattern.
  • Merge beats duplicate — but only against unchecked items; done items are history, not state.
  • Never trust a cross-platform default until you've watched Android. Sticky headers, gesture handling, keyboard behavior — the splits are quiet and the docs whisper them.
  • Prefer fixes that delete code. The scroll bug's cure was removing a component, not adding a workaround.

The shopping tab is done — next arc: money

That closes the arc this series has been building since the voice pipeline in Part 5: capture (voice and typed), offline-first sync, aisle ordering, frequently-bought chips, templates, reordering that survived a library deletion, and now the polish. A shopping list I genuinely use in the shop every week.

The next arc is money: what things cost, per-list budgets, a spend breakdown — and pointing the phone's camera at a paper receipt and letting Claude read it. That's already under construction, and it comes with its own war stories.

What's in your polish backlog right now — and what's actually stopping you from burning it down in one pass? Tell me the papercut you've been ignoring longest.

Top comments (0)