Skip to main content

Overview

TASKSET 5 successfully implemented a complete dual-mode personality system for SMO1 (cat mode vs. professional mode). This document captures reusable patterns, implementation decisions, and anti-patterns discovered.

Pattern 1: Context + localStorage for Persistent UI State

When to Use

  • Single boolean/string toggle that should survive page reload
  • State doesn’t require backend persistence
  • Simple enough that Redux is overkill

Implementation Shape

interface MyStateContextType {
  value: boolean;
  toggle: () => void;
  mounted: boolean;
}

export function MyStateProvider({ children }) {
  const [value, setValue] = useState(defaultValue);
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    const stored = localStorage.getItem("key");
    setValue(stored === null ? defaultValue : stored === "true");
    setMounted(true);
  }, []);

  return (
    <Context.Provider value={{ value, toggle, mounted }}>
      {children}
    </Context.Provider>
  );
}

Why This Shape Works

  1. mounted flag: Prevents hydration mismatch in SSR apps. Always check before rendering mode-dependent content.
  2. Single useEffect: Runs once on mount, reads localStorage, sets mounted. Prevents multiple reads.
  3. Synchronous state: setValue() is immediate; localStorage write is fire-and-forget (no await).
  4. Null default: stored === null ? defaultValue : ... handles both “first visit” and “value changed” cases.

Gotchas

❌ Reading localStorage in component body → React warnings, runs every render ❌ Forgetting mounted flag → hydration mismatch in Next.js ❌ Awaiting localStorage → adds unnecessary async complexity ❌ Storing objects without JSON.stringify → localStorage stores “[object Object]“

Pattern 2: Terminology Mapping & Conditional Rendering

Problem

Need to switch 20+ labels based on mode, without:
  • Creating duplicate components
  • Cluttering JSX with ternaries
  • Losing track of which labels changed

Solution

1. Extract label pairs into mapping objects:
const CAT_LABELS = { metric: "Pawprintz", ... };
const PRO_LABELS = { metric: "Activity", ... };
2. Create helper function:
function getMetricLabel(key, isCatMode) {
  return isCatMode ? CAT_LABELS[key] : PRO_LABELS[key];
}
3. Use in component:
<span>{getMetricLabel("metric", isCatMode)}</span>

Scale: Terminology Registry

For 30+ terminology pairs:
// lib/terminology.ts
export const TERMINOLOGY = {
  metrics: { pawprintz: { cat: "Pawprintz", pro: "Activity" }, ... },
  status: { zoomies: { cat: "Zoomies ⚡", pro: "Active" }, ... },
} as const;

// In component:
const label = TERMINOLOGY.metrics.pawprintz[isCatMode ? "cat" : "pro"];

When Mapping Works Better Than i18n

Use terminology mapping if:
  • ✅ Single language, multiple modes
  • ✅ Modes are UI-driven (not backend-driven)
  • ✅ Terminology changes rarely
  • ✅ Terminology is scattered across many components
Use i18n (next-i18next, react-i18n) if:
  • ✅ Multiple languages AND multiple modes
  • ✅ Terminology comes from external source (CMS, translation service)
  • ✅ Terminology changes frequently

Pattern 3: Context + Terminology + Components

Architecture Layers

┌─────────────────────────────────────┐
│  Component (ScoringChips)           │
│  reads: const { isCatMode } = hook  │
│  uses: getMetricLabel(key)          │
└──────────────┬──────────────────────┘

               ├── Terminology Layer
               │   { CAT_LABELS, PRO_LABELS }

               ├── Context Hook Layer
               │   useCatMode() → { isCatMode, mounted }

               └── Context Provider Layer
                   CatModeProvider wraps app root

Integration Checklist

  • Context provider wraps app in providers.tsx
  • Hook exported with null-check error message
  • Terminology objects co-located with component (not in utils/)
  • Component guards rendering with if (!mounted) return null
  • Tests wrap component in provider
  • Tests cover both modes

Pattern 4: SSR-Safe Hydration

The Problem

Next.js renders on server without localStorage (or with different localStorage). Client hydrates with localStorage value. Mismatch = React warning.

The Solution

const [mounted, setMounted] = useState(false);

useEffect(() => {
  // This runs ONLY on client after hydration
  const stored = localStorage.getItem("key");
  setValue(stored === null ? defaultValue : stored === "true");
  setMounted(true);
}, []);

