Topics
v5.0.0
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.

PatternWhen to use
Plugin registryCross-route feature UI registered at bootstrap
Middleware pipelineOrdered cross-cutting behavior on store/event path
Facaded APIPlugins never touch core internals
Module federationTeam-owned remotes with shared React singleton
Per-request initSSR — 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

SystemExtension mechanism
Reduxmiddleware, store enhancers, RTK listeners
Zustandmiddleware wraps set (persist, devtools, immer)
TanStack QueryQueryCache/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 + news

Interview: 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 shell

Cross-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 useShallow

SSR: 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 prod

Best 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 core

Practical 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

Last updated on