6.3 KiB
Extensibility Patterns
How to design plugins, middleware, builders, and framework adapters.
Pipeline Architecture
Vite's hook lifecycle demonstrates the Unix philosophy for builds:
| Hook | Responsibility | Returns |
|---|---|---|
resolveId |
Where is it? | Path or null to defer |
load |
What is it? | Content or null to defer |
transform |
How should it change? | Modified or null to defer |
Why pipelines work:
- Each hook has one responsibility
- Plugins cooperate by returning
nullto defer to next handler - Specialized plugins handle specific cases; general plugins provide fallbacks
The null-propagation pattern: When a handler returns null, the system tries the next handler. This enables composable chains without configuration.
Why this beats event systems:
- Clear ordering (first non-null wins)
- Easy to reason about (check your case, return null otherwise)
- No event listener management
- Type-safe (return type is clear)
Type Accumulation Through Builder Chains
tRPC's pattern for type-safe extensibility:
const protectedProcedure = procedure
.use(isAuthed) // Returns ProcedureBuilder<{ctx: {user: User}}>
.input(z.object({...})) // Returns ProcedureBuilder<{ctx: ..., input: ...}>
.query(...) // Terminates chain with concrete type
Three critical decisions:
1. Root object injection over globals.
// Global interface declaration (avoid)
declare global {
interface AppContext {
user: User;
}
}
// Factory carries type context (prefer)
const t = initTRPC.context<{ user: User }>().create();
2. Each method returns new object. Builder methods return new instances with updated generics—never mutate. This lets TypeScript track accumulated types through the chain.
3. Terminator methods end chains. .query(), .mutation() return concrete types that can't be extended. The chain has a clear end.
Dependencies in Types, Not Runtime
Effect-TS's Effect<Success, Error, Requirements> encodes dependencies in types:
type GetUser = Effect<User, MissingUser | DatabaseError, DatabaseService>;
Why compile-time dependencies:
- Wiring errors caught before code runs
- IDE shows what's missing
- No "service not registered" runtime surprises
- Easy to mock in tests
The tradeoff: More complex types, steeper learning curve. Worth it for infrastructure-heavy code.
Init-Destroy Lifecycle
const myPlugin = () => {
let server; // Closure holds reference
return {
configureServer(_server) {
server = _server;
},
buildEnd() {
/* cleanup using server */
},
};
};
Why explicit lifecycles:
- Video elements need
.pause()and source cleanup - Audio contexts need
.close() - WebRTC connections need graceful shutdown
- Event listeners need removal
The pattern: Acquire resources in init, release in destroy.
Framework-Agnostic Core
| Library | Core | Adapters |
|---|---|---|
| Zustand | Vanilla JS | zustand/react |
| TanStack Query | Query logic | @tanstack/react-query, vue, solid |
| Vite | Bundler | @vitejs/plugin-vue, react, svelte |
Why this pattern works:
- Core logic tested once, not per-framework
- Community adds frameworks without core team
- Bugs fixed in core benefit all adapters
- Smaller adapters are easier to maintain
The test: Can your core run in Node with no framework? If not, you've leaked framework concerns.
Adapter Architecture
Pattern 1: Vanilla Core with Hooks
Used by: Zustand, Nanostores, TanStack Store
@lib/store/
├── core/ ← Runtime-agnostic logic
├── react/ ← React hooks
├── vue/ ← Vue composables
└── solid/ ← Solid primitives
Core exposes minimal interface:
interface Store<T> {
get(): T;
set(value: T | ((prev: T) => T)): void;
subscribe(callback: (value: T) => void): () => void;
}
Adapter responsibilities:
- Subscribe to core store on mount
- Trigger framework re-render on state change
- Clean up subscription on unmount
- Support selectors for partial subscription
Pattern 2: State Machine Core
Used by: Zag.js, XState
- Logic encoded as pure state + transitions (no framework code)
connect()function translates machine state → UI props- Normalizer handles framework event naming (
onClickvson:click)
Pattern 3: Separated State + Behavior
Used by: React Aria + React Stately
| Layer | Scope | Example |
|---|---|---|
| State hooks | Platform-agnostic | useToggleState() — manages boolean |
| Behavior hooks | DOM-specific | useCheckbox() — adds ARIA, keyboard |
| Component | Composes both | <Checkbox /> |
Why this separation:
- State hooks portable to React Native (no DOM)
- Behavior hooks encapsulate web accessibility patterns
- Clear testing boundary
Prop Normalization
Handle framework-specific attribute naming:
// Core returns canonical props
{ onClick: handler, className: 'foo' }
// Normalizer transforms per framework
normalize.vue(props) // → { 'on:click': handler, class: 'foo' }
normalize.solid(props) // → { onClick: handler, class: 'foo' }
SSR / Hydration Safety
Rules:
- No
window,document,navigatorat module scope - Lazy DOM access inside functions
- Provide server snapshot for initial render
- Support
forceMountfor SSR-first render
// Breaks SSR
const width = window.innerWidth;
// Lazy access (safe)
function getWidth() {
return typeof window === 'undefined' ? 0 : window.innerWidth;
}
Testing Adapters
What to test:
- Adapter subscribes on mount
- Adapter unsubscribes on unmount
- State changes trigger re-render
- Selectors prevent unnecessary re-renders
- SSR: No errors during server render
See Also
- Principles — emergent extensibility
- State Patterns — state composition
- TypeScript — builder type accumulation