React & Next.js Topic

React State Management Libraries

The Evolution of State Management

Why We Need State Management Libraries

While React's built-in state is great for:

  • Local component state

  • Simple parent-child data flow

  • Limited scope state

We need state management libraries for:

  • Global state shared across many components

  • Complex state logic with multiple updates

  • Server cache state alongside UI state

  • DevTools for debugging and time-travel debugging

  • Performance optimizations for frequent updates


1. Redux Toolkit (Modern Redux)

Redux Toolkit (RTK) is the official, opinionated, batteries-included toolset for efficient Redux development. It addresses Redux's historical boilerplate issues.

Core Concepts

jsx

// 1. Create a Slice (contains reducer logic + actions)
import { createSlice, configureStore } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    value: 0,
    loading: false,
    error: null
  },
  reducers: {
    increment: (state) => {
      state.value += 1; // ✅ Can "mutate" directly (uses Immer internally)
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmount: (state, action) => {
      state.value += action.payload;
    },
    setLoading: (state, action) => {
      state.loading = action.payload;
    }
  }
});

// Auto-generated action creators
export const { increment, decrement, incrementByAmount, setLoading } = counterSlice.actions;

// 2. Create Store
const store = configureStore({
  reducer: {
    counter: counterSlice.reducer,
    // other reducers...
  }
});

// 3. Provide store to React app
import { Provider } from 'react-redux';

function App() {
  return (
    <Provider store={store}>
      <MyComponent />
    </Provider>
  );
}

// 4. Use in components with React-Redux hooks
import { useSelector, useDispatch } from 'react-redux';

function Counter() {
  const count = useSelector((state) => state.counter.value);
  const dispatch = useDispatch();

  return (
    <div>
      <span>Count: {count}</span>
      <button onClick={() => dispatch(increment())}>+</button>
      <button onClick={() => dispatch(decrement())}>-</button>
      <button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
    </div>
  );
}

Async Logic with createAsyncThunk

jsx

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

// Async thunk for API calls
export const fetchUserById = createAsyncThunk(
  'users/fetchById',
  async (userId, { rejectWithValue }) => {
    try {
      const response = await fetch(`/api/users/${userId}`);
      if (!response.ok) throw new Error('Server error!');
      return await response.json();
    } catch (error) {
      return rejectWithValue(error.message);
    }
  }
);

const usersSlice = createSlice({
  name: 'users',
  initialState: {
    entities: {},
    loading: 'idle', // 'idle' | 'pending' | 'succeeded' | 'failed'
    error: null
  },
  reducers: {
    // sync reducers...
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUserById.pending, (state) => {
        state.loading = 'pending';
        state.error = null;
      })
      .addCase(fetchUserById.fulfilled, (state, action) => {
        state.loading = 'succeeded';
        state.entities[action.payload.id] = action.payload;
      })
      .addCase(fetchUserById.rejected, (state, action) => {
        state.loading = 'failed';
        state.error = action.payload;
      });
  }
});

// Usage in component
function UserProfile({ userId }) {
  const dispatch = useDispatch();
  const { user, loading, error } = useSelector((state) => ({
    user: state.users.entities[userId],
    loading: state.users.loading,
    error: state.users.error
  }));

  useEffect(() => {
    dispatch(fetchUserById(userId));
  }, [dispatch, userId]);

  if (loading === 'pending') return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  
  return <div>{user?.name}</div>;
}

RTK Query (Data Fetching & Caching)

jsx

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

// Define API service
export const apiSlice = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['User', 'Post'],
  endpoints: (builder) => ({
    getUsers: builder.query({
      query: () => '/users',
      providesTags: ['User']
    }),
    getUserById: builder.query({
      query: (id) => `/users/${id}`,
      providesTags: (result, error, id) => [{ type: 'User', id }]
    }),
    createUser: builder.mutation({
      query: (userData) => ({
        url: '/users',
        method: 'POST',
        body: userData
      }),
      invalidatesTags: ['User']
    })
  })
});

// Auto-generated hooks
export const { useGetUsersQuery, useGetUserByIdQuery, useCreateUserMutation } = apiSlice;

// Setup store
const store = configureStore({
  reducer: {
    [apiSlice.reducerPath]: apiSlice.reducer,
    // other reducers...
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(apiSlice.middleware)
});

// Usage in component
function UsersList() {
  const { data: users, isLoading, error } = useGetUsersQuery();
  const [createUser, { isLoading: isCreating }] = useCreateUserMutation();

  const handleAddUser = async (userData) => {
    try {
      await createUser(userData).unwrap();
      // Cache automatically invalidated, data refetched
    } catch (err) {
      console.error('Failed to create user:', err);
    }
  };

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      {users?.map(user => <div key={user.id}>{user.name}</div>)}
      <button onClick={() => handleAddUser({ name: 'New User' })}>
        {isCreating ? 'Creating...' : 'Add User'}
      </button>
    </div>
  );
}

2. Zustand (Lightweight Alternative)

Zustand is a small, fast, and scalable state management solution with a minimal API.

Basic Usage

jsx

import { create } from 'zustand';

// Create a store
const useCounterStore = create((set, get) => ({
  count: 0,
  loading: false,
  
  // Actions
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  incrementBy: (amount) => set((state) => ({ count: state.count + amount })),
  
  // Async action
  incrementAsync: async (amount) => {
    set({ loading: true });
    await new Promise(resolve => setTimeout(resolve, 1000));
    set((state) => ({ count: state.count + amount, loading: false }));
  },
  
  // Using get() to access current state in actions
  logCount: () => {
    const currentCount = get().count;
    console.log('Current count:', currentCount);
  }
}));

