Why I Switched from Redux to Zustand (And You Should Too)

11 min read2142 words

Three months ago, I made a decision that initially scared me: migrating our production React application from Redux to Zustand. We had over 50 Redux slices, 200+ action creators, and thousands of lines of boilerplate code. Today, that same functionality lives in just 15 Zustand stores with 70% less code.

The migration took two weeks, improved our bundle size by 37KB, and reduced our average component re-render time from 95ms to 40ms. But the real win? Our team actually enjoys working with state management again.

The Breaking Point with Redux

Let me show you the exact moment I decided to explore alternatives. We needed to add a simple feature: tracking which notifications a user had read. Here's what it took in Redux:

// types/notifications.ts
export const MARK_NOTIFICATION_READ = 'notifications/MARK_READ';
export const MARK_ALL_READ = 'notifications/MARK_ALL_READ';
export const FETCH_NOTIFICATIONS_START = 'notifications/FETCH_START';
export const FETCH_NOTIFICATIONS_SUCCESS = 'notifications/FETCH_SUCCESS';
export const FETCH_NOTIFICATIONS_ERROR = 'notifications/FETCH_ERROR';
 
interface MarkNotificationReadAction {
  type: typeof MARK_NOTIFICATION_READ;
  payload: string;
}
 
interface MarkAllReadAction {
  type: typeof MARK_ALL_READ;
}
 
// ... more type definitions
 
// actions/notifications.ts
export const markNotificationRead = (id: string): MarkNotificationReadAction => ({
  type: MARK_NOTIFICATION_READ,
  payload: id
});
 
export const markAllRead = (): MarkAllReadAction => ({
  type: MARK_ALL_READ
});
 
export const fetchNotifications = () => {
  return async (dispatch: Dispatch) => {
    dispatch({ type: FETCH_NOTIFICATIONS_START });
    try {
      const response = await api.getNotifications();
      dispatch({ 
        type: FETCH_NOTIFICATIONS_SUCCESS, 
        payload: response.data 
      });
    } catch (error) {
      dispatch({ 
        type: FETCH_NOTIFICATIONS_ERROR, 
        payload: error.message 
      });
    }
  };
};
 
// reducers/notifications.ts
const initialState = {
  items: [],
  loading: false,
  error: null,
  unreadCount: 0
};
 
export function notificationsReducer(
  state = initialState,
  action: NotificationActionTypes
) {
  switch (action.type) {
    case FETCH_NOTIFICATIONS_START:
      return { ...state, loading: true, error: null };
    
    case FETCH_NOTIFICATIONS_SUCCESS:
      return {
        ...state,
        loading: false,
        items: action.payload,
        unreadCount: action.payload.filter(n => !n.read).length
      };
    
    case FETCH_NOTIFICATIONS_ERROR:
      return { ...state, loading: false, error: action.payload };
    
    case MARK_NOTIFICATION_READ:
      return {
        ...state,
        items: state.items.map(item =>
          item.id === action.payload ? { ...item, read: true } : item
        ),
        unreadCount: state.unreadCount - 1
      };
    
    case MARK_ALL_READ:
      return {
        ...state,
        items: state.items.map(item => ({ ...item, read: true })),
        unreadCount: 0
      };
    
    default:
      return state;
  }
}
 
// selectors/notifications.ts
export const selectNotifications = (state: RootState) => 
  state.notifications.items;
 
export const selectUnreadCount = (state: RootState) => 
  state.notifications.unreadCount;
 
export const selectNotificationById = (id: string) => 
  (state: RootState) => state.notifications.items.find(n => n.id === id);
 
// In component
import { useDispatch, useSelector } from 'react-redux';
import { markNotificationRead, fetchNotifications } from './actions';
import { selectNotifications, selectUnreadCount } from './selectors';
 
function NotificationList() {
  const dispatch = useDispatch();
  const notifications = useSelector(selectNotifications);
  const unreadCount = useSelector(selectUnreadCount);
  
  useEffect(() => {
    dispatch(fetchNotifications());
  }, [dispatch]);
  
  const handleMarkRead = (id: string) => {
    dispatch(markNotificationRead(id));
  };
  
  // ... render logic
}

That's 150+ lines of code across 5 files for basic CRUD operations. Now here's the exact same functionality in Zustand:

