@stateloom/immer
Immer integration for mutable draft syntax in store set() calls.
Install
pnpm add @stateloom/core @stateloom/store @stateloom/immer immernpm install @stateloom/core @stateloom/store @stateloom/immer immeryarn add @stateloom/core @stateloom/store @stateloom/immer immerSize: ~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
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 initialGuide
Wrapping a Creator
Call immer() around your state creator function. The set function inside the creator now accepts two call styles:
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:
import { createStore } from '@stateloom/store';
const store = createStore(creator);Nested Mutations
Immer shines with deeply nested state. Mutate arrays and objects naturally:
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:
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:
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:
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>
import { immer } from '@stateloom/immer';Parameters:
creator— AnImmerStateCreator<T>function that receives(set, get)wheresetacceptsPartial<T> | ((draft: Draft<T>) => void).
Returns: A standard StateCreator<T> compatible with createStore.
produceState<T>(store, recipe): void
import { produceState } from '@stateloom/immer';Parameters:
store— Any object withgetState()andsetState()(structurally typed viaStateLike<T>).recipe— A function that receives a mutableDraft<T>and modifies it in place.
ImmerSetStateFn<T>
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>
type ImmerStateCreator<T> = (set: ImmerSetStateFn<T>, get: GetStateFn<T>) => T;Creator function that receives the Immer-enhanced set and standard get.
StateLike<T>
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
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:
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:
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:
// 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:
interface AppState {
count: number;
increment: () => void;
}
const store = createStore<AppState>(
immer<AppState>((set) => ({
count: 0,
increment: () =>
set((draft) => {
draft.count++;
}),
})),
);When to Use
| Scenario | Recommendation |
|---|---|
| Deeply nested state mutations | Use @stateloom/immer |
| Array push/splice/sort operations | Use @stateloom/immer |
| Simple flat state updates | Plain partials are simpler |
| Performance-critical hot paths | Benchmark — produce has overhead |
| Already using Immer elsewhere | Use @stateloom/immer for consistency |