Skip to content

@stateloom/immer

Immer integration for mutable draft syntax in store set() calls.

Install

bash
pnpm add @stateloom/core @stateloom/store @stateloom/immer immer
bash
npm install @stateloom/core @stateloom/store @stateloom/immer immer
bash
yarn add @stateloom/core @stateloom/store @stateloom/immer immer

Size: ~0.3 KB gzipped (+ core + immer)

Overview

The immer function wraps a state creator so that set() calls accept Immer's mutable draft syntax. Plain Partial<T> objects pass through with zero overhead. The produceState utility provides the same draft syntax for external store.setState() callers.

Quick Start

typescript
import { createStore } from '@stateloom/store';
import { immer } from '@stateloom/immer';

const store = createStore(
  immer((set, get) => ({
    count: 0,
    todos: [] as Array<{ text: string; done: boolean }>,
    increment: () =>
      set((draft) => {
        draft.count++;
      }),
    addTodo: (text: string) =>
      set((draft) => {
        draft.todos.push({ text, done: false });
      }),
    reset: () => set({ count: 0, todos: [] }),
  })),
);

store.getState().increment(); // count: 1
store.getState().addTodo('Buy milk'); // todos: [{ text: 'Buy milk', done: false }]
store.getState().reset(); // back to initial

Guide

Wrapping a Creator

Call immer() around your state creator function. The set function inside the creator now accepts two call styles:

typescript
import { immer } from '@stateloom/immer';

const creator = immer((set, get) => ({
  count: 0,
  // Draft updater — mutate in place
  increment: () =>
    set((draft) => {
      draft.count++;
    }),
  // Plain partial — zero Immer overhead
  reset: () => set({ count: 0 }),
}));

Pass the result directly to createStore:

typescript
import { createStore } from '@stateloom/store';

const store = createStore(creator);

Nested Mutations

Immer shines with deeply nested state. Mutate arrays and objects naturally:

typescript
const store = createStore(
  immer((set) => ({
    users: [] as Array<{ name: string; scores: number[] }>,
    addUser: (name: string) =>
      set((draft) => {
        draft.users.push({ name, scores: [] });
      }),
    addScore: (index: number, score: number) =>
      set((draft) => {
        draft.users[index].scores.push(score);
      }),
    removeUser: (index: number) =>
      set((draft) => {
        draft.users.splice(index, 1);
      }),
  })),
);

Reading State in Actions

The get() function returns the current (non-draft) state:

typescript
const store = createStore(
  immer((set, get) => ({
    count: 0,
    double: () =>
      set((draft) => {
        draft.count = get().count * 2;
      }),
  })),
);

External State Updates with produceState

Use produceState when you need draft syntax outside the creator:

typescript
import { produceState } from '@stateloom/immer';

// From anywhere — component, effect, event handler
produceState(store, (draft) => {
  draft.items.push('new item');
  draft.count++;
});

With Middleware

immer is a creator wrapper, not a middleware. It composes naturally with any middleware:

typescript
import { createStore } from '@stateloom/store';
import { immer } from '@stateloom/immer';

const store = createStore(
  immer((set) => ({
    count: 0,
    increment: () =>
      set((draft) => {
        draft.count++;
      }),
  })),
  {
    middleware: [logger(), devtools({ name: 'MyStore' })],
  },
);

API Reference

immer<T>(creator): StateCreator<T>

typescript
import { immer } from '@stateloom/immer';

Parameters:

  • creator — An ImmerStateCreator<T> function that receives (set, get) where set accepts Partial<T> | ((draft: Draft<T>) => void).

Returns: A standard StateCreator<T> compatible with createStore.

produceState<T>(store, recipe): void

typescript
import { produceState } from '@stateloom/immer';

Parameters:

  • store — Any object with getState() and setState() (structurally typed via StateLike<T>).
  • recipe — A function that receives a mutable Draft<T> and modifies it in place.

ImmerSetStateFn<T>

