DEV Community

Cover image for Whose categories are these? Making an LLM's throwaway labels feel like real things
Ibukun Demehin
Ibukun Demehin

Posted on

Whose categories are these? Making an LLM's throwaway labels feel like real things

The request sounded trivial: let me reorder the aisles so the list matches how I walk my store — and let me rename "Produce" to "Fruit & Veg" while you're at it. Reorder and rename. Settings-screen stuff.

Small problem: the aisles don't exist. When Claude parses "milk and a dozen eggs" it stamps category: "Dairy" on an item and moves on. There's no Category table, no aisle entity, nothing to reorder — just strings, scattered across items, that happen to repeat. This post is about making things a user can own out of labels an LLM throws away.

TL;DR — Don't promote LLM-generated labels to a database model just to make them editable. Model each aisle client-side as { key, name, emoji }: the key is the immutable string that lives on items (Claude's vocabulary), while name and emoji are editable display. Store the user's ordered list in one a.json() field on their profile. Seed it with the standard aisles, and auto-add any category the LLM invents so nothing is ever un-orderable. Rename becomes a display edit — no item rewrites, no migration.

(Part 9 of Building CannyCart, a voice-first shopping app I'm building in public. Context from Part 5: Claude turns speech into structured items, each with a free-text category. Otherwise self-contained.)

The three options, honestly weighed

Option A — derive the aisles from the items. No storage at all: the aisle list is just the distinct categories currently on the list. It's zero-schema and always accurate, but it's unstable — check off your last Dairy item and the Dairy aisle vanishes, taking its position in your carefully arranged order with it. Add one tomorrow and it's back… at the bottom. You can't hang user preferences off something that flickers in and out of existence.

Option B — hard-code the fixed set. The parsing Lambda's prompt already names the standard grocery aisles (Produce, Bakery, Dairy, Meat, Frozen, Pantry, Drinks, Household), so just make that the editable list. Stable, simple — until Claude does what LLMs do. Say "a new laptop and some cat food" and your items land in Electronics and Pet — categories the fixed list has never heard of, now stuck unsortable at the bottom forever.

Option C — a first-class ShoppingCategory model. The "proper" answer: real rows, real ids, foreign keys from items. And for a per-user list of roughly twelve entries, it's a sandbox deploy, CRUD plumbing, joins on every list render, and a data migration for existing items — to store what is functionally a user preference.

What shipped: a seed, an auto-add, and one JSON field

The decision: B's stability plus A's openness, in a preference. Each aisle is:

export type Aisle = { key: string; name: string; emoji: string };
Enter fullscreen mode Exit fullscreen mode

Three fields, one rule that makes everything downstream free:

  • key never changes. It's the category string exactly as it lives on items — Claude's canonical "Dairy". This is the aisle's identity.
  • name is the editable label. Rename edits this and only this.
  • emoji is the section glyph. Also editable, also display-only.

The user's ordered list lives in a single JSON column on their profile — the whole schema change is one line:

// UserProfile
aisleOrder: a.json(), // ordered [{key,name,emoji}] — per-user aisle display order
Enter fullscreen mode Exit fullscreen mode

Seeded from the same vocabulary the Lambda's prompt uses, so Claude's output maps straight onto an aisle: 🥬 Produce · 🥖 Bakery · 🥛 Dairy · 🥩 Meat · 🧊 Frozen · 🥫 Pantry · 🥤 Drinks · 🧽 Household · 🛒 Other.

And here's why the key/name split earns its keep. When the user renames Produce to "Fruit & Veg", the items still say category: "Produce", Claude keeps emitting "Produce", and the section header renders the name. Nothing else moves — no item rewrite, no re-prompting the LLM, no migration. Identity and display were separated, so editing display costs nothing.

Why emoji, not icons

The glyph choice looks cosmetic but was forced by a build-time constraint. This app's icon library (react-native-iconify v1 — Part 3 explains the version pin) works via a Babel plugin that scans JSX for static literal icon names and inlines each SVG at build time. A per-aisle, user-chosen icon is by definition a variable — the one thing that pipeline can't resolve.

Emoji are just text. Fully dynamic, no registry, colourful by default, and they render identically in a section header and a settings row. An open-ended name→emoji lookup gives auto-added categories a sensible glyph, with a fallback:

const EMOJI_MAP: Record<string, string> = {
  produce: "🥬", bakery: "🥖", dairy: "🥛", meat: "🥩", frozen: "🧊",
  snacks: "🍿", coffee: "", pet: "🐾", electronics: "💻", // … ~40 entries
};

export function defaultEmojiFor(key: string): string {
  return EMOJI_MAP[key.trim().toLowerCase()] ?? "🛒";
}
Enter fullscreen mode Exit fullscreen mode

Auto-add: nothing is ever un-orderable

The open-world half of the design. Whenever an item carries a category that isn't in the user's list yet, it gets appended — name defaulting to the key, emoji from the lookup:

export function withUnlistedAisles(saved: Aisle[], seenKeys: string[]): Aisle[] {
  const have = new Set(saved.map((a) => a.key.toLowerCase()));
  const extra = dedupe(seenKeys)
    .filter((k) => !have.has(k.toLowerCase()))
    .map((k) => ({ key: k, name: k, emoji: defaultEmojiFor(k) }));
  return extra.length ? [...saved, ...extra] : saved;
}
Enter fullscreen mode Exit fullscreen mode

A small effect on the list screen watches for this and saves the merged list. One guard mattered in practice — a session-scoped ref of already-attempted keys, so a failed save doesn't re-trigger the effect in a loop:

// Auto-add any category Claude produced that isn't in the aisle list yet, so
// it becomes reorderable/renamable instead of stuck last. Converges (the saved
// list then includes it); a session ref stops a failed save from re-looping.
useEffect(() => {
  const fresh = seenCategories.filter(
    (k) => !known.has(k) && !attemptedAislesRef.current.has(k),
  );
  if (fresh.length) {
    fresh.forEach((k) => attemptedAislesRef.current.add(k));
    addUnlistedAisles(withUnlistedAisles(aisles, seenCategories));
  }
}, [items, aisles]);
Enter fullscreen mode Exit fullscreen mode

So when Claude invents Electronics, it appears at the end of the aisle editor — 💻, reorderable, renamable — instead of being a second-class string forever.

How it's holding up

  • The editor reuses Part 8's tap-to-reorder menu — Move to top/up/down/bottom, no drag library. The boring pattern got reused within a week of shipping, which is the best endorsement a pattern gets.
  • Saves are optimistic (React Query onMutate/rollback), so reordering feels instant even though it round-trips a profile update.
  • AWSJSON strikes again: the stored JSON can come back single- or double-encoded depending on the layer, so the reader unwraps with the same while (typeof v === "string") v = JSON.parse(v) loop from Part 5 — plus a shape check per entry, because this field is user-adjacent data, not a trusted API response.

The Aisle order editor in the More tab: every category as an emoji + name row, arranged in your walking order

Updating an aisle — rename it and pick its emoji; the items underneath keep the immutable key

When you should promote to a real model

The JSON-preference approach assumes categories are per-user, small, and display-only. Promote to a first-class model when any of those breaks: categories shared across users or lists, per-category metadata that grows (budgets per aisle, store locations), or category-level soft-delete/audit needs. Those are entity problems and deserve entity treatment. A dozen display preferences aren't — and the single JSON field has the virtue that deleting the feature would be one column, not a migration.

Next up

Those categories are about to earn their keep: the next feature mines the user's shopping history for the items they buy most — which runs straight into DynamoDB's most famous missing feature. You can't GROUP BY. What you do instead is Post 10.

Where's your line for "this deserves a real table"? I keep finding that half my would-be models are actually preferences wearing a trench coat.

Top comments (0)