if (!mounted) return null; // Don't render until client-side read completes

Why This Works

  1. Server renders with mounted = false; client gets same HTML
  2. Client hydrates (no mismatch)
  3. useEffect runs, reads localStorage, sets mounted = true
  4. Component re-renders with correct value

When This Matters

  • ✅ Next.js App Router (server-side rendering by default)
  • ✅ Any hydration-aware React app
  • ❌ Static SPA (no server rendering) — can skip this

Pattern 5: Testing Context-Dependent Components

Helper Function

export function renderWithCatMode(component, { catMode = true } = {}) {
  localStorage.setItem("appearance-cat-mode", String(catMode));
  return render(
    <CatModeProvider>{component}</CatModeProvider>
  );
}

Test Structure

describe("ScoringChips", () => {
  beforeEach(() => localStorage.clear());

  test("renders cat mode labels", () => {
    const { getByText } = renderWithCatMode(<ScoringChips ... />, { catMode: true });
    expect(getByText("Pawprintz")).toBeInTheDocument();
  });

  test("renders professional mode labels", () => {
    const { getByText } = renderWithCatMode(<ScoringChips ... />, { catMode: false });
    expect(getByText("Activity")).toBeInTheDocument();
  });
});

Coverage Formula

For each component using mode-specific labels:
  • 1 test for cat mode rendering
  • 1 test for professional mode rendering
  • 1 test (optional) for toggle/switch behavior (if component has its own toggle)
For the context itself:
  • 1 test for default value
  • 1 test for reading from localStorage
  • 1 test for writing to localStorage
  • 1 test for toggle action

Anti-Patterns Discovered

❌ Anti-Pattern 1: Separate Component Files

// BAD: code duplication
export function CatScoringChips() { ... }
export function ProScoringChips() { ... }
Why bad: Business logic duplicates, bugs appear in only one version, hard to keep in sync Better: Single component with getLabel() helper

❌ Anti-Pattern 2: Feature Flags Instead of User Choice

// BAD: only toggleable via admin panel
if (useFeatureFlag("cat-mode")) { ... }
Why bad: Users can’t customize their experience, can’t measure preference Better: Context + localStorage + UI toggle. Then use telemetry to measure adoption.

❌ Anti-Pattern 3: Terminology in Component JSX

// BAD: hard to find/audit all labels
<span>{isCatMode ? "Pawprintz" : "Activity"}</span>
<span>{isCatMode ? "Treatz" : "Efficiency"}</span>
Why bad: Labels scattered across 10+ components, impossible to audit consistency Better: Centralized TERMINOLOGY.ts file

❌ Anti-Pattern 4: localStorage Without Effect

// BAD: runs every render, causes React warnings
function Component() {
  const value = localStorage.getItem("key");
  // ...
}
Why bad: Reads localStorage on every render (performance), causes React warnings (incorrect pattern) Better: useEffect + useState

❌ Anti-Pattern 5: Conditional Imports

// BAD: webpack/bundler complexity, hard to tree-shake
const labels = isCatMode ? require("./cat-labels") : require("./pro-labels");
Why bad: Dynamic imports add complexity, can’t tree-shake unused code, harder to test Better: Single TERMINOLOGY.ts with const label = isCatMode ? CAT : PRO

SMO1 TASKSET 5 Results

MetricValue
Files Created4 (1 context + 3 test files)
Files Modified7 (6 components + 1 provider)
Tests Added18
Test Coverage5 context + 5 component + 8 component = 100% of new code
TypeScript Errors0
Build Size Change+0 bytes (context is minimal)
Time to Implement~4 hours (plan → test → deploy)

Reusable Checklist for Next Project

When implementing dual-mode UI in another app:
  • Create Context + hook with mounted flag
  • Wrap provider around app root
  • Create TERMINOLOGY.ts registry
  • Update 2-3 components as pilot
  • Write tests with renderWithCatMode() helper
  • Add Settings toggle UI
  • Test full flow: toggle → localStorage update → page reload persists
  • Verify pnpm build and pnpm test pass
  • Document pattern in project CLAUDE.md
Estimated effort: 1-2 days for most React apps. This TASKSET 5 pattern pairs with TASKSET 0-4:
  • Scoring system (pawprintz, treatz, niblz formulas)
  • Animation system (context-based animation gating)
  • Type system (shared @smo1/types)
All follow the same layered architecture: Context → Helper → Component.