Immutability In React With Immer

Discover how Immer simplifies immutable state updates in React. Write cleaner code with the produce function and useImmer hook while maintaining optimal performance.

Introduction: Why Immutability Matters in React

State management is at the heart of every React application, but the requirement for immutable updates often leads to verbose, error-prone code. Immer (German for "always") is a tiny package that transforms how we approach immutable state, allowing you to write mutation-style code while maintaining all the benefits of immutable data structures.

Whether you're working with complex nested objects or simple arrays, Immer simplifies your state management logic without sacrificing performance or introducing unnecessary dependencies.

What You'll Learn

  • How the produce function works under the hood
  • Integrating Immer with useState and useImmer hook
  • TypeScript support and type-safe patterns
  • Performance optimization strategies
  • Real-world patterns for production applications

For developers building custom React applications, mastering Immer leads to cleaner codebases and faster development cycles.

Immer Official Documentation provides comprehensive coverage of these concepts and more.

Understanding Immer Fundamentals

What is Immer and How It Works

Immer is a tiny 3KB gzipped package that revolutionizes how we handle immutable state in JavaScript. At its core, Immer uses proxies to track mutations on draft state, then produces a completely new immutable copy based on those changes.

Key benefits of Immer include:

  • Structural sharing: Unchanged parts of state are shared in memory
  • Auto-freezing: Produced state is frozen to prevent accidental mutations
  • Boilerplate reduction: No more spreading objects at every level
  • Simpler mental model: Write mutation-style code, get immutable results

The produce Function

The produce function is Immer's core API. It takes a current state and a "recipe" function that describes how to transform it:

import { produce } from 'immer'

const baseState = {
 user: {
 name: 'John',
 preferences: {
 theme: 'dark',
 notifications: true
 }
 }
}

const newState = produce(baseState, (draft) => {
 draft.user.name = 'Jane'
 draft.user.preferences.theme = 'light'
})

The original baseState remains unchanged, while newState contains only the modified parts. Unchanged properties like user.preferences.notifications are structurally shared between both states.

This approach pairs well with custom React hooks for building reusable state logic across your application. For more details on the produce function and its capabilities, see the Immer Official Documentation.

Basic produce Function Example
1import { produce } from 'immer'2 3// Before Immer: Verbose nested spreads4const updateUserBefore = (user) => {5 return {6 ...user,7 profile: {8 ...user.profile,9 settings: {10 ...user.profile.settings,11 theme: 'dark'12 }13 }14 }15}16 17// With Immer: Clean and readable18const updateUserWithImmer = (user) => {19 return produce(user, (draft) => {20 draft.profile.settings.theme = 'dark'21 })22}23 24// Arrays work too25const addTodoWithImmer = (todos) => {26 return produce(todos, (draft) => {27 draft.push({ id: Date.now(), text: 'New task', completed: false })28 })29}

React Integration Patterns

Using Immer with useState

The most common pattern is wrapping state updates with produce. This approach works seamlessly with React's existing state management:

import { useState } from 'react'
import { produce } from 'immer'

function UserProfile() {
 const [user, setUser] = useState({
 name: '',
 email: '',
 preferences: { theme: 'light', notifications: true }
 })

 const updateTheme = (theme) => {
 setUser(produce(user, draft => {
 draft.preferences.theme = theme
 }))
 }

 return (
 <div>
 <button onClick={() => updateTheme('dark')}>Dark Theme</button>
 </div>
 )
}

Why this pattern maintains React's immutability guarantees: The produce function always returns a completely new object reference, even though only the modified properties are actually copied. This means React's reference equality check (Object.is) detects the state change correctly, triggering the appropriate re-renders. Meanwhile, Immer's structural sharing ensures that unchanged portions of state remain the same reference in memory, optimizing memory usage. This dual benefit--correct React updates combined with efficient memory handling--makes the produce wrapper an ideal companion to React's state model.

This pattern aligns with our approach to clean code practices in modern React applications, reducing bugs while maintaining performance.

The useImmer Hook

For even cleaner code, the use-immer package provides a useImmer hook that auto-wraps updates with produce:

Installation:

npm i use-immer

Usage:

import { useImmer } from 'use-immer'

function FormExample() {
 const [formData, updateFormData] = useImmer({
 firstName: '',
 lastName: '',
 email: ''
 })

 const handleChange = (field, value) => {
 updateFormData(draft => {
 draft[field] = value
 })
 }

 const handleSubmit = () => {
 console.log(formData) // Ready to send to API
 }

 return (
 <form>
 <input
 value={formData.firstName}
 onChange={(e) => handleChange('firstName', e.target.value)}
 />
 <button type="button" onClick={handleSubmit}>Submit</button>
 </form>
 )
}

The updateFormData function automatically handles the immutability, so you can focus on your business logic. Combining useImmer with state testing strategies ensures your state management remains reliable as your application grows. For more practical examples, see this Introduction to Immer in React guide.

