Part 12 made the case for polish as a pass — a deliberate batch of small fixes rather than drive-by patches. This is the second one, and it has more teeth than the first: half of these "small things" were real bugs with real root causes, and four of them share a spine.
TL;DR — The spine: a hook called from three tabs is three hooks. Component-local state in a shared hook gave every screen a private copy of the "active list" (Expenses silently described a different shop than Shopping) and let three tabs each auto-create "My Shopping List" on signup. Fix: UI state in Redux, single-flight guards at module scope. Then Android's turn: a
recycled bitmapcrash traced via logcat to the Fresco image component (fixed by moving to expo-image, with a stable recycling key), bottom sheets that ignored the back button, and a keyboard trap. Plus the leak that mattered most — the persisted query cache carrying the previous user's data into the next account — and eight quieter touches.
(Part 27 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)
The spine: a shared hook that wasn't shared
Two bugs, one cause. The useActiveList hook — which list is the user currently shopping — held its selection in component-local useState. Fine when one screen called it. Then Home, Shopping and Expenses all called it, and every screen had its own private copy.
Bug 1: switching lists on Shopping left Expenses on the old one. Its totals and "needs a price" rows described a different shop entirely. setActiveList updated the caller and persisted to storage; every other instance kept whatever it read at mount and only corrected itself if it happened to remount. No error, no crash — just two tabs quietly disagreeing about reality.
Bug 2: signup created three default lists. The "create the default list once" guard was a per-instance ref. Three tabs mount together on first launch, each with its own ref, each seeing "no lists yet", each creating "My Shopping List."
The fix is the project's own state convention, finally applied: selection is UI state, so it lives in Redux, where every tab reads one value and re-renders together; persistence is hydrated once at launch rather than per hook instance (which also removed a race where a late read could clobber a fresh choice). The default-list guard became a module-scope single-flight flag, reset once lists exist so a later fresh account on the same device still gets one.
The lesson generalises beyond React: a hook is a function, not a singleton. State that must be shared has to live somewhere shared. If two screens can disagree, they will, and the app will feel haunted rather than broken.
Android's turn
The recycled bitmap. From 1 August, Android builds crashed on image-heavy screens with Canvas: trying to use a recycled bitmap. Not reproducible by reloading; the clue came from logcat, which showed a touch event ~100ms before every crash — so this was a draw of a bitmap whose memory had already been freed, during a scroll. The culprit was React Native core's Fresco-backed Image. Every remote image moved to expo-image (Glide-backed) — already a dependency and already a config plugin, so no dev-client rebuild. One subtle detail: in recycling lists, recyclingKey must be a stable key — the S3 object key, not the signed URL, which rotates hourly and would have defeated recycling entirely.
Sheets that swallowed the back button. The bottom-sheet library registers no back handler, so a hardware back press with a sheet open fell through to navigation and left the sheet stranded over a different screen. Fix: a thin wrapper around the sheet component that tracks open state, listens for hardwareBackPress only while open, dismisses, and consumes the event — used everywhere in place of the raw component, so every future sheet inherits it. The wrapper is where Part 22's tablet capping later landed too; one component, N screens.
The keyboard trap — a bottom sheet's android_keyboardInputMode="adjustResize" prop makes the library trust a window resize that never happens under mandatory edge-to-edge, so it skips its own keyboard lift and the keyboard covers the sheet. Omit the prop; the library's own behaviour does the lifting. (Part 20 told the same story for the capture bar; this was the sheet-shaped version.)
The tab-bar inset, done per platform. iOS's translucent tab bar floats over content on a device-dependent home indicator; Android's Material bar does not overlay — the layout already insets. One hook now returns the right clearance per platform (bar + inset + breathing room on iOS, breathing room alone on Android), and every scrolling screen pads with it instead of a hand-tuned number. The hand-tuned numbers had been wrong on exactly half of all devices.
The leak that mattered most
Signing out cleared nothing. The next person to sign in on the same device inherited the previous user's shopping list, currency and receipt settings — and, because the onboarding flag was local, never even saw the first-run step.
The biggest leak wasn't a preference at all: it was the persisted React Query cache from Part 7 — the thing that makes the app work offline holds the previous user's profile, lists, items and receipts, and it survived sign-out on disk. The fix clears per-user storage keys, the query client and its persisted copy, the Redux prefs, and the money formatter's module mirror — keyed on the Cognito sub, so the same user relaunching keeps everything while a different user starts clean. Offline caches are user data; sign-out is a deletion event.
The quieter eight
- A real Settings page — two honest switches (add unmatched receipt items; keep the screen awake while shopping), replacing a placeholder.
- Keep-awake in the aisle — a focus-scoped wake lock on the Shopping stack (list + barcode scanning), and a separate one held by the voice sheet for its whole session, since it also opens from Home.
- Buy-again chips in the review sheet — parsed items you've bought before offer up to three exact past products (name, last unit price, barcode) as chips. Never auto-applied; renaming clears the applied price.
-
Password reveal on sign-in and sign-up — with an implementation note: the icon is two elements, not a ternary on the icon name, because the icon library's babel plugin inlines SVGs at build time and a computed name renders nothing. Plus
autoCompletehints so password managers offer to fill. - Deliberate close on the voice sheet — swipe-down and backdrop taps used to discard a recording or a fully reviewed list; now an explicit ✕ in every phase, content padded clear of the drag handle that swallows touches.
- The honest clear dialog — "Clear completed" now says what happens: this list's spend resets; receipts and history don't. (Part 18 later made that promise structurally true.) Collapsing the Done section from a pinned header also snaps home instead of stranding the viewport in a list that no longer exists.
- Voice in your language — speech recognition follows the device language, with a picker to override.
- The brand tab bar — the native tab bar had shipped in iOS system blue for weeks. Five props later it's teal, in both colour schemes.
What I took away
- A hook is a function, not a singleton. Shared selection lives in shared state; per-instance guards guard nothing.
- logcat timing is evidence. "A touch 100ms before every crash" turned a mystery into a draw-after-free.
- Recycling keys must be stable — a URL that rotates isn't a key.
- Wrap third-party components once so platform fixes (back button, tablet cap) land everywhere by default.
- An offline cache is user data. Sign-out must clear it, keyed on identity so the same user keeps their speed.
- Build-time icon inlining means no computed icon names — two elements, not a ternary.
Next up
Part 28 is a short one: branding every Cognito email — verification, reset, invite — with one customMessage trigger Lambda, and the one line that keeps it out of dependency trouble.
Which of your "small" bugs turned out to be three screens holding three copies of the same state?
Top comments (0)