Привет, друзья!


На днях мне на глаза попалась статья, посвященная разработке корзины товаров на React с помощью Redux Toolkit для управления состоянием приложения и Redux Persist для хранения состояния в localStorage.


В этой заметке я покажу, как реализовать аналогичный функционал с помощью Zustand, что позволит вам наглядно убедиться в его преимуществах перед Redux как с точки зрения количества кода, так и с точки зрения производительности.



Если вам это интересно, прошу под кат.


Реализация корзины товаров с помощью Redux


С вашего позволения, я не буду полностью переводить указанную выше статью, а ограничусь кодом, непосредственно относящимся к Redux.


Итак, что нужно сделать для управления состоянием корзины товаров с помощью Redux и localStorage?


  • Устанавливаем 3 зависимости:

npm i @reduxjs/toolkit react-redux redux-persist

  • Определяем часть состояния (state slice) корзины:

// src/redux/cartSlice.js
import { createSlice } from '@reduxjs/toolkit';
const cartSlice = createSlice({
  name: 'cart',
  // начальное состояние
  initialState: {
    cart: [],
  },
  reducers: {
    // метод для добавления товара в корзину
    addToCart: (state, action) => {
      const itemInCart = state.cart.find((item) => item.id === action.payload.id);
      if (itemInCart) {
        itemInCart.quantity++;
      } else {
        state.cart.push({ ...action.payload, quantity: 1 });
      }
    },
    // метод для увеличения количества товара, находящегося в корзине
    incrementQuantity: (state, action) => {
      const item = state.cart.find((item) => item.id === action.payload);
      item.quantity++;
    },
    // метод для уменьшения количества товара, находящегося в корзине
    decrementQuantity: (state, action) => {
      const item = state.cart.find((item) => item.id === action.payload);
      if (item.quantity === 1) {
        item.quantity = 1
      } else {
        item.quantity--;
      }
    },
    // метод для удаления товара из корзины
    removeItem: (state, action) => {
      const removeItem = state.cart.filter((item) => item.id !== action.payload);
      state.cart = removeItem;
    },
  },
});
// редуктор
export const cartReducer = cartSlice.reducer;
// операции (экшены)
export const {
  addToCart,
  incrementQuantity,
  decrementQuantity,
  removeItem,
} = cartSlice.actions;

  • Определяем хранилище:

// src/redux/store.js
import { configureStore } from "@reduxjs/toolkit";
import { cartReducer } from "./cartSlice";
export const store = configureStore({
  reducer: cartReducer
});

  • Оборачиваем основной компонент приложения в провайдер хранилища:

// src/index.js
import { Provider } from 'react-redux';
import { store } from './redux/store';

root.render(
  <React.StrictMode>
    <BrowserRouter>
      <Provider store={store}>
        <App />
      </Provider>
    </BrowserRouter>
  </React.StrictMode>
);

  • Извлекаем состояние из хранилища с помощью хука useSelector:

import { useSelector } from 'react-redux';

// в компоненте
const cart = useSelector((state) => state.cart);

  • Отправляем операции в редуктор для модификации состояния с помощью метода dispatch, возвращаемого хуком useDispatch:

import { useDispatch } from 'react-redux';
import { addToCart } from "../redux/cartSlice";

// в компоненте
const dispatch = useDispatch();
dispatch(
  addToCart(item)
);

  • Для хранения состояния в localStorage хранилище определяется следующим образом:

// src/redux/store.js
import { configureStore } from "@reduxjs/toolkit";
import { cartReducer } from "./cartSlice";
import storage from 'redux-persist/lib/storage';
import {
  persistStore,
  persistReducer,
  FLUSH,
  REHYDRATE,
  PAUSE,
  PERSIST,
  PURGE,
  REGISTER,
} from 'redux-persist';
const persistConfig = {
  key: 'root',
  storage,
};
const persistedReducer = persistReducer(persistConfig, cartReducer);
export const store = configureStore({
  reducer: persistedReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
      },
    }),
});
export const persistor = persistStore(store);

  • А основной компонент приложения оборачивается еще в один провайдер:

import { store, persistor } from './redux';
import { PersistGate } from 'redux-persist/integration/react';

root.render(
  <React.StrictMode>
    <BrowserRouter>
      <Provider store={store}>
        <PersistGate loading={<div>Loading...</div>} persistor={persistor}>
          <App />
        </PersistGate>
      </Provider>
    </BrowserRouter>
  </React.StrictMode>
);

Согласитесь, кода получилось многовато. При этом, у нас нет ни одной асинхронной операции, которые в Redux обрабатываются особым образом.



Реализации корзины товаров с помощью Zustand


Клонируем начальный проект из статьи:


