Immer: Typesafe State Updates for NgRx Reducers

Immer is a compact library that simplifies working with immutable data by leveraging efficient cloning techniques and familiar JavaScript syntax. This article explores how Immer operates, its typical use cases, and its application in crafting streamlined, high-performing NgRx reducers.

NgRx remains a go-to state management solution for Angular applications, though it often introduces a fair amount of boilerplate. The challenge is especially pronounced in reducers, which can become unwieldy when handling complex, nested data structures.

Reducers must remain immutable, leading to the widespread use of the spread operator to clone objects with modifications. This operator, similar to Object.assign(), performs a shallow clone that only copies the top-level properties. While common, this approach makes updating nested objects tricky and can introduce subtle bugs.

Immer, at just 3KB, offers a way to clone and update objects with ease:

export const initialState: ExampleState = {
  model2: 'test',
  levelOne: {levelTwo: {nestedOne: {nestedTwo: ['a'], someData: 'b'}, someData: 'c'}, otherData: 'd'}
};

const exampleReducer = createReducer(initialState,
// ? Clone each level of object with spread operator                                    
  on(exampleAction, (state, {value}) => ({
    ...state, levelOne: {
      ...state.levelOne,
      levelTwo: {
        ...state.levelOne.levelTwo,
        nestedOne: {
          ...state.levelOne.levelTwo.nestedOne,
          nestedTwo: [...state.levelOne.levelTwo.nestedOne.nestedTwo, value]
        }
      }
    }
  })),
// ? Nornal JS mutation, Immer ensure the immutability on each level with produce function
  on(exampleAction, (state, {value}) =>
    produce(state, draft => {
      draft.levelOne.levelTwo.nestedOne.nestedTwo.push(value);
    })
  )
);

The snippet above illustrates the core problem Immer solves — cutting down on boilerplate with a straightforward JavaScript API.

One of the key benefits of Immer is that it requires no new syntax to learn; it works with plain JavaScript. This high-level API ensures type safety, reduces code complexity, and boosts overall readability and maintainability.

Furthermore, using the spread operator may lead to a loss of type safety if the reducer's return type isn't explicitly declared. Immer sidesteps this issue by inferring types seamlessly without requiring you to specify them, enabling type inheritance to function correctly out of the box.

In a conventional reducer lacking an explicit return type, TypeScript will fail to flag typos that reference non-existent properties on the state interface.

You can see this behavior in action (try it yourself on StackBlitz):

export interface Feature1State {
  books: Book[];
}

export const initialState: Feature1State = {
  books: []
};

// Return type of the on fn. Contains the action reducer coupled to one or more action types.
const feature1Reducer = createReducer(
  initialState,
  on(getBooksSuccess, (state, { books }) =>
    // ? Produce is Immer's function to manage change in an immutable way
     produce(state, draft => {
      draft.model1 = books; // ERROR TS2551: Property 'model1' does not exist on type 'WritableDraft '.
    })
  ),
  on(getBooksSuccess, (state, { books }) => ({
    ...state,
    model1: books // no error => at runtime will create a model1 inside the Feature1State.
  })),
  on(getBooksSuccess,
    (state, { books }): Feature1State => ({
      ...state,
      model1: books // we got the Error only when specifing the return type on each reducer function
    })
  )
);

Timdeschryver, co-creator of NgRx, has developed an Immer wrapper called NgRx-Immer. It integrates the Immer produce function into NgRx reducers and is a potential candidate for inclusion in the official NgRx ecosystem in the future.

The following sections detail various object cloning techniques, the strategy Immer employs, and its internal workings. If your focus is solely on using Immer with NgRx reducers, feel free to jump ahead to the article's final section.

Understanding Immer's Mechanics

Immer is a small package that enables immutable state updates using standard JavaScript. Instead of modifying the original object, changes are applied to a temporary draft object, which acts as a proxy for the original. This ensures the original remains untouched.

In practice, Immer takes your initial state, generates a draft for you to modify, and then finalizes the next state once your updates are complete:

Typesafe code with Immer and where it can help in NgRx — figure 1

Using Immer is straightforward. Its entire functionality is managed by the produce function, whose signature is:

produce(originalObject, recipe: (draft) => void): clonedObject

In this setup:

  • The recipe is a function that receives a draft object, which is safe to mutate. The draft is a proxy object of the original, and naming it "draft" is a convention signaling that mutation is acceptable here.

The produce function can also be called with a single argument, creating a producer function in the form (originalObject, ...arguments) => mutatedObject. This producer takes the originalObject along with any number of extra arguments. This pattern, known as currying, helps minimize boilerplate in certain scenarios.

