diff --git a/corpus/frontend/react/index.yaml b/corpus/frontend/react/index.yaml
new file mode 100644
index 0000000..96665ea
--- /dev/null
+++ b/corpus/frontend/react/index.yaml
@@ -0,0 +1,10 @@
+namespace: frontend.react
+patterns:
+ rsc-default:
+ file: rsc-default.yaml
+ zustand-store:
+ file: zustand-store.yaml
+ state-hierarchy:
+ file: state-hierarchy.yaml
+ suspense-boundary:
+ file: suspense-boundary.yaml
diff --git a/corpus/frontend/react/rsc-default.yaml b/corpus/frontend/react/rsc-default.yaml
new file mode 100644
index 0000000..cee7eea
--- /dev/null
+++ b/corpus/frontend/react/rsc-default.yaml
@@ -0,0 +1,28 @@
+name: rsc-default
+category: rendering
+description: Server Components are the default. Use 'use client' only when interactivity is required.
+when: Every new component. Decide server vs client first.
+code: |
+ // ✅ RSC by default - no directive needed
+ export default async function ProductList() {
+ const products = await db.query("SELECT * FROM products");
+ return
{products.map(p => - {p.name}
)}
;
+ }
+
+ // ✅ Client only when needed
+ "use client";
+ import { useState } from "react";
+ export function Counter() {
+ const [count, setCount] = useState(0);
+ return ;
+ }
+antiPattern: |
+ // ❌ 'use client' on a component that just renders data
+ "use client";
+ export default function ProductCard({ product }) {
+ return {product.name}
; // no interactivity - RSC is fine
+ }
+tips:
+ - SEO-critical content → RSC/SSR/SSG/ISR
+ - Non-SEO + interactive → Client Component
+ - Fetching data → always RSC unless client-side only
diff --git a/corpus/frontend/react/state-hierarchy.yaml b/corpus/frontend/react/state-hierarchy.yaml
new file mode 100644
index 0000000..089e079
--- /dev/null
+++ b/corpus/frontend/react/state-hierarchy.yaml
@@ -0,0 +1,27 @@
+name: state-hierarchy
+category: state
+description: "Strict state placement order: URL → server → local → Zustand → Context (injection only)."
+when: Deciding where to put any new piece of state.
+code: |
+ // 1. URL state (searchParams) - shareable, bookmarkable
+ const searchParams = useSearchParams();
+ const sort = searchParams.get("sort") ?? "asc";
+
+ // 2. Server state - React Query / SWR / RSC fetch
+ const { data } = useQuery({ queryKey: ["users"], queryFn: fetchUsers });
+
+ // 3. Local component state
+ const [open, setOpen] = useState(false);
+
+ // 4. Shared client state - Zustand
+ const user = useAuthStore((s) => s.user);
+
+ // 5. Context - injection only (theme, auth, i18n, feature flags)
+ const theme = useContext(ThemeContext);
+antiPattern: |
+ // ❌ Context for frequently changing state
+ const CountContext = createContext(0); // re-renders all consumers on every change
+tips:
+ - "Ask: can this live in the URL? If yes → URL state"
+ - Context is for injection (stable values), not reactive state
+ - Redux is banned - Zustand only for shared client state
diff --git a/corpus/frontend/react/suspense-boundary.yaml b/corpus/frontend/react/suspense-boundary.yaml
new file mode 100644
index 0000000..56e2105
--- /dev/null
+++ b/corpus/frontend/react/suspense-boundary.yaml
@@ -0,0 +1,20 @@
+name: suspense-boundary
+category: rendering
+description: Suspense for async RSC children. Error boundaries for error states.
+when: Any page with async data in RSC children.
+code: |
+ import { Suspense } from "react";
+ import { ErrorBoundary } from "react-error-boundary";
+
+ export default function Page() {
+ return (
+ }>
+ }>
+
+
+
+ );
+ }
+tips:
+ - Skeleton over spinner for layout-stable loading
+ - ErrorBoundary wraps Suspense
diff --git a/corpus/frontend/react/zustand-store.yaml b/corpus/frontend/react/zustand-store.yaml
new file mode 100644
index 0000000..7fd8507
--- /dev/null
+++ b/corpus/frontend/react/zustand-store.yaml
@@ -0,0 +1,33 @@
+name: zustand-store
+category: state
+description: Zustand for shared client state. Slice pattern for large stores.
+when: State shared across multiple client components that isn't URL or server state.
+code: |
+ import { create } from "zustand";
+ import { persist } from "zustand/middleware";
+
+ interface AuthStore {
+ user: User | null;
+ token: string | null;
+ setUser: (user: User, token: string) => void;
+ logout: () => void;
+ }
+
+ export const useAuthStore = create()(
+ persist(
+ (set) => ({
+ user: null,
+ token: null,
+ setUser: (user, token) => set({ user, token }),
+ logout: () => set({ user: null, token: null }),
+ }),
+ { name: "auth-storage" }
+ )
+ );
+
+ // Usage - subscribe only to needed slice (perf)
+ const user = useAuthStore((s) => s.user);
+tips:
+ - Never use Redux
+ - Slice selectors prevent unnecessary re-renders
+ - persist middleware for auth/settings
diff --git a/corpus/index.yaml b/corpus/index.yaml
index 91bffc9..479274f 100644
--- a/corpus/index.yaml
+++ b/corpus/index.yaml
@@ -13,3 +13,5 @@ namespaces:
index: frontend/shadcn/index.yaml
frontend.ui-ux:
index: frontend/ui-ux/index.yaml
+ frontend.react:
+ index: frontend/react/index.yaml
diff --git a/src/plugins/react/tools/get-pattern.ts b/src/plugins/react/tools/get-pattern.ts
index 8975cc9..a953bad 100644
--- a/src/plugins/react/tools/get-pattern.ts
+++ b/src/plugins/react/tools/get-pattern.ts
@@ -1,6 +1,102 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import YAML from "yaml";
import { z } from "zod";
-import { PATTERNS, getPatternByName } from "../data.js";
+import { PATTERNS, getPatternByName, type Pattern } from "../data.js";
+
+type CorpusIndex = {
+ namespaces?: {
+ "frontend.react"?: {
+ index?: string;
+ };
+ };
+};
+
+type CorpusNamespaceIndex = {
+ namespace?: string;
+ patterns?: Record;
+};
+
+type CorpusPatternEntry = {
+ name?: string;
+ category?: string;
+ description?: string;
+ when?: string;
+ code?: string;
+ antiPattern?: string;
+ tips?: string[];
+};
+
+type LoadedCorpusPattern = { pattern: Pattern; source: string };
+
+const moduleDir = dirname(fileURLToPath(import.meta.url));
+const corpusRoot = join(moduleDir, "../../../../corpus");
+const corpusNamespace = "frontend.react";
+
+const cachedCorpusPatterns = new Map();
+
+function normalize(name: string): string {
+ return name.toLowerCase().trim();
+}
+
+function loadCorpusPattern(name: string): LoadedCorpusPattern | null {
+ const key = normalize(name);
+ if (cachedCorpusPatterns.has(key)) {
+ return cachedCorpusPatterns.get(key) ?? null;
+ }
+
+ try {
+ const indexRaw = readFileSync(join(corpusRoot, "index.yaml"), "utf8");
+ const index = YAML.parse(indexRaw) as CorpusIndex | null;
+ const namespaceIndexPath = index?.namespaces?.[corpusNamespace]?.index;
+ if (!namespaceIndexPath) {
+ cachedCorpusPatterns.set(key, null);
+ return null;
+ }
+
+ const namespaceRaw = readFileSync(join(corpusRoot, namespaceIndexPath), "utf8");
+ const namespaceIndex = YAML.parse(namespaceRaw) as CorpusNamespaceIndex | null;
+ const patternPath = namespaceIndex?.patterns?.[key]?.file;
+ if (!patternPath) {
+ cachedCorpusPatterns.set(key, null);
+ return null;
+ }
+
+ const raw = readFileSync(join(corpusRoot, "frontend/react", patternPath), "utf8");
+ const entry = YAML.parse(raw) as CorpusPatternEntry | null;
+ if (
+ !entry ||
+ normalize(entry.name ?? "") !== key ||
+ !entry.category ||
+ !entry.description ||
+ !entry.when ||
+ !entry.code
+ ) {
+ cachedCorpusPatterns.set(key, null);
+ return null;
+ }
+
+ const loaded: LoadedCorpusPattern = {
+ pattern: {
+ name: entry.name ?? name,
+ category: entry.category as Pattern["category"],
+ description: entry.description,
+ when: entry.when,
+ code: entry.code,
+ antiPattern: entry.antiPattern,
+ tips: entry.tips,
+ },
+ source: corpusNamespace,
+ };
+ cachedCorpusPatterns.set(key, loaded);
+ return loaded;
+ } catch {
+ cachedCorpusPatterns.set(key, null);
+ return null;
+ }
+}
export function register(server: McpServer): void {
server.tool(
@@ -10,7 +106,8 @@ export function register(server: McpServer): void {
name: z.string().describe("Pattern name (e.g. 'rsc-default', 'state-hierarchy', 'zustand-store', 'suspense-boundary', 'nextjs-metadata', 'composition-pattern', 'component-template')"),
},
async ({ name }) => {
- const pattern = getPatternByName(name);
+ const corpusEntry = loadCorpusPattern(name);
+ const pattern = corpusEntry?.pattern ?? getPatternByName(name);
if (!pattern) {
const available = PATTERNS.map((p) => p.name).join(", ");
return {
@@ -33,6 +130,10 @@ export function register(server: McpServer): void {
for (const tip of pattern.tips) text += `- ${tip}\n`;
}
+ if (corpusEntry) {
+ text += `\n**Corpus Source:** ${corpusEntry.source}`;
+ }
+
return { content: [{ type: "text", text }] };
}
);
diff --git a/tests/react-pattern-corpus-backed-tools-behaviour.test.ts b/tests/react-pattern-corpus-backed-tools-behaviour.test.ts
new file mode 100644
index 0000000..a5d8554
--- /dev/null
+++ b/tests/react-pattern-corpus-backed-tools-behaviour.test.ts
@@ -0,0 +1,49 @@
+import { expect, test } from "bun:test";
+import { captureTool, extractTextContent } from "./helpers";
+import { register as registerReactGetPattern } from "../src/plugins/react/tools/get-pattern.ts";
+
+const reactGetPattern = captureTool(registerReactGetPattern);
+
+test("react_get_pattern prefers corpus metadata for rsc-default", async () => {
+ const result = await reactGetPattern.invoke({ name: "rsc-default" });
+ const text = extractTextContent(result);
+
+ expect(text).toContain("# rsc-default [rendering]");
+ expect(text).toContain("**Corpus Source:** frontend.react");
+ expect(text).toContain("export default async function ProductList()");
+});
+
+test("react_get_pattern prefers corpus metadata for zustand-store", async () => {
+ const result = await reactGetPattern.invoke({ name: "zustand-store" });
+ const text = extractTextContent(result);
+
+ expect(text).toContain("# zustand-store [state]");
+ expect(text).toContain("**Corpus Source:** frontend.react");
+ expect(text).toContain("create<");
+});
+
+test("react_get_pattern prefers corpus metadata for state-hierarchy", async () => {
+ const result = await reactGetPattern.invoke({ name: "state-hierarchy" });
+ const text = extractTextContent(result);
+
+ expect(text).toContain("# state-hierarchy [state]");
+ expect(text).toContain("**Corpus Source:** frontend.react");
+ expect(text).toContain("useSearchParams()");
+});
+
+test("react_get_pattern prefers corpus metadata for suspense-boundary", async () => {
+ const result = await reactGetPattern.invoke({ name: "suspense-boundary" });
+ const text = extractTextContent(result);
+
+ expect(text).toContain("# suspense-boundary [rendering]");
+ expect(text).toContain("**Corpus Source:** frontend.react");
+ expect(text).toContain("}>");
+});
+
+test("react_get_pattern falls back to in-file data for non-corpus patterns", async () => {
+ const result = await reactGetPattern.invoke({ name: "composition-pattern" });
+ const text = extractTextContent(result);
+
+ expect(text).toContain("# composition-pattern [architecture]");
+ expect(text).not.toContain("**Corpus Source:** frontend.react");
+});