git clone -b starter https://github.com/Tammibriggs/shopping-cart.git

Переходим в директорию и устанавливаем Zustand:


cd shopping-cart

npm i zustand

Создаем в директории src файл store/cart.js следующего содержания:


import create from "zustand";

const useCartStore = create((set, get) => ({
  // начальное состояние
  cart: [],
  // метод для добавления товара в корзину
  addToCart: (item) => {
    const { cart } = get();
    const itemInCart = cart.find((i) => i.id === item.id);
    const newCart = itemInCart
      ? cart.map((i) =>
          i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
        )
      : [...cart, { ...item, quantity: 1 }];
    set({ cart: newCart });
  },
  // метод для удаления товара из корзины
  removeItem: (id) => {
    const newCart = get().cart.filter((i) => i.id !== id);
    set({ cart: newCart });
  },
  // метод для увеличения количества товара, находящегося в корзине
  incrementQuantity: (id) => {
    const newCart = get().cart.map((i) =>
      i.id === id ? { ...i, quantity: i.quantity + 1 } : i
    );
    set({ cart: newCart });
  },
  // метод для уменьшения количества товара, находящегося в корзине
  decrementQuantity: (id) => {
    const newCart = get().cart.map((i) =>
      i.id === id ? { ...i, quantity: i.quantity - 1 } : i
    );
    set({ cart: newCart });
  },
  // дополнительно
  // метод для получения общего количества наименований товаров, находящихся в корзине
  getTotalItems: () => get().cart.length,
  // метод для получения общего количества товаров, находящихся в корзине
  getTotalQuantity: () => get().cart.reduce((x, y) => x + y.quantity, 0),
  // метод для получения общей стоимости товаров, находящихся в корзине
  getTotalPrice: () => get().cart.reduce((x, y) => x + y.price * y.quantity, 0),
}));

Внимание: это все, что требуется для управления состоянием приложения с помощью Zustand.


Посмотрим на использование хука useCartStore в компонентах приложения.


Главная страница


На главной странице из состояния извлекается общее количество наименований товаров, находящихся в корзине:


// pages/Home.js
import useCartStore from "../store/cart";

function Home() {
  // возвращаем объект для того, чтобы повторно рендерить компонент при любом изменении состояния
  const { getTotalItems } = useCartStore(({ getTotalItems }) => ({
    getTotalItems,
  }));
  // ...
}

Количество наименований отображается рядом с иконкой (кнопкой) корзины:


<div className="shopping-cart" onClick={() => navigate("/cart")}>
  <ShoppingCart id="cartIcon" />
  <p>{getTotalItems() || 0}</p>
</div>

Страница корзины


На странице корзины из состояние извлекаются товары, добавленные в корзину:


// pages/Cart.js
const cart = useCartStore(({ cart }) => cart);

Товары используются для рендеринга соответствующих карточек:


<div>
  <h3>Shopping Cart</h3>
  {cart.map((i) => (
    <CartItem key={i.id} {...i} />
  ))}
</div>

Карточка товара на главной странице


В карточке товара для главной странице из состояния извлекается метод для добавления товара в корзину:


// components/Item.js
const addToCart = useCartStore(({ addToCart }) => addToCart);

Данный метод можно мемоизировать следующим образом:


const addItem = useCallback(() => {
  addToCart({ id, title, image, price });
}, []);

Вызывается он при нажатии соответствующей кнопки:


<button onClick={addItem}>Add to Cart</button>

Карточка товара на странице корзины


В карточке товара для страницы корзины из состояния извлекаются методы для удаления товара из корзины, увеличения и уменьшения количества товаров, находящихся в корзине:


// components/CartItem.js
const { incrementQuantity, decrementQuantity, removeItem } = useCartStore(
  ({ incrementQuantity, decrementQuantity, removeItem }) => ({
    incrementQuantity,
    decrementQuantity,
    removeItem,
  })
);

Данные методы вызываются при нажатии соответствующих кнопок:


<div className="cartItem">
  <img className="cartItem__image" src={image} alt="item" />

  <div className="cartItem__info">
    <p className="cartItem__title">{title}</p>
    <p className="cartItem__price">
      <small>$</small>
      <strong>{price}</strong>
    </p>
    <div className="cartItem__incrDec">
      <button
        onClick={() => {
          if (quantity === 1) return;
          decrementQuantity(id);
        }}
      >
        -
      </button>
      <p>{quantity}</p>
      <button onClick={() => incrementQuantity(id)}>+</button>
    </div>
    <button
      className="cartItem__removeButton"
      onClick={() => removeItem(id)}
    >
      Remove
    </button>
  </div>
</div>

Статистика


В компоненте статистики из состояния извлекаются методы для получения общего количества и стоимости товаров, находящихся в корзине:


