On this page · sections
React System Design
A plugin system exposes stable extension points — registries, middleware, hooks — so consumers add behavior without forking the core. Large-scale React apps layer server islands, client stores, query caches, and extension APIs with explicit ownership boundaries and per-request isolation on the server.
| Pattern | When to use |
|---|---|
| Plugin registry | Cross-route feature UI registered at bootstrap |
| Middleware pipeline | Ordered cross-cutting behavior on store/event path |
| Facaded API | Plugins never touch core internals |
| Module federation | Team-owned remotes with shared React singleton |
| Per-request init | SSR — no global mutable registry leak across requests |
Extension points in React
Definition
Core owns shell layout, registration API, and lifecycle. Plugins own feature UI and cross-cutting behavior.
'use client';
type PluginContext = { emit: (event: string, payload: unknown) => void };
type DashboardPlugin = {
name: string;
setup: (ctx: PluginContext) => void;
Widget?: React.ComponentType;
};
const plugins: DashboardPlugin[] = [];
export function registerDashboardPlugin(plugin: DashboardPlugin) {
plugins.push(plugin);
}
function createPluginContext(): PluginContext {
const listeners = new Map<string, Set<(p: unknown) => void>>();
return {
emit(event, payload) {
listeners.get(event)?.forEach((fn) => fn(payload));
},
};
}
function DashboardShell() {
const ctx = createPluginContext();
plugins.forEach((p) => p.setup(ctx));
return (
<div className="dashboard">
{plugins.map((p) => (p.Widget ? <p.Widget key={p.name} /> : null))}
</div>
);
}Examples: Redux middleware, TanStack Query QueryCache callbacks, TipTap extensions. Plugins register at app bootstrap, not per render.
Interview: Plugin vs render prop — plugin registers once at init; render prop injects UI per instance. Combine when a plugin needs per-table configuration.
Middleware pipelines
Definition
Each plugin receives input and a next function — mutate context, short-circuit, or call next().
'use client';
type Action = { type: string; payload?: unknown };
type Middleware = (action: Action, next: (a: Action) => Action) => Action;
function applyMiddleware(
baseDispatch: (a: Action) => void,
middlewares: Middleware[],
) {
return middlewares.reduceRight(
(dispatch, mw) => (action: Action) => dispatch(mw(action, (a) => a)),
baseDispatch,
);
}
const logger: Middleware = (action, next) => {
console.log('before', action.type);
const result = next(action);
console.log('after', action.type);
return result;
};
const blockDelete: Middleware = (action, next) => {
if (action.type === 'DELETE' && !confirm('Sure?')) {
return { type: 'DELETE_BLOCKED' };
}
return next(action);
};Order is explicit: auth before logging before analytics. Middleware runs on the store/event path, not during render — avoid storing React state inside middleware.
Extensible data table
Real-world example
Plugins receive a TableApi facade — no direct access to internal state.
'use client';
import { useMemo, useState, type ReactNode } from 'react';
type Row = { id: string; name: string; status: string };
type TableApi = {
getRows: () => Row[];
setFilter: (key: string, value: string) => void;
};
type TablePlugin = {
id: string;
onInit?: (api: TableApi) => void;
columns?: Array<{ key: string; header: string; cell: (r: Row) => ReactNode }>;
Toolbar?: React.ComponentType<{ api: TableApi }>;
};
function DataTable({ rows, plugins }: { rows: Row[]; plugins: TablePlugin[] }) {
const [filter, setFilter] = useState<Record<string, string>>({});
const api: TableApi = useMemo(
() => ({
getRows: () =>
rows.filter((r) =>
Object.entries(filter).every(([k, v]) => String(r[k as keyof Row]).includes(v)),
),
setFilter: (key, value) => setFilter((f) => ({ ...f, [key]: value })),
}),
[rows, filter],
);
plugins.forEach((p) => p.onInit?.(api));
const columns = plugins.flatMap((p) => p.columns ?? []);
return (
<div>
{plugins.map((p) => (p.Toolbar ? <p.Toolbar key={p.id} api={api} /> : null))}
<table>
<thead><tr>{columns.map((c) => <th key={c.key}>{c.header}</th>)}</tr></thead>
<tbody>
{api.getRows().map((r) => (
<tr key={r.id}>{columns.map((c) => <td key={c.key}>{c.cell(r)}</td>)}</tr>
))}
</tbody>
</table>
</div>
);
}Cross-plugin communication goes through api or events, not direct imports. Namespace column keys: pluginId.columnKey.
Store-level extension models
Definition
| System | Extension mechanism |
|---|---|
| Redux | middleware, store enhancers, RTK listeners |
| Zustand | middleware wraps set (persist, devtools, immer) |
| TanStack Query | QueryCache/MutationCache callbacks, defaultOptions, meta |
'use client';
import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => console.error('global query error', error),
}),
});
queryClient.setDefaultOptions({
queries: { staleTime: 30_000 },
mutations: { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['posts'] }) },
});None are React component plugins — they are store-level hooks. React Query Devtools is a component; QueryCache callbacks are store-level — name both separately.
Large-scale app layering
Definition
Presentation layer
→ Server Components for reads; client islands for interaction
→ Layout owns chrome slots; pages swap main children
Data layer
→ RSC fetch + cache() for server reads
→ TanStack Query for client cache with shared key factories
→ Zustand for high-frequency client UI (tick streams, filters)
Extension layer
→ Plugin registry at bootstrap
→ Middleware on dispatch/event paths
→ Facaded APIs — no direct core imports
Infrastructure
→ Per-request QueryClient on server
→ Feature-scoped stores, not global god objects
→ ADR required for new global stores// app/layout.tsx — server shell
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<QueryProvider>
<PluginBootstrap plugins={allowedPlugins}>
<AppChrome>{children}</AppChrome>
</PluginBootstrap>
</QueryProvider>
</body>
</html>
);
}Reject → 'use client' in root layout (entire app becomes client)
Reject → global mutable plugin registry on server (cross-request leak)
Reject → API data mirrored in Zustand without invalidation
Reject → one React state tree for tape + positions + newsInterview: Name ownership per layer — who owns remote truth, who owns UI ephemeral, who owns extension contracts.
Module federation at scale
Definition
Host shell → routing, auth, shared providers, plugin registry
Remote teams → independent bundles loaded at runtime
Shared singletons → react, react-dom, @tanstack/react-query (one instance)
Version skew → semver remote entry; host rejects incompatible majors
SSR federation → harder; often client-only remotes with server shellCross-team contracts: typed plugin interface, Zod validation at registration, allowlist in production builds.
Versioning plugin APIs
Adapter pattern
Semver the plugin interface separately from UI visuals. Ship adapters mapping v1 plugins to v2 core.
type TablePluginV1 = { id: string; columns?: unknown[] };
type TablePluginV2 = { id: string; version: 2; getColumns: (api: TableApi) => ColumnDef[] };
function adaptPlugin(plugin: TablePluginV1 | TablePluginV2): TablePluginV2 {
if ('version' in plugin && plugin.version === 2) return plugin;
console.warn(`[table] adapt legacy plugin ${plugin.id}`);
return {
id: plugin.id,
version: 2,
getColumns: () => (plugin.columns ?? []) as ColumnDef[],
};
}Production: runtime validation with Zod at registerPlugin; allowlist plugins in production builds; fixture plugins in CI; two-release deprecation before removing extension points.
Observability and failure modes
Definition
RUM → INP per route; time-to-first-visible-row for virtual lists
Query cache → global onError in QueryCache; stale-while-revalidate indicators
Plugin errors → isolate per plugin; Error Boundary per Widget slot
Chaos → kill WS; verify REST fallback + degraded banner
CI guards → JS-disabled render assertion catches directive creep
→ flag bare useStore() and object selectors without useShallowSSR: single init per request — no global mutable registry leak across requests. Use request-scoped factory or Context registry.
Interview: System design answers name failure modes, ownership boundaries, and upgrade paths — not just happy-path component trees.
Senior Interview Summary
What to say
Plugin systems expose typed extension points with ordered middleware pipelines and facaded APIs — plugins never touch core internals. Large-scale apps layer RSC reads, Query client cache, Zustand for hot UI, and plugin registries at bootstrap. Per-request isolation on server; feature-scoped stores on client. Version the interface separately from visuals; ship adapters for legacy plugins.
System design checklist
Extension points → register at bootstrap with typed contract
Middleware order → auth before logging before analytics
TableApi facade → plugins never import core internals
Data ownership → RSC/Query for remote; Zustand for hot UI
SSR isolation → per-request QueryClient; no global registry leak
Federation → shared React singleton; semver remote entry
Versioning → adapter pattern; Zod at registration; allowlist in prodBest mental model
Core → shell + registration API + lifecycle
Plugin → feature UI registered at bootstrap
Middleware → (input, next) chain on store/event path
Layer → "frequency and ownership determine tool"
Adapter → maps v1 plugin shape to v2 corePractical rule
UI widget extension → TableApi facade + typed plugin interface
Dispatch pipeline → ordered middleware; auth before analytics
Third-party plugins → allowlist + Zod validation + CI fixture plugins
Monorepo → shared query-keys package; ADR for new global stores
Module federation → shared singleton React; semver on remote entry