useImmer Hook Example
1import { useImmer } from 'use-immer'2 3// Complex nested state example4function TodoApp() {5 const [todos, setTodos] = useImmer([])6 7 const addTodo = (text) => {8 setTodos(draft => {9 draft.push({10 id: Date.now(),11 text,12 completed: false,13 assignee: null14 })15 })16 }17 18 const toggleTodo = (id) => {19 setTodos(draft => {20 const todo = draft.find(t => t.id === id)21 if (todo) {22 todo.completed = !todo.completed23 }24 })25 }26 27 const removeTodo = (id) => {28 setTodos(draft => {29 const index = draft.findIndex(t => t.id === id)30 if (index !== -1) {31 draft.splice(index, 1)32 }33 })34 }35 36 return { todos, addTodo, toggleTodo, removeTodo }37}

TypeScript and Immer

Immer has excellent TypeScript support with automatic type inference. When you pass a typed state object to produce, the draft parameter inherits the state type with Immer's Draft utility:

import { produce, Draft } from 'immer'

interface UserState {
 id: string
 name: string
 preferences: {
 theme: 'light' | 'dark'
 notifications: boolean
 }
}

const updateUser = (
 state: UserState, 
 newTheme: 'light' | 'dark'
): UserState => {
 return produce(state, (draft: Draft<UserState>) => {
 draft.preferences.theme = newTheme
 // TypeScript knows: draft.id, draft.name, draft.preferences
 })
}

Type-Safe Custom Hooks

Create reusable typed hooks for your application:

import { useImmer, UseImmerState } from 'use-immer'

interface FormState {
 firstName: string
 lastName: string
 email: string
}

export function useFormState(initialValue: FormState): [
 FormState,
 (updater: (draft: FormState) => void) => void
] {
 return useImmer(initialValue)
}

This pattern complements our TypeScript type patterns guide for building type-safe applications. For a detailed comparison of Immer with other immutability libraries including TypeScript considerations, see this Immer vs Immutable.js Comparison.

Immer vs Alternative Approaches

Why Immer Wins for Most React Applications

Immer vs Manual Spread Operators:

AspectManual SpreadImmer
Code clarityVerbose for nested updatesClean, direct mutations
Error-proneEasy to miss propertiesAutomatic handling
PerformanceCreates full copiesStructural sharing
Bundle sizeNo extra dependency+3KB gzipped

Immer vs Immutable.js:

AspectImmutable.jsImmer
Bundle size~15KB gzipped~3KB gzipped
Learning curveSteep APIFamiliar JavaScript patterns
TypeScriptRequires additional setupNative support
ImmutabilityBuilt-in from startProxy-based

While Immutable.js from Facebook offers powerful data structures, Immer provides a more intuitive API that feels like natural JavaScript. For React applications, Immer's smaller footprint and simpler API make it the preferred choice. When building modern JavaScript applications, choosing the right tools for state management significantly impacts developer productivity and code maintainability. The detailed comparison between Immer and Immutable.js highlights these advantages clearly.

Comparison: Spread vs Immer
1// Traditional spread approach - verbose for nested updates2const updateNestedStateTraditional = (state) => {3 return {4 ...state,5 dashboard: {6 ...state.dashboard,7 widgets: {8 ...state.dashboard.widgets,9 header: {10 ...state.dashboard.widgets.header,11 title: 'New Title'12 }13 }14 }15 }16}17 18// Immer approach - clean and maintainable19const updateNestedStateImmer = (state) => {20 return produce(state, (draft) => {21 draft.dashboard.widgets.header.title = 'New Title'22 })23}24 25// Array operations are equally simplified26const addItemToArrayTraditional = (items) => {27 return [...items, { id: Date.now(), name: 'New Item' }]28}29 30const addItemToArrayImmer = (items) => {31 return produce(items, (draft) => {32 draft.push({ id: Date.now(), name: 'New Item' })33 })34}

Best Practices and Performance Tips

When to Use Immer

Immer shines in scenarios with:

  • Deeply nested state objects - Eliminates complex spread chains
  • Frequent state updates - Structural sharing reduces memory pressure
  • TypeScript projects - Excellent type inference
  • Team environments - Cleaner code is easier to maintain

For simple, shallow state updates, the traditional spread operator may be more appropriate.

Performance Optimization

import { useMemo, useCallback } from 'react'
import { produce } from 'immer'

function OptimizedComponent({ data }) {
 // Memoize expensive produce operations
 const processedData = useMemo(() => {
 return produce(data, (draft) => {
 draft.items.sort((a, b) => a.rank - b.rank)
 })
 }, [data])

 // Memoize callback to prevent unnecessary re-renders
 const handleUpdate = useCallback((id, value) => {
 setData(produce(data, (draft) => {
 const item = draft.items.find(i => i.id === id)
 if (item) item.value = value
 }))
 }, [data])

 return <List data={processedData} onUpdate={handleUpdate} />
}

Key optimizations:

  1. Use useMemo for expensive transformations
  2. Memoize callbacks with useCallback
  3. Let Immer handle structural sharing automatically
  4. Enable auto-freeze in development to catch bugs early