// components/Total.js
const { getTotalQuantity, getTotalPrice } = useCartStore(
  ({ getTotalQuantity, getTotalPrice }) => ({
    getTotalQuantity,
    getTotalPrice,
  })
);

Данные методы вызываются для рендеринга соответствующих данных:


<p className="total__p">
  total ({getTotalQuantity()} items) :{" "}
  <strong>${getTotalPrice()}</strong>
</p>

Таким образом, мы видим, что управлять состоянием приложения с помощью Zustand гораздо проще, чем с помощью Redux. Для реализации корзины товаров требуется в 3 раза меньше кода.


Хранение состояния в localStorage


Для хранения состояния в localStorage с помощью Zustand достаточно обернуть функцию обратного вызова, передаваемую в create(), в посредник persist, указав ключ и используемое хранилище:


import { persist } from "zustand/middleware";

const useCartStore = create(
  persist(
    (set, get) => ({
      // ...
    }),
    {
      name: "cart-storage",
      getStorage: () => localStorage,
    }
  )
);

Готово.



Сравнение производительности


Immer, встроенный в Redux Toolkit, делает его чудовищно медленным. Проведем небольшой эксперимент.


Для чистоты эксперимента удалим весь код, связанный с хранением состояния в localStorage.


Определим функции для добавления 2500 товаров в хранилище:


// redux
// src/App.js
// ...
import { addToCart } from "./redux/cartSlice";

function App() {
  const dispatch = useDispatch();

  const addToCart2500Items = () => {
    const times = [];
    let id = 0;
    for (let i = 0; i < 25; i++) {
      const start = performance.now();
      for (let j = 0; j < 100; j++) {
        const item = {
          id: id++,
          title: "title",
          image: "image",
          price: "price",
        };
        // !
        dispatch(addToCart(item));
      }
      const difference = performance.now() - start;
      times.push(difference);
    }
    const time = Math.round(times.reduce((a, c) => (a += c), 0) / 25);
    console.log("Time:", time);
  };

  // вызываем функцию после полной загрузки страницы
  useEffect(() => {
    window.addEventListener("load", addToCart2500Items);

    return () => {
      window.removeEventListener("load", addToCart2500Items);
    };
  }, []);

  // ...
}

// zustand
// ...
import useCartStore from "./store/cart";

function App() {
  const addToCart = useCartStore(({ addToCart }) => addToCart);

  const addToCart2500Items = () => {
    const times = [];
    let id = 0;
    for (let i = 0; i < 25; i++) {
      const start = performance.now();
      for (let j = 0; j < 100; j++) {
        const item = {
          id: id++,
          title: "title",
          image: "image",
          price: "price",
        };
        // !
        addToCart(item);
      }
      const difference = performance.now() - start;
      times.push(difference);
    }
    const time = Math.round(times.reduce((a, c) => (a += c), 0) / 25);
    console.log("Time:", time);
  };

  useEffect(() => {
    window.addEventListener("load", addToCart2500Items);

    return () => {
      window.removeEventListener("load", addToCart2500Items);
    };
  }, []);

  // ...
}

Получаем следующие средние значения (на вашей машине эти значения, скорее всего, будут немного отличаться):


  • Zustand10 мс;
  • Redux250 мс.

Внимание: для добавления в хранилище 2500 товаров Redux требуется (sic!) в 25 раз больше времени, чем Zustand.


Обновление и удаления товаров дают аналогичные результаты. Полагаю, цифры говорят сами за себя.


Что насчет размера пакетов? — спросите вы. Пожалуйста:








Это все, чем я хотел поделиться с вами в данной заметке. Надеюсь, вы нашли для себя что-то интересное и не зря потратили время. Благодарю за внимание и happy coding!




Комментарии (3)


  1. markelov69
    22.08.2022 11:02

    Мда, что Redux убожество, что Zustand убожество. Как вообще можно про них говорить/думать/писать/применять в работе? Уже ж много лет все нормальные люди юзают MobX.


  1. yroman
    22.08.2022 12:14
    +1

    Данный метод можно мемоизировать следующим образом:

    Зачем делать кривую мемоизацию? Чтобы потом ловить странные баги? Либо делайте как нужно, с прописыванием зависимостей, либо вообще не делайте, далеко не факт, что она тут нужна.


  1. belousovnikita92
    23.08.2022 00:28

    1. Тулкит это целый комбайн с экосистемой, сравнивать его с просто стором не совсем корректно.

    2. Если будет задача массового добавления товаров в корзину, то не лучше ли будет делать это одним экшном вместо N*количество товаров? Кейс кажется выдуманным и в реальном проекте за такое сразу дадут по шапке