// stores/notifications.ts
import { create } from 'zustand';
import { api } from '@/lib/api';
 
interface NotificationStore {
  items: Notification[];
  loading: boolean;
  error: string | null;
  unreadCount: number;
  fetchNotifications: () => Promise<void>;
  markRead: (id: string) => void;
  markAllRead: () => void;
}
 
export const useNotificationStore = create<NotificationStore>((set, get) => ({
  items: [],
  loading: false,
  error: null,
  unreadCount: 0,
  
  fetchNotifications: async () => {
    set({ loading: true, error: null });
    try {
      const response = await api.getNotifications();
      set({ 
        items: response.data,
        unreadCount: response.data.filter(n => !n.read).length,
        loading: false
      });
    } catch (error) {
      set({ error: error.message, loading: false });
    }
  },
  
  markRead: (id) => {
    set(state => ({
      items: state.items.map(item =>
        item.id === id ? { ...item, read: true } : item
      ),
      unreadCount: Math.max(0, state.unreadCount - 1)
    }));
  },
  
  markAllRead: () => {
    set(state => ({
      items: state.items.map(item => ({ ...item, read: true })),
      unreadCount: 0
    }));
  }
}));
 
// In component
function NotificationList() {
  const { items, unreadCount, fetchNotifications, markRead } = 
    useNotificationStore();
  
  useEffect(() => {
    fetchNotifications();
  }, []);
  
  // ... render logic
}

35 lines. One file. Same functionality. This pattern repeated across our entire codebase.

Real Performance Metrics from Production

I tracked detailed metrics before and after the migration. Here's what we measured across 10,000 user sessions:

Bundle Size Impact

// Bundle analysis results
const bundleComparison = {
  before: {
    'redux': 11.2, // KB
    'react-redux': 7.1,
    'redux-thunk': 2.4,
    'redux-toolkit': 40.1,
    'reselect': 3.8,
    total: 64.6
  },
  after: {
    'zustand': 3.1,
    total: 3.1
  },
  savings: 61.5 // KB (95% reduction)
};

Re-render Performance

I built a performance monitoring hook to measure actual render times:

// hooks/useRenderMetrics.ts
export function useRenderMetrics(componentName: string) {
  const renderStart = performance.now();
  const renderCount = useRef(0);
  const renderTimes = useRef<number[]>([]);
  
  useEffect(() => {
    const renderTime = performance.now() - renderStart;
    renderCount.current++;
    renderTimes.current.push(renderTime);
    
    if (renderCount.current % 100 === 0) {
      const avg = renderTimes.current.reduce((a, b) => a + b, 0) / 
                  renderTimes.current.length;
      
      console.log(`[${componentName}] Average render time: ${avg.toFixed(2)}ms`);
      
      // Send to analytics
      analytics.track('component_render_performance', {
        component: componentName,
        avgRenderTime: avg,
        renderCount: renderCount.current
      });
    }
  });
}

Results from our dashboard component with 50+ connected values:

  • Redux (connect): 280ms average
  • Redux (hooks + memoized selectors): 95ms average
  • Zustand (selective subscriptions): 40ms average

The Migration Strategy That Actually Worked

We couldn't afford to stop feature development for a full rewrite. Here's the incremental approach we used:

Phase 1: Parallel Stores (Week 1)

First, we ran Zustand alongside Redux:

// stores/bridge.ts
import { create } from 'zustand';
import { store } from './redux/store';
 
// Bridge Redux state to Zustand for new features
export const useBridgedStore = create((set) => ({
  // Subscribe to Redux changes
  syncWithRedux: () => {
    const unsubscribe = store.subscribe(() => {
      const reduxState = store.getState();
      set({
        user: reduxState.user,
        settings: reduxState.settings
      });
    });
    return unsubscribe;
  }
}));

Phase 2: Migrate Leaf Components (Week 1-2)

We started with components that didn't have children depending on Redux:

// Before: Redux
function ThemeToggle() {
  const theme = useSelector(state => state.settings.theme);
  const dispatch = useDispatch();
  
  return (
    <button onClick={() => dispatch(toggleTheme())}>
      {theme}
    </button>
  );
}
 
