--- name: react-microfrontend-patterns description: > Use when building a React frontend that dynamically loads independent bundles sharing a single React instance via import maps, needs frecency-based autocomplete, dynamic schema-driven forms, or Zustand state with localStorage. --- # React Micro-Frontend Patterns ## The Pattern **Problem:** You have multiple independently-built UI bundles that must share React at runtime, a form system driven by server-side schemas that change dynamically, and state that needs to persist in the browser and sync across tabs. **Approach:** Import maps with Vite's `rollupOptions.external` for shared React, a useFrecency hook with exponential decay scoring, schema-to-React rendering with action-triggered schema refinement, and Zustand stores with localStorage sync. Pattern proven in production across multiple React frontends and web services. ## Key Design Decisions ### 1. Import map + `rollupOptions.external` for shared React When multiple independently-built bundles run on the same page, each gets its own copy of React. This causes the "dual React instance" bug: hooks break because the React instance that rendered the component is different from the one providing `useState`. The fix: externalize React in every bundle's Vite config and provide it via an import map in the HTML: ```html ``` ```typescript // vite.config.ts export default defineConfig({ build: { rollupOptions: { external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime'], }, }, }) ``` ### 2. The Vite dev-mode problem Vite's dev server ignores browser import maps — it pre-bundles CJS modules and serves them from `/.vite/deps/`. This means dynamically-loaded bundles that use bare `import React from 'react'` will resolve via the import map to a *different* React instance than the host app. **Solutions:** Use `@vitejs/plugin-react` with careful configuration, or write a custom Vite plugin that serves shim modules redirecting bare specifiers to Vite's pre-bundled paths during dev. This is inherently complex — consult your Vite config documentation for the specifics of your setup. ### 3. useFrecency hook: group-by-suffix, exponential decay scoring Frecency (frequency + recency) ranks autocomplete results by how often AND how recently a user selected them: ```typescript // hooks/useFrecency.ts const DECAY_HALF_LIFE_MS = 7 * 24 * 60 * 60 * 1000 // 1-week half-life const STORAGE_KEY = 'frecency-data' const _suggestionCache = new Map() // module-level perf cache interface FrecencyEntry { score: number lastUsed: number // timestamp ms } function computeScore(entry: FrecencyEntry, now: number): number { const ageFraction = (now - entry.lastUsed) / DECAY_HALF_LIFE_MS return entry.score * Math.pow(0.5, ageFraction) } function useFrecency(namespace: string) { const [entries, setEntries] = useState>(() => { const stored = localStorage.getItem(`${STORAGE_KEY}:${namespace}`) return stored ? JSON.parse(stored) : {} }) const getSuggestions = useCallback((fieldKey: string, allValues: string[]) => { const cacheKey = `${namespace}:${fieldKey}` if (_suggestionCache.has(cacheKey)) return _suggestionCache.get(cacheKey)! const now = Date.now() const sorted = [...allValues].sort((a, b) => { const sa = entries[a] ? computeScore(entries[a], now) : 0 const sb = entries[b] ? computeScore(entries[b], now) : 0 return sb - sa }) _suggestionCache.set(cacheKey, sorted) return sorted }, [namespace, entries]) const recordValue = useCallback((fieldKey: string, value: string) => { _suggestionCache.clear() setEntries(prev => { const now = Date.now() const existing = prev[value] ?? { score: 0, lastUsed: now } const updated = { ...prev, [value]: { score: existing.score + 1, lastUsed: now } } localStorage.setItem(`${STORAGE_KEY}:${namespace}`, JSON.stringify(updated)) return updated }) }, [namespace]) const recordAll = useCallback((formData: Record) => { _suggestionCache.clear() setEntries(prev => { const now = Date.now() const updated = { ...prev } for (const value of Object.values(formData)) { if (value) { const existing = updated[value] ?? { score: 0, lastUsed: now } updated[value] = { score: existing.score + 1, lastUsed: now } } } localStorage.setItem(`${STORAGE_KEY}:${namespace}`, JSON.stringify(updated)) return updated }) }, [namespace]) return { getSuggestions, recordValue, recordAll } } ``` The group-by-suffix pattern extracts a group name from the field key using a regex suffix match — not from URL path splits: ```typescript // Group extraction uses /_([a-z]+)$/ on field keys: function getGroupFromFieldKey(fieldKey: string): string | null { const match = fieldKey.match(/_([a-z]+)$/) return match ? match[1] : null // e.g. "repo_name" → "name", "target_branch" → "branch" } ``` > **Note:** The `/_([a-z]+)$/` regex extracts groups from field keys like `workspace_repo` → `repo`. Adapt this regex to your own field naming convention. ### 4. Zustand store with localStorage sync and Map-based collections ```typescript // stores/appStore.ts import { create } from 'zustand' interface AppState { items: Map activeId: string | null events: Map addEvent: (id: string, event: AppEvent) => void setItem: (item: Item) => void } const MAX_EVENTS_PER_ITEM = 1000 // event capping const useAppStore = create((set) => ({ items: new Map(), activeId: null, events: new Map(), setItem: (item) => set((state) => { const next = new Map(state.items) next.set(item.id, item) // Persist to localStorage localStorage.setItem('items', JSON.stringify([...next.entries()])) return { items: next } }), addEvent: (id, event) => set((state) => { const next = new Map(state.events) const existing = next.get(id) || [] // Event capping — keep most recent N events const updated = [...existing, event].slice(-MAX_EVENTS_PER_ITEM) next.set(id, updated) return { events: next } }), })) ``` The localStorage sync pattern on init: ```typescript // Hydrate from localStorage on store creation const stored = localStorage.getItem('items') if (stored) { try { const entries = JSON.parse(stored) as [string, Item][] initialItems = new Map(entries) } catch { // Corrupt localStorage — start fresh } } ``` ## Template / Starter Code ```typescript // Minimal Zustand store with localStorage persistence import { create } from 'zustand' interface AppState { items: Record setItem: (id: string, item: Item) => void } const STORAGE_KEY = 'app-state' function loadPersistedState(): Record { try { const raw = localStorage.getItem(STORAGE_KEY) return raw ? JSON.parse(raw) : {} } catch { return {} } } const useStore = create((set) => ({ items: loadPersistedState(), setItem: (id, item) => set((state) => { const next = { ...state.items, [id]: item } localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) return { items: next } }), })) ``` ## Gotchas & Lessons Learned 1. **The dual-React-instance problem is silent until hooks break.** Everything renders fine — components appear, props flow. But `useState` throws "Invalid hook call" because the component was rendered by React instance A but the hook tries to register with React instance B. The error message doesn't mention "two copies of React" — you have to know to look for it. 2. **Import maps don't work in Vite dev mode.** Vite's dev server pre-bundles dependencies using esbuild, which ignores the browser's import map. A dev shim is required. This is the most common source of "works in production, breaks in dev" issues with shared React. 3. **Event capping prevents memory exhaustion.** Without the `MAX_EVENTS_PER_ITEM` cap, a long-running instance would accumulate thousands of events in the Zustand store. The `.slice(-N)` pattern keeps only the most recent events. Choose N based on what the UI actually renders — if you only show the last 50 events, capping at 1000 is generous. 4. **localStorage has a ~5MB limit per origin.** Large Map serializations can exceed this. The Zustand persistence should either cap the total size or use IndexedDB for larger datasets. A common symptom: the app works until the localStorage quota is exceeded, then silently stops persisting.