typescript
type ImmerSetStateFn<T> = (partial: Partial<T> | ((draft: Draft<T>) => void)) => void;

Enhanced set function that accepts either a plain partial or an Immer draft updater.

ImmerStateCreator<T>

typescript
type ImmerStateCreator<T> = (set: ImmerSetStateFn<T>, get: GetStateFn<T>) => T;

Creator function that receives the Immer-enhanced set and standard get.

StateLike<T>

typescript
interface StateLike<T> {
  getState(): T;
  setState(partial: Partial<T> | ((state: T) => Partial<T>)): void;
}

Structural interface for produceState. Any object matching this shape works — no @stateloom/store import required.

Patterns

Todo List with CRUD

typescript
interface Todo {
  id: number;
  text: string;
  done: boolean;
}

interface TodoState {
  todos: Todo[];
  nextId: number;
  add: (text: string) => void;
  toggle: (id: number) => void;
  remove: (id: number) => void;
}

const store = createStore<TodoState>(
  immer((set) => ({
    todos: [],
    nextId: 1,
    add: (text) =>
      set((draft) => {
        draft.todos.push({ id: draft.nextId, text, done: false });
        draft.nextId++;
      }),
    toggle: (id) =>
      set((draft) => {
        const todo = draft.todos.find((t) => t.id === id);
        if (todo) todo.done = !todo.done;
      }),
    remove: (id) =>
      set((draft) => {
        const index = draft.todos.findIndex((t) => t.id === id);
        if (index !== -1) draft.todos.splice(index, 1);
      }),
  })),
);

Mixing Draft and Plain Updates

Use draft syntax for complex mutations, plain partials for simple resets:

typescript
const store = createStore(
  immer((set) => ({
    items: [] as string[],
    filter: 'all' as 'all' | 'active' | 'done',
    // Complex mutation → draft
    addItem: (item: string) =>
      set((draft) => {
        draft.items.push(item);
      }),
    // Simple update → plain partial (zero overhead)
    setFilter: (filter: 'all' | 'active' | 'done') => set({ filter }),
  })),
);

External Batch Updates

Use produceState for batch operations outside the creator:

typescript
import { produceState } from '@stateloom/immer';

function importData(store: StoreApi<AppState>, data: ImportedData) {
  produceState(store, (draft) => {
    draft.users = data.users;
    draft.settings = { ...draft.settings, ...data.settings };
    draft.lastImported = Date.now();
  });
}

How It Works

Creator Wrapper Strategy

Unlike middleware, immer wraps the creator function itself. This is necessary because the store's setState resolves updater functions before the middleware chain — a draft-style (draft) => { draft.count++ } would execute against the real state and corrupt it.

Plain Partial Fast Path

When set receives a plain Partial<T> object (not a function), it bypasses Immer entirely:

Frozen Output Safety

Immer's produce() returns a frozen object, but the store's internal Object.assign({}, state, partial) creates a fresh unfrozen copy. Downstream code can safely work with the resulting state without encountering frozen-object errors.

TypeScript

The immer function infers the state type from the creator's return value:

typescript
// Type is inferred as StoreApi<{ count: number; increment: () => void }>
const store = createStore(
  immer((set) => ({
    count: 0,
    increment: () =>
      set((draft) => {
        draft.count++;
      }),
  })),
);

For explicit typing, pass the generic to immer:

typescript
interface AppState {
  count: number;
  increment: () => void;
}

const store = createStore<AppState>(
  immer<AppState>((set) => ({
    count: 0,
    increment: () =>
      set((draft) => {
        draft.count++;
      }),
  })),
);

When to Use

ScenarioRecommendation
Deeply nested state mutationsUse @stateloom/immer
Array push/splice/sort operationsUse @stateloom/immer
Simple flat state updatesPlain partials are simpler
Performance-critical hot pathsBenchmark — produce has overhead
Already using Immer elsewhereUse @stateloom/immer for consistency