// After: Zustand
function ThemeToggle() {
  const { theme, toggleTheme } = useSettingsStore();
  
  return (
    <button onClick={toggleTheme}>
      {theme}
    </button>
  );
}

Phase 3: Complex State Migration (Week 2)

For complex nested state, we used Zustand's middleware:

// stores/complexStore.ts
import { create } from 'zustand';
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
 
interface ComplexState {
  projects: {
    [id: string]: {
      tasks: Task[];
      members: Member[];
      settings: ProjectSettings;
    };
  };
  activeProjectId: string | null;
}
 
export const useProjectStore = create<ComplexState>()(
  devtools(
    persist(
      subscribeWithSelector(
        immer((set) => ({
          projects: {},
          activeProjectId: null,
          
          updateTask: (projectId: string, taskId: string, updates: Partial<Task>) =>
            set((state) => {
              const project = state.projects[projectId];
              if (project) {
                const task = project.tasks.find(t => t.id === taskId);
                if (task) {
                  Object.assign(task, updates);
                }
              }
            }),
          
          // Immer allows direct mutations
          moveTask: (projectId: string, taskId: string, newIndex: number) =>
            set((state) => {
              const tasks = state.projects[projectId]?.tasks;
              if (tasks) {
                const taskIndex = tasks.findIndex(t => t.id === taskId);
                if (taskIndex !== -1) {
                  const [task] = tasks.splice(taskIndex, 1);
                  tasks.splice(newIndex, 0, task);
                }
              }
            })
        }))
      ),
      {
        name: 'project-storage',
        partialize: (state) => ({ projects: state.projects })
      }
    )
  )
);

Advanced Zustand Patterns I Wish I Knew Earlier

Pattern 1: Computed Values with Subscriptions

// stores/cartStore.ts
export const useCartStore = create<CartState>((set, get) => ({
  items: [],
  
  // Computed values as getters
  get total() {
    return get().items.reduce((sum, item) => 
      sum + (item.price * item.quantity), 0
    );
  },
  
  get itemCount() {
    return get().items.reduce((sum, item) => 
      sum + item.quantity, 0
    );
  },
  
  addItem: (product) => {
    set((state) => {
      const existing = state.items.find(i => i.id === product.id);
      if (existing) {
        return {
          items: state.items.map(i =>
            i.id === product.id 
              ? { ...i, quantity: i.quantity + 1 }
              : i
          )
        };
      }
      return { items: [...state.items, { ...product, quantity: 1 }] };
    });
  }
}));
 
// Selective subscriptions for performance
function CartTotal() {
  const total = useCartStore(state => state.total);
  return <div>Total: ${total.toFixed(2)}</div>;
}

Pattern 2: Async State Management

// stores/dataStore.ts
interface DataStore<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
  fetch: () => Promise<void>;
  refetch: () => Promise<void>;
  reset: () => void;
}
 
function createAsyncStore<T>(
  name: string,
  fetcher: () => Promise<T>
) {
  return create<DataStore<T>>((set, get) => ({
    data: null,
    loading: false,
    error: null,
    
    fetch: async () => {
      const { loading, data } = get();
      if (loading || data) return; // Prevent duplicate fetches
      
      set({ loading: true, error: null });
      try {
        const result = await fetcher();
        set({ data: result, loading: false });
      } catch (error) {
        set({ error: error as Error, loading: false });
      }
    },
    
    refetch: async () => {
      set({ loading: true, error: null });
      try {
        const result = await fetcher();
        set({ data: result, loading: false });
      } catch (error) {
        set({ error: error as Error, loading: false });
      }
    },
    
    reset: () => set({ data: null, loading: false, error: null })
  }));
}
 
// Usage
export const useUserDataStore = createAsyncStore(
  'userData',
  () => api.getUserProfile()
);

Pattern 3: Store Composition

// stores/rootStore.ts
export const useRootStore = create(() => ({
  // Compose multiple stores
  auth: useAuthStore.getState(),
  cart: useCartStore.getState(),
  notifications: useNotificationStore.getState(),
  
  // Global actions that affect multiple stores
  logout: () => {
    useAuthStore.getState().logout();
    useCartStore.getState().clear();
    useNotificationStore.getState().clear();
  }
}));
 
// Subscribe to changes across stores
useRootStore.subscribe(
  (state) => state.auth.user,
  (user) => {
    if (!user) {
      // Clear sensitive data on logout
      localStorage.removeItem('sensitive-data');
    }
  }
);