Comparing Cloning Methods: Shallow, Deep, and Structural Sharing

Before examining Immer's approach in detail, it's useful to understand the differences between shallow cloning, deep cloning, and structural sharing:

  • Shallow copy: This method clones only the top level of an object, keeping references to nested structures. Consequently, mutating a property that points to a nested object affects the clone. It's typically performed with Object.assign or the spread (...) operator.
  • Deep copy: Achieved through a recursive process that clones every property, including nested objects, creating new references. The cloneDeep function from Lodash is a common example. Deep cloning is computationally expensive and not ideal for general use.
  • Structural sharing: This differs from deep cloning by only cloning the specific parts of the data structure that change, regardless of their depth. When you assign a new value, a copy-on-write process kicks in. The changed property and all its parent objects are shallowly cloned, but unmodified nested objects retain their original references. To preserve the immutability contract, the entire data structure is often frozen to prevent unintended direct mutations.

Immer is built on the principle of structural sharing, using proxies to achieve efficient cloning.

Looking Inside Immer's Implementation

Immer avoids cloning every reference within the root object; it clones only the parts affected by your changes to the draft (a concept known as Structural-Sharing and Copy-on-write). The draft itself is a revocable Proxy of the initial state. Proxies, an ES6 feature, create a stand-in for another object and can intercept or redefine core operations:

const target = {
  message1: "hello",
  message2: "everyone"
};

const traps = {
  get: function(target, prop, receiver) {
     if(Object.prototype.hasOwnProperty.call(target, prop)){
         return "property exist, hello! from proxy";
     }
    return undefined;
  }
};

const {proxy, revoke} = Proxy.revocable(target, traps);
console.log(proxy.message2) // "property exist, hello! from proxy"
console.log(proxy.message3) // undefined
revoke();
console.log(proxy.message2) // Cannot perform 'get' on a proxy that has been revoked 

This snippet demonstrates the basic operation of a proxy object.

The internal decision-making process of Immer is outlined in the following flow-chart:

Typesafe code with Immer and where it can help in NgRx — figure 2

Immer's lifecycle can be summarized in these steps:

  1. Create a root proxy at the start.
  2. Create a proxy for a nested object only when it's first accessed.
  3. Make a shallow clone of the object when a write operation occurs.
  4. Combine all clones and freeze the resulting objects upon completion.

Let's break down each step using code excerpts from the library itself.

When the produce function is called, Immer creates a draft as a proxy. It stores essential information, like the scope and parent proxy state, within a ProxyState object:

export function createProxyProxy<T extends Objectish>(
	base: T,
	parent?: ImmerState
): Drafted<T, ProxyState> {
	const isArray = Array.isArray(base)
	const state: ProxyState = {
		type_: isArray ? ProxyType.ProxyArray : (ProxyType.ProxyObject as any),
		// Track which produce call this is associated with.
		scope_: parent ? parent.scope_ : getCurrentScope()!,
		// True for both shallow and deep changes.
		modified_: false,
		// Used during finalization.
		finalized_: false,
	  	// Track which properties have been assigned (true) or deleted (false).
		assigned_: {},
		// The parent draft state.
		parent_: parent,
		// The base state.
		base_: base,
		// The base proxy.
		draft_: null as any, // set below
		// The base copy with any updated values.
		copy_: null,
		// Called by the `produce` function.
		revoke_: null as any,
		isManual_: false
	}
	
	let target: T = state as any
	let traps: ProxyHandler<object | Array<any>> = objectTraps
	...
	const {revoke, proxy} = Proxy.revocable(target, traps)
	state.draft_ = proxy as any
	state.revoke_ = revoke
	return proxy as any
}

This code, taken from the library, shows the stored information and proxy instantiation.

The proxy's traps intercept all operations on the target. For instance, when you access a property, the "get" trap triggers. If that property holds an object, a new proxy is created, resulting in a tree of proxies.

Here's a summary of some of Immer's core proxy traps:

// summary of the "get" trap
function readProperty(base, prop) {
    const draftState = findOrCreateDraftState(base)
    const value = draftState.modified
        ? draftState.copy[prop]
        : base[prop]
    if (isProxyable(value))
        return getOrCreateProxy(value)
    return value
}

// summary of the "set" trap
function setProperty(base, prop, value) {
    const draftState = findOrCreateDraftState(base)
    if (!draftState.modified) {
        draftState.modified = true
        draftState.copy = {...base}
        markParentsChanged(draftState)
    }
    draftState.copy[prop] = value
}