These patterns integrate seamlessly with React performance optimization techniques for building high-performance applications. For more on immutability patterns in React, see LogRocket's guide on Immutability in React with Immer.

Integration with Next.js

Immer works seamlessly with Next.js App Router. Since Immer is a client-side library, ensure proper usage in client components:

'use client'

import { useImmer } from 'use-immer'

export default function Dashboard() {
 const [metrics, setMetrics] = useImmer({
 pageViews: 0,
 uniqueVisitors: 0,
 bounceRate: 0
 })

 const trackVisit = () => {
 setMetrics(draft => {
 draft.pageViews += 1
 draft.uniqueVisitors += 1
 })
 }

 return (
 <div onClick={trackVisit}>
 <h1>Dashboard</h1>
 <p>Page Views: {metrics.pageViews}</p>
 </div>
 )
}

SEO and Performance Benefits

Using Immer contributes to better web performance in several ways:

  • Smaller bundle size leaves more room for critical rendering resources
  • Efficient updates mean faster React reconciliation cycles
  • Structural sharing reduces memory churn in long-running applications

For optimal Core Web Vitals, combine Immer with proper code splitting and server-side rendering where appropriate. This approach aligns with our Next.js development services philosophy of building performant web applications.

Real-World Form Implementation
1'use client'2import { useImmer } from 'use-immer'3 4interface FormData {5 firstName: string6 lastName: string7 email: string8 preferences: {9 newsletter: boolean10 notifications: string[]11 }12}13 14export function RegistrationForm() {15 const [formData, updateFormData] = useImmer<FormData>({16 firstName: '',17 lastName: '',18 email: '',19 preferences: {20 newsletter: true,21 notifications: ['email']22 }23 })24 25 const handleSubmit = (e) => {26 e.preventDefault()27 // formData is now ready to send to your API28 console.log('Submitting:', formData)29 30 // Reset form31 updateFormData(draft => {32 draft.firstName = ''33 draft.lastName = ''34 draft.email = ''35 })36 }37 38 const toggleNotification = (type) => {39 updateFormData(draft => {40 const index = draft.preferences.notifications.indexOf(type)41 if (index === -1) {42 draft.preferences.notifications.push(type)43 } else {44 draft.preferences.notifications.splice(index, 1)45 }46 })47 }48 49 return (50 <form onSubmit={handleSubmit}>51 <input52 type="text"53 value={formData.firstName}54 onChange={e => updateFormData(draft => {55 draft.firstName = e.target.value56 })}57 placeholder="First Name"58 />59 <input60 type="text"61 value={formData.lastName}62 onChange={e => updateFormData(draft => {63 draft.lastName = e.target.value64 })}65 placeholder="Last Name"66 />67 <label>68 <input69 type="checkbox"70 checked={formData.preferences.newsletter}71 onChange={e => updateFormData(draft => {72 draft.preferences.newsletter = e.target.checked73 })}74 />75 Subscribe to newsletter76 </label>77 <button type="submit">Register</button>78 </form>79 )80}
Key Takeaways

Master Immer for cleaner React state management

Simplified State Updates

Write mutation-style code with produce() while maintaining immutability guarantees. No more nested spread operators.

useImmer Hook

The useImmer hook provides a cleaner API than useState + produce, reducing boilerplate for complex state shapes.

TypeScript Support

Immer has excellent TypeScript support with automatic type inference and Draft utilities for stronger typing.

Performance Benefits

Structural sharing reduces memory usage, and auto-freezing catches accidental mutations in development.

Frequently Asked Questions

Is Immer worth it for simple state updates?

For simple state with one or two properties, traditional spread operators are fine. Immer shines when you have nested objects or frequent complex updates. The 3KB cost is negligible for most applications.

Does Immer work with Redux?

Yes! Immer is compatible with Redux and simplifies reducer code significantly. Many Redux Toolkit functions use Immer internally for this reason.

How does Immer's performance compare to manual spreading?

Immer's structural sharing means unchanged portions of state are reused, reducing memory pressure. For updates, there's a small proxy overhead, but this is negligible for most use cases.

Can I use Immer with class components?

Yes, Immer works with class components. Use produce(this.state, draft => { ... }) in your setState calls. However, we recommend using functional components with hooks.

Does Immer freeze state in production?

Auto-freezing is enabled by default in development mode only. In production, freezing is disabled for performance. If you need frozen objects in production, use the freeze option explicitly.

Ready to Simplify Your React State Management?

Our team specializes in building modern React applications with clean, maintainable code patterns. Let us help you implement Immer and other best practices in your project.

Sources

  1. Immer Official Documentation - Primary source for Immer API and core concepts
  2. LogRocket - Immutability in React with Immer - Comprehensive guide on React integration patterns
  3. DEV Community - Introduction to Immer in React - Practical code examples and useImmer hook
  4. DhiWise - Immer vs Immutable.js Comparison - Performance benchmarks and library comparison