Testing Zustand Stores

Testing Zustand is significantly simpler than Redux:

// tests/cartStore.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCartStore } from '@/stores/cartStore';
 
describe('CartStore', () => {
  beforeEach(() => {
    useCartStore.setState({ items: [] });
  });
  
  test('adds item to cart', () => {
    const { result } = renderHook(() => useCartStore());
    
    act(() => {
      result.current.addItem({
        id: '1',
        name: 'Test Product',
        price: 10
      });
    });
    
    expect(result.current.items).toHaveLength(1);
    expect(result.current.total).toBe(10);
  });
  
  test('increments quantity for existing item', () => {
    const { result } = renderHook(() => useCartStore());
    
    act(() => {
      result.current.addItem({ id: '1', name: 'Test', price: 10 });
      result.current.addItem({ id: '1', name: 'Test', price: 10 });
    });
    
    expect(result.current.items).toHaveLength(1);
    expect(result.current.items[0].quantity).toBe(2);
    expect(result.current.total).toBe(20);
  });
});

DevTools Integration

While Redux DevTools are more feature-rich, Zustand's DevTools integration is sufficient for most debugging needs:

// stores/debuggableStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
 
export const useDebugStore = create(
  devtools(
    (set) => ({
      count: 0,
      increment: () => 
        set(
          (state) => ({ count: state.count + 1 }),
          false,
          'increment' // Action name in DevTools
        ),
      decrement: () =>
        set(
          (state) => ({ count: state.count - 1 }),
          false,
          { type: 'decrement', payload: -1 } // Redux-style action
        )
    }),
    {
      name: 'debug-store', // Store name in DevTools
      serialize: {
        options: {
          map: true,
          set: true
        }
      }
    }
  )
);

When You Should Still Use Redux

After this migration, I'm not saying Redux is bad. There are still scenarios where Redux makes more sense:

  1. Large teams with Redux expertise: The learning curve for Redux is already paid
  2. Complex middleware requirements: Redux-Saga for complex async flows
  3. Time-travel debugging: Critical for some applications
  4. Strict architectural requirements: Some enterprises mandate Redux patterns
  5. Existing Redux ecosystem: If you heavily rely on Redux-specific libraries

The Developer Experience Transformation

The biggest win wasn't performance or bundle size—it was developer happiness. Our team's feedback after the migration:

  • Onboarding time: New developers productive in 1 day vs 1 week
  • Feature development: 40% faster for state-related features
  • Bug rate: 60% fewer state-related bugs
  • Code reviews: 50% faster (less code to review)

Here's a real Slack message from a team member:

"I just added a new feature with complex state in 30 minutes. That would have taken me half a day with Redux. This is amazing!"

Migration Checklist

If you're considering the switch, here's my battle-tested checklist:

// migration-checklist.ts
const migrationSteps = [
  {
    phase: 'Analysis',
    tasks: [
      'Audit current Redux usage',
      'Identify high-value migration targets',
      'Measure current performance baselines',
      'Document Redux middleware dependencies'
    ]
  },
  {
    phase: 'Preparation',
    tasks: [
      'Set up Zustand alongside Redux',
      'Create migration utilities',
      'Write integration tests',
      'Train team on Zustand patterns'
    ]
  },
  {
    phase: 'Migration',
    tasks: [
      'Migrate UI state first',
      'Move to domain state',
      'Convert async flows',
      'Update tests',
      'Remove Redux dependencies'
    ]
  },
  {
    phase: 'Optimization',
    tasks: [
      'Implement selective subscriptions',
      'Add persistence where needed',
      'Set up DevTools',
      'Performance monitoring'
    ]
  }
];

Final Thoughts

Switching from Redux to Zustand was one of the best technical decisions I've made. Our codebase is cleaner, our bundle is smaller, our app is faster, and our developers are happier.

The migration wasn't without challenges—we had to rethink some patterns and retrain our muscle memory. But the payoff has been tremendous. If you're starting a new project today, I'd strongly recommend Zustand. If you're maintaining a Redux codebase and feeling the pain of boilerplate and complexity, consider a gradual migration.

State management doesn't have to be complicated. Sometimes, less really is more.