Munchable asks you two questions before it can answer anything: which digestive conditions you are eating for, and which allergies you need flagged. The seven conditions are listed on munchable.app/conditions, each with its own guide, for example GERD and reflux. The allergies are the fourteen that labels are legally required to declare.

Both questions are the same screen, twice, in onboarding and again in settings. One component renders all of it, and almost every line of it is about one of two problems: a list long enough to be a scroll instead of a choice, and a choice that disappears.

A list is split only when splitting pays

/** * The list is only split once it is long enough that scanning it costs * something. Below this every item is simply a row, which is what the seven * conditions want; the fourteen allergies get the common set and a search. */ const SPLIT_AT = 9; 

Seven conditions render as seven rows with no search field at all. Fourteen allergies render the seven with the highest adult prevalence in UK and US surveys, a search, and a "Show 7 more" disclosure.

That threshold is the only configuration either screen passes about layout. Everything else about the shape of the list is derived, so adding an eighth condition one day changes the screen without anybody deciding it should.

Search has to match the word you would actually type

The search runs over three fields: the title, the one-line helper, and a hidden keyword list that is never displayed.

keywords: ['heartburn', 'acid', 'reflux', 'gord', 'oesophageal'], keywords: ['fodmap', 'irritable bowel', 'bloating'], keywords: ['bile', 'bile acid', 'diarrhoea', 'diarrhea'], 

People do not search for the name of their condition in the form a product team wrote it. They search for the symptom, for the abbreviation they were given at the clinic, and for whichever of two spellings their country uses. Both spellings of diarrhoea are in there for exactly that reason, and so is the British spelling of the reflux acronym, GORD, next to the American GERD.

Matching folds diacritics as well as case, so an accent typed or not typed makes no difference:

/** Lowercase and strip diacritics, so "coeliac" finds "Coeliac" and "é" finds "e". */ function fold(s: string): string { return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); } 

The item arrays are built once at module scope rather than inline in the screens, because three screens render the same list and the search is a useMemo whose dependency is that array. A fresh literal per render means the memo is decorative.

Nothing you chose is allowed to hide

Here is the bug that drove most of this component. Select something from the bottom of the expanded list, collapse the list, and your selection is now behind a disclosure you have to remember to open. The screen says you picked four things and shows three.

So selected rows get pinned into view:

// Hidden items that are selected get pinned into view, so nothing the user // has chosen is ever behind a disclosure they would have to remember to open. const [pinned, setPinned] = useState<Set<Id>>(() => new Set(selected)); 

Then three subtleties, each of which was a real moment of the thing feeling wrong.

Pinning happens when the list closes, not when the item is tapped, because closing is the moment something could go out of sight. While the list is open nothing is pinned at all: every row is already on screen in its own place, and hoisting the row you just tapped up the page moves the thing under your finger.

Selecting from the collapsed list or from a search result pins immediately, because clearing a search is the other way a just-made choice can vanish.

And deselecting does not unpin. The row stays exactly where it is until the next time the screen is opened. Tapping a checkbox should never make the row you are looking at disappear, even when your tap means "not this one".

While a search is running, anything selected that the query does not reach gets named underneath rather than silently dropped:

{missed.length > 0 ? ( <Text variant="caption" muted>Also selected: {missed.map((i) => i.title).join(', ')}.</Text> ) : null} 

The empty state names the edge of the list

A search that matches nothing is the one place a picker can leave somebody stuck, so it does not say "no results":

export const CONDITIONS_NOT_LISTED = 'Munchable checks seven digestive conditions today. If yours is not here, it cannot be checked yet.'; export const ALLERGIES_NOT_LISTED = 'Munchable checks the 14 allergens that labels must declare. If yours is not here, it cannot be checked yet.'; 

Both are deliberately flat. Searching for a condition Munchable does not cover is a real answer and the user has learned something true about the product in one sentence. The alternative, a cheerful "try another search", wastes the only moment where the honest answer is useful.

The clear button sits in the field the whole time, so the way out of an empty result is one tap and is visible before you need it.

The accessibility details are in the labels, not the look

The visible disclosure says "Show 7 more". Its accessibility label says the noun too:

accessibilityLabel={showRest ? `Show fewer ${noun}` : `Show ${hiddenItems.length} more ${noun}`} accessibilityState={{ expanded: showRest }} 

The noun prop exists for that string alone, which is why the component takes "allergies" and "conditions" as text. "Show 7 more" is clear when you can see what it sits under and meaningless read on its own. The clear button announces "Clear allergies search" for the same reason. Rows are role="checkbox" with their selected state attached, rather than a styled pressable that looks chosen.

The only non-visual feedback is one haptic tick per toggle. The app ships no sound files at all, so a selection confirms itself by feel or not at all.

Open the conditions page and you are looking at the same seven items, the same titles and the same one-line helpers the picker shows, just rendered as pages instead of rows. The list is short enough to read in full, which is the whole reason the picker can afford to tell you when your condition is not on it.