The code above outlines the logic within the 'get' and 'set' trap functions.

The set trap doesn't directly modify the target. Instead, it creates a shallow copy of the node with the new value and stores it in a scope variable called copy_. It also marks all parent nodes as "modified" to track the changed branch.

The resulting proxy tree looks like this:

Typesafe code with Immer and where it can help in NgRx — figure 3

Once the producer function completes, Immer retraces the proxy tree. It clones the parts that were marked as modified and reuses the references of untouched nodes. The clones are then combined, the final objects are frozen, and all proxies are revoked.

Here's a practical example to clarify Immer's behavior:

Immer uses an internal utility called shallowCopy for cloning. This function uses Object.create(objPrototype, objDescriptor) when dealing with objects. Unlike Object.assign, it copies all own properties, including getters/setters and non-enumerable ones.

For environments without Proxy support, Immer can run in ES5 mode; you must call enableES5() early in your application. This fallback works similarly but doesn't use proxies, making it somewhat slower.

Understanding Auto-Freeze

Immer automatically freezes all state it produces. While beneficial for immutability, this can be excessive for large datasets that will remain unchanged. In such cases, it may be more efficient to pre-freeze your data with the freeze utility (using freeze(data)). Any state created via produce is frozen, so modifying a frozen property will raise an error.

Auto-freeze has been enabled by default since version 8.0. This change ensures consistent behavior in both development and production environments and prevents the performance issues that could arise from disabling it (see this Github issue for further details). If you experience performance slowdowns from frequent mutation of large structures, you can still opt out.

Assessing Immer's Performance

Despite its convenience, Immer remains performant even with large data sets. For example, it takes around 145ms to update 10k objects within a collection of 100k. A carefully optimized hand-written reducer, however, can do the same job in just 30ms — but that's an extreme case involving a very large collection.

Typesafe code with Immer and where it can help in NgRx — figure 4

According to the official Immer documentation:

Although the numbers above may not show it, Immer can sometimes be significantly faster than a hand-crafted reducer. This is because Immer automatically detects "no-op" changes and returns the original state if nothing actually changed. There are known cases where simply applying Immer resolved critical performance bottlenecks.

Introducing NgRx-Immer

NgRx-Immer enables developers to write reducers that are strongly typed, clear, and concise. Since it's fully opt-in, you can choose exactly where to use it. Additionally, its future maintenance within the NgRx ecosystem makes it a dependable choice.

NgRx-immer API for NgRx-Store:

createImmerReducer is a wrapper for the createReducer function. When using it, Immer manages the entire reducer, and you are required to return the modified state.

immerOn is a wrapper around the on function. This allows you to mix on and immerOn within a reducer, and it doesn't require you to return the new state, as Immer handles that for you.

ImmerOn offers superior flexibility and less boilerplate, making it my preferred choice. The following code illustrates these benefits:

// <------ createImmerReducer ------>
const todoReducer = createImmerReducer(
	{ todos: [] },
	on(completeTodo, (state, action) => {
		state.todos[action.index].completed = true;
		return state;
	}),
);

// <------ immerOn ------>
const todoReducer = createReducer(
	{ todos: [] },
	on(newTodo, (state, action) => {
		return {
			...state,
			todos: [...state.todos, action.todo],
		};
	}),
	immerOn(completeTodo, (state, action) => {
		state.todos[action.index].completed = true;
	}),
);

NgRx-immer API for NgRx-Store

NgRx-immer API for NgRx-Component-Store

ImmerComponentStore is a wrapper for the ComponentStore interface. It only wraps the updater and setState methods with Immer and does not wrap patchState.

Take a look at this example:

import { ImmerComponentStore } from 'ngrx-immer/component-store';

@Injectable()
export class MoviesStore extends ImmerComponentStore<MoviesState> {
	constructor() {
		super({ movies: [] });
	}

	readonly addMovie = this.updater((state, movie: Movie) => {
		state.movies.push(movie);
	});
}

NgRx-immer API for NgRx-Component-Store

Final Thoughts: Should You Use Immer?

Immer is a powerful library that I use personally in many projects, even without NgRx. The auto-freeze feature is especially valuable during development and unit testing for uncovering bugs. Keep in mind, however, that with extremely large data sets, Immer can be less efficient than a hand-optimized reducer. For operations that update very large arrays, it's also wise to find the index in the original state rather than in the draft.

I find little reason to avoid Immer. It provides structural sharing, type safety, solid performance, object freezing, and leads to code that is more concise, readable, and easier to maintain.