Skip to content

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:

  1. immer() — A higher-order function (HOF) that wraps a state creator to intercept set() calls and route draft updaters through Immer's produce()
  2. 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:

typescript
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:

  1. Gets the current state from the store
  2. Creates an Immer draft from it
  3. Passes the draft to the recipe for mutation
  4. 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:

typescript
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():

typescript
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:

typescript
// 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:

typescript
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:

typescript
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>:

typescript
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):

  1. Creates a proxy-based draft from currentState
  2. Executes recipe(draft) — mutations are trapped by the proxy
  3. Returns a new frozen immutable state reflecting the mutations
  4. 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

ConcernStrategyCost
Plain partial pass-throughtypeof check + direct set() call — no Immer involvementO(1) overhead
Draft updaterSingle produce() call per set()O(n) where n = state size (proxy creation + structural diff)
Structural sharingImmer preserves unchanged subtree referencesMemory proportional to changed subtrees only
Frozen outputStore's Object.assign creates unfrozen copyO(k) where k = number of state keys
Creator wrappingOne-time closure creation during createStoreO(1) — no per-call overhead
Type narrowingtypeof check is a primitive operationO(1)

Cross-References