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
producefunction works under the hood - Integrating Immer with
useStateanduseImmerhook - 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.
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.
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:
| Aspect | Manual Spread | Immer |
|---|---|---|
| Code clarity | Verbose for nested updates | Clean, direct mutations |
| Error-prone | Easy to miss properties | Automatic handling |
| Performance | Creates full copies | Structural sharing |
| Bundle size | No extra dependency | +3KB gzipped |
Immer vs Immutable.js:
| Aspect | Immutable.js | Immer |
|---|---|---|
| Bundle size | ~15KB gzipped | ~3KB gzipped |
| Learning curve | Steep API | Familiar JavaScript patterns |
| TypeScript | Requires additional setup | Native support |
| Immutability | Built-in from start | Proxy-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.
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:
- Use
useMemofor expensive transformations - Memoize callbacks with
useCallback - Let Immer handle structural sharing automatically
- 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.
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}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.
Sources
- Immer Official Documentation - Primary source for Immer API and core concepts
- LogRocket - Immutability in React with Immer - Comprehensive guide on React integration patterns
- DEV Community - Introduction to Immer in React - Practical code examples and useImmer hook
- DhiWise - Immer vs Immutable.js Comparison - Performance benchmarks and library comparison