// Usage in component
function Counter() {
  const { count, loading, increment, incrementAsync } = useCounterStore();
  
  return (
    <div>
      <span>Count: {count}</span>
      <button onClick={increment} disabled={loading}>+</button>
      <button onClick={() => incrementAsync(5)} disabled={loading}>
        {loading ? 'Loading...' : '+5 Async'}
      </button>
    </div>
  );
}

// Selective state picking (prevents unnecessary re-renders)
function CountDisplay() {
  const count = useCounterStore((state) => state.count);
  return <div>Current count: {count}</div>;
}

Advanced Zustand Patterns

jsx

// Middleware for persistence, devtools, etc.
const useAuthStore = create(
  persist(
    (set, get) => ({
      user: null,
      token: null,
      
      login: async (credentials) => {
        set({ loading: true });
        try {
          const response = await fetch('/api/login', {
            method: 'POST',
            body: JSON.stringify(credentials)
          });
          const { user, token } = await response.json();
          set({ user, token, loading: false, error: null });
        } catch (error) {
          set({ error: error.message, loading: false });
        }
      },
      
      logout: () => set({ user: null, token: null, error: null }),
      
      // Computed values
      isAuthenticated: () => !!get().token,
    }),
    {
      name: 'auth-storage', // localStorage key
      // Only persist these fields
      partialize: (state) => ({ user: state.user, token: state.token })
    }
  )
);

// Slice pattern for larger stores
const createUserSlice = (set, get) => ({
  users: [],
  addUser: (user) => set((state) => ({ 
    users: [...state.users, user] 
  })),
  removeUser: (id) => set((state) => ({
    users: state.users.filter(user => user.id !== id)
  }))
});

const createPostSlice = (set, get) => ({
  posts: [],
  addPost: (post) => set((state) => ({
    posts: [...state.posts, post]
  }))
});

// Combine slices
const useAppStore = create((...args) => ({
  ...createUserSlice(...args),
  ...createPostSlice(...args)
}));

3. Jotai (Atomic State Management)

Jotai takes a atomic approach inspired by React's own useState. It's great for primitive state and derived state.

Basic Usage

jsx

import { atom, useAtom } from 'jotai';

// Primitive atoms
const countAtom = atom(0);
const nameAtom = atom('John');

// Derived atoms (computed state)
const doubledCountAtom = atom((get) => get(countAtom) * 2);
const userInfoAtom = atom((get) => ({
  name: get(nameAtom),
  count: get(countAtom),
  doubled: get(doubledCountAtom)
}));

// Write-only atoms (actions)
const incrementAtom = atom(null, (get, set) => {
  set(countAtom, get(countAtom) + 1);
});

// Async atoms
const userAtom = atom(async (get) => {
  const userId = get(selectedUserIdAtom);
  const response = await fetch(`/api/users/${userId}`);
  return response.json();
});

function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const [doubledCount] = useAtom(doubledCountAtom);
  const [, increment] = useAtom(incrementAtom);

  return (
    <div>
      <p>Count: {count}</p>
      <p>Doubled: {doubledCount}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={increment}>Increment (action)</button>
    </div>
  );
}

Advanced Jotai Patterns

jsx

import { atom, useAtom, Provider } from 'jotai';
import { useHydrateAtoms } from 'jotai/utils';

// Atoms with localStorage persistence
const persistentAtom = (key, initialValue) => {
  const baseAtom = atom(initialValue);
  const persistedAtom = atom(
    (get) => get(baseAtom),
    (get, set, update) => {
      set(baseAtom, update);
      localStorage.setItem(key, JSON.stringify(update));
    }
  );
  return persistedAtom;
};

// SSR hydration
const countAtom = atom(0);

function HydrateAtoms({ initialCount, children }) {
  useHydrateAtoms([[countAtom, initialCount]]);
  return children;
}

function App({ initialCount }) {
  return (
    <Provider>
      <HydrateAtoms initialCount={initialCount}>
        <MyComponent />
      </HydrateAtoms>
    </Provider>
  );
}

Comparison & When to Use

Library Bundle Size Learning Curve Use Cases
Redux Toolkit ~9kB + React-Redux Moderate Large apps, complex state, need DevTools, team projects
Zustand ~1.5kB Easy Small-medium apps, simple global state, minimal boilerplate
Jotai ~3kB Easy Primitive state, derived state, React-like mental model

Decision Guide

Choose Redux Toolkit when:

  • Building large, complex applications

  • Need excellent DevTools and time-travel debugging

  • Working in a team with established patterns

  • Need built-in solutions for data fetching (RTK Query)

  • Require middleware ecosystem

Choose Zustand when:

  • Building small to medium applications

  • Want minimal boilerplate and simple API

  • Need a lightweight global state solution

  • Prefer less opinionated structure

Choose Jotai when:

  • Working with lots of primitive state

  • Need fine-grained reactivity

  • Want React-like mental model

  • Need computed/derived state

Modern Recommendation

jsx

// For most new projects today:
// 1. Start with React built-in state + Context
// 2. Add Zustand when you need more than Context
// 3. Use Redux Toolkit for very complex apps or teams
// 4. Consider Jotai for fine-grained reactive state

// Example: Modern stack for a medium app
- React Query/TanStack Query (server state)
- Zustand (client state) 
- React Hook Form (forms)

The key is to start simple and only add complexity when you have clear needs. Most applications don't need Redux's full power, and Zustand or even Context + useReducer might be sufficient.

Comments (0)

No comments yet. Be the first to comment!

LecturreAI Assistant

Hello! I'm LecturreAI

What would you like to learn today?