Immer Design
Low-level design for @stateloom/immer — the Immer integration for mutable draft syntax in stores. Covers the higher-order function wrapping pattern, draft detection mechanism, produceState standalone function, middleware pipeline integration, and type safety considerations.
Overview
The @stateloom/immer package enables Immer's mutable draft syntax for store updates. Instead of returning partial state objects from set(), developers can mutate a draft object directly. The package provides two entry points:
immer()— A higher-order function (HOF) that wraps a state creator to interceptset()calls and route draft updaters through Immer'sproduce()produceState()— A standalone function for applying Immer mutations to a store from outside the creator
Both functions avoid adding middleware to the pipeline. Instead, they transform the set function signature at the creator level, keeping the integration lightweight and composable with all existing middleware.
Architecture
Higher-Order Function Pattern
The immer() function is a higher-order function that takes an ImmerStateCreator<T> and returns a standard StateCreator<T>. It does not create middleware — it transforms the creator function itself:
The wrapping:
function immer<T extends Record<string, unknown>>(creator: ImmerStateCreator<T>): StateCreator<T> {
return (set: SetStateFn<T>, get: GetStateFn<T>): T => {
const immerSet = (partial: Partial<T> | ((draft: Draft<T>) => void)): void => {
if (typeof partial === 'function') {
const recipe = partial;
set((currentState) => produce(currentState, recipe) as Partial<T>);
} else {
set(partial);
}
};
return creator(immerSet, get);
};
}The outer function (immer) returns a standard StateCreator<T> that createStore accepts directly. Inside, it constructs an immerSet function that intercepts calls and routes them based on type.
Draft Detection Mechanism
The immerSet function distinguishes between plain partial objects and Immer draft updaters using typeof partial === 'function':
Draft updater path: The function is treated as an Immer recipe. It is wrapped in the store's updater function form (currentState) => produce(currentState, recipe), which:
- Gets the current state from the store
- Creates an Immer draft from it
- Passes the draft to the recipe for mutation
- Returns the produced immutable result as
Partial<T>
Plain partial path: Non-function values are passed directly to the store's set() with zero overhead. No Immer involvement.
Integration with Middleware Pipeline
Because immer() wraps the creator (not the middleware chain), the produced state flows through the normal setState path, including all middleware:
Key insight: By the time the resolved partial reaches the middleware chain, it is already a plain Partial<T> object. Middleware never sees Immer drafts or recipes — it receives the same type it would from any other setState call.
produceState Function
The produceState function provides Immer mutations for stores from outside the creator:
function produceState<T>(store: StateLike<T>, recipe: (draft: Draft<T>) => void): void {
store.setState((currentState) => produce(currentState, recipe) as Partial<T>);
}Structural Typing with StateLike<T>
The store parameter is typed as StateLike<T>, a structural interface that matches any object with getState() and setState():
interface StateLike<T> {
getState(): T;
setState(partial: Partial<T> | ((state: T) => Partial<T>)): void;
}This means produceState works with StoreApi<T> from @stateloom/store without importing it, keeping @stateloom/immer in the middleware layer.
Use Case
produceState is useful for event handlers, middleware, or utility functions that need to update a store with complex mutations but are defined outside the creator:
// In an event handler
produceState(todoStore, (draft) => {
draft.items.push({ text: 'New todo', done: false });
draft.lastModified = Date.now();
});The recipe goes through store.setState(), so it flows through the full middleware chain.
Type Safety
ImmerSetStateFn<T> Union Type
The enhanced set function accepts a union of Partial<T> and (draft: Draft<T>) => void:
type ImmerSetStateFn<T> = (partial: Partial<T> | ((draft: Draft<T>) => void)) => void;This differs from the store's standard SetStateFn<T> which accepts Partial<T> | ((state: T) => Partial<T>). The key difference is the return type of the function variant:
- Standard:
(state: T) => Partial<T>— must return a value - Immer:
(draft: Draft<T>) => void— mutates in place, returns nothing
TypeScript enforces this at the call site: inside an immer() creator, set() accepts either form but does not accept the standard updater form (state) => partial.
Partial<T> Widening
Immer's produce() returns a full T (same shape as the input), but the store's setState expects Partial<T>. The cast as Partial<T> widens the full state to match the expected type:
set((currentState) => produce(currentState, recipe) as Partial<T>);This is safe because every T is assignable to Partial<T>. The store's Object.assign({}, state, partial) handles the full-state-as-partial correctly — it replaces all keys.
Generic Constraint
The immer() function constrains T extends Record<string, unknown>:
function immer<T extends Record<string, unknown>>(creator: ImmerStateCreator<T>): StateCreator<T>;This ensures the state type is an object (not a primitive), which is required for both Immer's Draft<T> and the store's Object.assign merge semantics.
Immer Interaction Details
Produce Semantics
Immer's produce(currentState, recipe):
- Creates a proxy-based draft from
currentState - Executes
recipe(draft)— mutations are trapped by the proxy - Returns a new frozen immutable state reflecting the mutations
- Unchanged subtrees share references with the original (structural sharing)
Frozen Output
Immer's produce returns a frozen object (Object.freeze applied deeply). However, the store's internal rawSet creates a new object via Object.assign({}, state, partial). This new object is unfrozen, so downstream code is unaffected by Immer's freezing behavior.
No Return Value in Recipes
Immer recipes must not return a value when modifying the draft. Returning a value from a recipe replaces the entire state, which conflicts with the as Partial<T> cast. The TypeScript type (draft: Draft<T>) => void enforces this constraint at compile time.
Design Decisions
Why a HOF Instead of Middleware
The immer() function wraps the creator rather than implementing the Middleware<T> interface. This is because Immer needs to intercept the set function signature at the creator level, not the onSet hook. The onSet hook receives resolved Partial<T> — by that point, the updater function has already been called and Immer would have no draft to provide. Wrapping the creator allows immer() to provide a modified set that accepts draft recipes before they are resolved.
Why typeof for Detection
Using typeof partial === 'function' is the simplest and most reliable way to distinguish draft updaters from partial objects. Since Partial<T> where T extends Record<string, unknown> is always an object, there is no ambiguity — functions are always draft updaters, objects are always partials.
Why StateLike<T> for produceState
Structural typing via StateLike<T> keeps @stateloom/immer independent of @stateloom/store. Any object matching { getState(): T; setState(...): void } works, making produceState usable with custom store implementations or test doubles.
Why No Runtime Dependency on @stateloom/core
Unlike @stateloom/history (which uses signal from core) or @stateloom/devtools (which uses the inspect system), @stateloom/immer needs no core primitives. It only depends on immer (the npm package) and uses structural typing for the store interface. This makes it the lightest integration in the middleware layer.
Performance Considerations
| Concern | Strategy | Cost |
|---|---|---|
| Plain partial pass-through | typeof check + direct set() call — no Immer involvement | O(1) overhead |
| Draft updater | Single produce() call per set() | O(n) where n = state size (proxy creation + structural diff) |
| Structural sharing | Immer preserves unchanged subtree references | Memory proportional to changed subtrees only |
| Frozen output | Store's Object.assign creates unfrozen copy | O(k) where k = number of state keys |
| Creator wrapping | One-time closure creation during createStore | O(1) — no per-call overhead |
| Type narrowing | typeof check is a primitive operation | O(1) |
Cross-References
- Middleware Overview — where immer fits in the middleware ecosystem
- Architecture Overview — layer structure and dependency rules
- Store Design — how
createStoreinvokes the creator and resolves updater functions - Core Design — signal primitives that underlie the store
- API Reference:
@stateloom/immer— consumer-facing documentation