সরাসরি পর্বে যান
পর্ব ১৮ · ৯৩-এর মধ্যে · মডিউল ৩

Zustand আর Redux Toolkit — global store, action আর selector

Zustand and Redux Toolkit — Global Store, Actions and Selectors

Taught in Bangla, this lesson explains why stores live outside React, the useSyncExternalStore contract, selectors that decide who re-renders, the path from Flux to Redux, Redux Toolkit with configureStore, createSlice and Immer, Redux DevTools and memoized selectors, Zustand as a single hook with persist, the same cart built both ways with measured bundle sizes and render counts, and when neither is needed.

এই পর্বে যা শিখবেন

  • Store কেন React-এর বাইরে রাখা হয়, আর component কীভাবে তাতে subscribe করে
  • Selector দিয়ে অকারণ re-render ঠেকানো, আর নতুন object ফেরানোর ফাঁদ চেনা
  • Flux থেকে Redux, তিনটি নীতি, configureStore, createSlice, Immer আর async thunk
  • Zustand-এর create, selector, persist আর hydration
  • “বাজারঘর”-এর একই ঝুড়ি দুইভাবে বানিয়ে আকার, কোডের লাইন আর render মাপা, আর কখন কোনোটিই না নেওয়া

১ · Store কেন React-এর বাইরে

একটি মুদি দোকান ভাবুন। ক্যাশে একটি বড় হিসাব খাতা আছে। চার জন কর্মচারী আলাদা আলাদা কাজ করেন। একজন শুধু চালের মজুত দেখেন, একজন শুধু আজকের বিক্রি। প্রতিটি লেনদেনে যদি মালিক চারজনকেই ডেকে পুরো খাতা পড়ে শোনান, সবার কাজ থেমে যাবে। ভালো ব্যবস্থা হলো, খাতা এক জায়গায় থাকবে। প্রত্যেকে বলে রাখবেন, “আমার লাইনটি বদলালে আমাকে জানাবেন।”

বাজারঘরের ঝুড়িতেও এই সমস্যা। ওপরের মেনুতে পণ্যের সংখ্যা দেখাতে হয়, পণ্যের পাতায় “যোগ” বোতাম আছে, আর পাশের প্যানেলে মোট দাম। এই তিনজন ট্রিতে অনেক দূরে। পর্ব ১৬ আর ১৭-তে state ওপরে তোলা আর Context দেখেছেন। Context মান পৌঁছে দেয়, কিন্তু মান বদলালে যে যে component ওই Context পড়ে, React তাদের সবাইকে আবার render করে। কে কোন অংশ পড়েছে, Context তা দেখে না।

Store এখানে অন্য পথ নেয়। State থাকে একটি সাধারণ JavaScript object-এ, React-এর বাইরে, একটি module-এর ভেতরে। Component শুধু তার দরকারি অংশ পড়ে আর store-এ subscribe করে রাখে। Store বদলালে প্রতিটি subscriber নিজের অংশ মিলিয়ে দেখে। অংশ না বদলালে তার render-ই হয় না। এই শব্দগুলো পরের তিন অংশে পরিষ্কার হবে।

Store ট্রির বাইরে, component সরাসরি যুক্ত

Components subscribe to one store that lives outside the React tree

React tree-র বাইরে একটি store, আর তিনটি component-এর সরাসরি subscription React tree App Badge ProductList CartPanel সংখ্যা add lines subscribe + selector store — React-এর বাইরে lines · add · decrement · clear একটি module-এ একটি object
ছবি ১৮.১ — ধূসর তীর React-এর ট্রি। সবুজ দাগ store-এর সংযোগ, যা মাঝের কোনো component-এর ভেতর দিয়ে যায় না।
মূল ধারণা

Store-এর লাভ দুটি। এক: prop drilling ছাড়াই দূরের component মান পায়। দুই: বদল হলে শুধু সংশ্লিষ্ট component render হয়। দ্বিতীয় লাভটি আসে selector থেকে, store থেকে নিজে নিজে আসে না।

২ · useSyncExternalStore — React আর বাইরের জগতের চুক্তি

React জানে না আপনার module-এর একটি variable কখন বদলাল। তাকে জানাতে দুটি জিনিস লাগে। React 18 (২০২২) থেকে আসা hook useSyncExternalStore ঠিক এই দুটি নেয়:

const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?);
  • subscribe(callback): React একটি callback দেয়। Store বদলালে সেটি ডাকতে হবে। ফেরত দিতে হবে unsubscribe function.
  • getSnapshot(): এখনকার মান ফেরত দেয়। Store না বদলালে প্রতিবার একই মান ফেরাতে হবে। React Object.is দিয়ে আগের মানের সাথে মেলায়, আলাদা হলে render করে।
  • getServerSnapshot(): ঐচ্ছিক। Server render আর hydration-এর সময় যে মান দেখাবে। এটি না দিলে server-এ render করার সময় error হয়।

Zustand-এর ভেতরে আসলে এটুকুই আছে। নিচের ব্লকটি শেখানোর মডেল, আসল Zustand নয়। প্যাকেজ এখানে import করা যায় না, তাই একই ধারণা প্রায় ১৫ লাইনে লেখা হয়েছে। বোতাম চেপে নিচের log দেখুন।

<div id="root"></div>
<script src="../course-kit/vendor/react-lab.js"></script>
<script type="text/jsx">
// শেখানোর মডেল: আসল Zustand নয়
function create(init) {
  let state;
  const listeners = new Set();
  const set = (partial) => {
    state = { ...state, ...partial };
    listeners.forEach((fn) => fn());
  };
  state = init(set, () => state);
  const subscribe = (fn) => { listeners.add(fn); return () => listeners.delete(fn); };
  return (selector) => useSyncExternalStore(subscribe, () => selector(state));
}
const useCart = create((set, get) => ({
  count: 0, shopper: 'Rafi',
  add: () => set({ count: get().count + 1 }),
  rename: () => set({ shopper: 'Mina' }),
}));
const runs = { Badge: 0, Name: 0 };
const log = (who) => console.log(who, 'render', ++runs[who]);
function Badge() { const c = useCart((s) => s.count); log('Badge'); return <p>ঝুড়ি: {c}</p>; }
function Name() { const n = useCart((s) => s.shopper); log('Name'); return <p>ক্রেতা: {n}</p>; }
function Buttons() {
  const add = useCart((s) => s.add), rename = useCart((s) => s.rename);
  return <><button onClick={add}>যোগ</button> <button onClick={rename}>নাম বদল</button></>;
}
createRoot(document.getElementById('root')).render(<><Badge /><Name /><Buttons /></>);
</script>

পাতা খোলার সময় log-এ আসে Badge render 1 আর Name render 1. Edge-এ Playwright দিয়ে “যোগ” দুবার আর “নাম বদল” একবার চাপলে আরও তিনটি লাইন এলো: Badge render 2, Badge render 3, Name render 2. “যোগ” চাপলে Name render হয়নি, যদিও পুরো store বদলেছে। Name-এর selector আগের মতোই 'Rafi' দিয়েছে, তাই React তাকে ছেড়ে দিয়েছে। এখানে কোনো Provider নেই, শুধু module-এর একটি closure. Closure ভুলে গেলে JavaScript কোর্সে দেখে নিতে পারেন।

কেন নিজে useEffect দিয়ে subscribe করবেন না? Concurrent rendering-এ React একটি render থামিয়ে অন্য কাজ করতে পারে। সেই ফাঁকে বাইরের store বদলালে অর্ধেক পাতা পুরোনো মান, অর্ধেক নতুন মান দেখাতে পারে। একে বলে tearing. useSyncExternalStore DOM বসানোর ঠিক আগে getSnapshot আবার ডাকে। মান বদলে গেলে সে update-টি আবার শুরু করে blocking হিসেবে, তাই পুরো পাতা একই মান দেখায়।

৩ · Selector — কে কখন re-render হবে

Selector একটি ছোট function, যা পুরো state থেকে একটি অংশ বেছে নেয়। Store বদলালে কী হয়, এবার ধীরে দেখা যাক। শুরুতে state হলো count: 2, shopper: 'Rafi'. তিনটি component subscribe করে আছে। ক্রেতা “যোগ” চাপলেন।

1function set(partial) {2  state = { ...state, ...partial };3  listeners.forEach((fn) => fn());4}5// Badge  : (s) => s.count6// Name   : (s) => s.shopper7// Buttons: (s) => s.add
মান
state.count
state.shopper
Badge
Name
Buttons
  1. শুরু। তিনটি component-এর কাছে তাদের শেষ দেখা মান আছে: 2, Rafi আর add function. কেউ render হচ্ছে না।
  2. set({ count: 3 }) চলল। একটি নতুন state object তৈরি হলো। count এখন 3, shopper আগের মতোই।
  3. তিনজনকেই খবর। Store জানে না কে কী পড়ে। তাই প্রতিটি listener ডাকা হয়। লক্ষ করুন, সব selector চলবে, যদিও render সবার হবে না।
  4. Badge মিলিয়ে দেখে। Selector দিল 3, আগে ছিল 2. Object.is(2, 3) মিথ্যা, তাই Badge render হবে।
  5. Name মিলিয়ে দেখে। Selector দিল Rafi, আগেও Rafi. সমান, তাই render নেই।
  6. Buttons মিলিয়ে দেখে। add function একবারই তৈরি হয়েছিল, তাই reference একই। Render নেই।
  7. ফল। তিনটি selector চলল, render হলো একটি component. Selector চলার খরচ সামান্য, render-এর খরচ অনেক বেশি। Selector-এর পুরো লাভ এখানেই।

দুটি সাধারণ ভুল, দুটিই চালিয়ে দেখা

ভুল ১: পুরো store নেওয়া। const { add } = useCart(); দেখতে সুন্দর। কিন্তু selector না দিলে পুরো state-ই সেই component-এর snapshot. তাই যেকোনো বদলেই সে render হবে, এমনকি add না বদলালেও।

ভুল ২: প্রতিবার নতুন object ফেরানো। (s) => ({ lines: s.lines }) প্রতি ডাকে একটি নতুন object বানায়। পর্ব ১০-এ দেখা reference equality অনুযায়ী দুটি আলাদা object সমান নয়। Zustand 5-এ এটি সরাসরি অ্যাপ ভেঙে দেয়। সত্যিকারের run (Zustand 5.0.16, React 19.3.1, Vite dev server, Edge-এর console):

The result of getSnapshot should be cached to avoid an infinite loop
Uncaught Error: Maximum update depth exceeded. This can happen when a component
repeatedly calls setState inside componentWillUpdate or componentDidUpdate.
React limits the number of nested updates to prevent infinite loops.
An error occurred in the <CartPanel> component.

একই ভুল Redux-এ অ্যাপ ভাঙে না, শুধু development-এ সাবধান করে। React-Redux 9.3.1-এর real console বার্তা (stack trace বাদ দিয়ে):

Selector unknown returned a different result when called with the same parameters.
This can lead to unnecessary rerenders.
Selectors that return a new reference (such as an object or an array) should be memoized

Zustand-এ একসাথে কয়েকটি মান দরকার হলে useShallow ব্যবহার করুন। এটি প্রতিটি field আগের মানের সাথে shallow ভাবে মেলায়, আর মিললে আগের object-টিই ফেরত দেয়:

import { useShallow } from 'zustand/react/shallow';

// ভালো: একটি করে ছোট মান
const add = useCart((s) => s.add);

// ভালো: একসাথে কয়েকটি, useShallow দিয়ে
const { lines, clear } = useCart(useShallow((s) => ({ lines: s.lines, clear: s.clear })));

Render সংখ্যা মাপা হয়েছে একই ধাপে: পাতা খোলা, তারপর ৫টি কাজ (চাল, চাল, ডাল, ডাল কমানো, খালি করা)। Production build, Edge, Playwright, একটি local run. বদলগুলো শুধু ProductList-এর ভেতরে, যে component-টি ঝুড়ির কিছুই দেখায় না:

লেখার ধরনBadgeProductListCartPanel
Context + useReducer666
Zustand, (s) => s.add616
Zustand, useShallow616
Zustand, const { add } = useCart()666
Redux Toolkit, selector ছাড়া শুধু dispatch616
Redux Toolkit, নতুন object ফেরানো selector666

পুরো store নেওয়া মানে কর্মচারী বলছেন, “খাতার যেকোনো লাইন বদলালে আমাকে ডাকবেন।” তাহলে খাতা এক জায়গায় রেখেও লাভ রইল না। টেবিলের চতুর্থ সারি প্রথম সারির সমান, কারণ দুটোতেই সবাই সব খবর পাচ্ছেন।

৪ · Flux থেকে Redux — state বদলানোর একটিই পথ

২০১৪ সালে Facebook Flux নামে একটি নকশা দেখায়: data চলবে একদিকে, action থেকে store হয়ে view-তে। ২০১৫-র মাঝামাঝি Dan Abramov একটি সম্মেলনের talk-এ time-travel debugging দেখাতে Redux তৈরি করেন। Andrew Clark-ও এর সহ-নির্মাতা হিসেবে পরিচিত। Redux-এর নথিতে Elm ভাষার প্রভাবের কথাও আছে। ২০১৯ সালে Redux দল Redux Toolkit আনে, কারণ হাতে লেখা Redux-এ বাড়তি কোড অনেক লাগত।

Redux-এর নথি তিনটি নীতি দেয়। এগুলো মুখস্থ নয়, বুঝে রাখার জিনিস:

১. এক জায়গায় সত্য

Single source of truth. পুরো অ্যাপের state একটি store-এর একটি object tree-তে থাকে।

২. State শুধু পড়ার

State is read-only. বদলানোর একমাত্র পথ একটি action পাঠানো, যে object বলে “কী ঘটেছে”।

৩. Pure function-এ বদল

Changes are made with pure functions. Reducer পুরোনো state আর action নিয়ে নতুন state ফেরত দেয়।

ব্যাংকের লেজার ভাবুন। কেউ ব্যালান্সের ঘর ঘষে বদলান না। প্রতিটি জমা বা উত্তোলন একটি ভাউচার, আর ক্যাশিয়ার নিয়ম মেনে নতুন ব্যালান্স লেখেন। ভাউচার হলো action, ক্যাশিয়ার reducer, লেজার store. শেষের ব্যালান্সে সন্দেহ হলে ভাউচারগুলো শুরু থেকে আবার মেলালেই সব ধরা পড়ে।

নিচের ব্লকটিও শেখানোর মডেল। এতে Redux-এর মূল store-টি প্রায় ১০ লাইনে লেখা। শেষ লাইনে খেয়াল করুন, শুধু action-এর তালিকা থেকেই পুরোনো state আবার বের করা যাচ্ছে।

// শেখানোর মডেল: Redux-এর মূল ধারণা, আসল library নয়
function createStore(reducer) {
  let state = reducer(undefined, { type: '@@init' });
  const listeners = new Set();
  return {
    getState: () => state,
    subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); },
    dispatch(action) {
      state = reducer(state, action);      // নতুন state
      listeners.forEach((fn) => fn());     // সবাইকে খবর
    },
  };
}
function cart(state = { count: 0, total: 0 }, action) {
  switch (action.type) {
    case 'cart/added':
      return { count: state.count + 1, total: state.total + action.price };
    case 'cart/cleared':
      return { count: 0, total: 0 };
    default:
      return state;                        // অচেনা action: একই state
  }
}
const store = createStore(cart);
const actionLog = [];                      // DevTools-এর মতো খাতা
const send = (action) => {
  store.dispatch(action);
  actionLog.push(action);
  console.log(actionLog.length, action.type, JSON.stringify(store.getState()));
};
send({ type: 'cart/added', price: 420 });
send({ type: 'cart/added', price: 135 });
send({ type: 'cart/cleared' });
const replay = (n) => actionLog.slice(0, n).reduce(cart, undefined);
console.log('time travel to 2:', JSON.stringify(replay(2)));

আউটপুট:

1 cart/added {"count":1,"total":420}
2 cart/added {"count":2,"total":555}
3 cart/cleared {"count":0,"total":0}
time travel to 2: {"count":2,"total":555}

৪২০ + ১৩৫ = ৫৫৫. তৃতীয় action ঝুড়ি খালি করেছে, তবু শেষ লাইনে ২ নম্বর ধাপের state ফিরে এলো। কারণ replay শুধু প্রথম দুটি action আবার reducer-এ চালিয়েছে। Reducer pure বলেই এটি সম্ভব। সে যদি সময় পড়ত বা random সংখ্যা নিত, একই action-এর তালিকা ভিন্ন ফল দিত।

আসল Redux Toolkit-এ একটি action পুরো পথ কীভাবে পার হয়, দেখা যাক। ঝুড়িতে এক প্যাকেট চাল আছে, ক্রেতা আবার “যোগ” চাপলেন। মানগুলো নিচের সত্যিকারের run থেকে নেওয়া।

  1. UIdispatchক্লিক
  2. Actioncart/addedভাউচার
  3. Middlewarethunk…গেট
  4. Reduceraddedক্যাশিয়ার
  5. StoregetStateলেজার
  6. SelectoruseSelectorনিজের লাইন
ক্লিকের আগেstore-এর state
cart.lines = [{ id: 'rice', price: 420, qty: 1 }]
dispatch(added({ id: 'rice', price: 420 }))
Actionএকটি সাধারণ object
{ type: 'cart/added', payload: { id: 'rice', price: 420 } }
Middlewarereducer-এর আগের গেট
thunk: এটি function? না, তাই সামনে যায়
development-এর পরীক্ষা: state বদলানো হয়নি, মান serializable
next(action)
ReducerImmer-এর draft-এ চলে
const line = state.lines.find(...)  // rice পাওয়া গেল
line.qty += 1                       // 1 থেকে 2
// Immer নতুন state বানায়, পুরোনোটি অক্ষত
Storeনতুন state, সবাইকে খবর
cart.lines = [{ id: 'rice', price: 420, qty: 2 }]
state changed? true
Selectorকে render হবে
Badge      selectCount  1 → 2      render
CartPanel  selectLines  নতুন array  render
ProductList শুধু dispatch           render নেই
  1. ক্লিক। Component নিজে state বদলায় না। সে শুধু dispatch ডাকে। এখন ঝুড়িতে চালের qty 1.
  2. Action তৈরি। added(...) একটি action creator. এটি কিছুই বদলায় না, শুধু একটি object বানায়। type নামটি slice-এর নাম আর reducer-এর নাম জুড়ে হয়েছে।
  3. Middleware. Server-এর middleware-এর মতোই, মাঝখানে বসা function-এর শিকল। Redux-এ thunk function ধরে নেয়। সাধারণ object পেলে সামনে পাঠায়। Development-এ বাড়তি পরীক্ষাও এখানে চলে।
  4. Reducer. Reducer চালের লাইন খুঁজে qty বাড়ায়। লেখাটি mutation-এর মতো দেখালেও আসল state বদলায় না। অংশ ৫-এ এর রহস্য দেখবেন।
  5. Store. নতুন root state জমা হলো, qty এখন 2. Store সব subscriber-কে খবর দিল। পুরোনো আর নতুন state আলাদা object, তাই real run-এ state changed? true এসেছে।
  6. Selector. useSelector নিজের মান মিলিয়ে দেখে। Badge আর CartPanel render হয়, ProductList হয় না। আগের টেবিলের ৬, ১, ৬ সংখ্যার কারণ এটাই।

৫ · Redux Toolkit — configureStore, createSlice আর Immer

আগে Redux লিখতে action type, action creator আর switch আলাদা আলাদা ফাইলে লিখতে হতো। পুরোনো createStore এখন আর সুপারিশ করা হয় না। এখন সরকারি পথ হলো Redux Toolkit. নিচে বাজারঘরের ঝুড়ির আসল slice, Vite project-এ চালানো (লেখার সময়ে @reduxjs/toolkit 2.13.0, react-redux 9.3.1, যা ভেতরে Immer 11.2.9 আর Redux 5.0.1 আনে)।

import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { Product } from './products';

export type Line = Product & { qty: number };
const initialState = { lines: [] as Line[] };

const cartSlice = createSlice({
  name: 'cart',
  initialState,
  reducers: {
    added(state, action: PayloadAction<Product>) {
      const line = state.lines.find((l) => l.id === action.payload.id);
      if (line) line.qty += 1;
      else state.lines.push({ ...action.payload, qty: 1 });
    },
    decremented(state, action: PayloadAction<string>) {
      const line = state.lines.find((l) => l.id === action.payload);
      if (!line) return;
      line.qty -= 1;
      if (line.qty === 0) state.lines = state.lines.filter((l) => l.id !== line.id);
    },
    cleared(state) {
      state.lines = [];
    },
  },
});

export const { added, decremented, cleared } = cartSlice.actions;
export const cartReducer = cartSlice.reducer;
import { configureStore } from '@reduxjs/toolkit';
import { cartReducer } from './cartSlice';

export const store = configureStore({ reducer: { cart: cartReducer } });

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
  • createSlice: একটি slice মানে state-এর একটি ভাগ। নাম, শুরুর মান আর reducer দিলে সে action creator নিজে বানিয়ে দেয়।
  • configureStore: Store বানায়, thunk যোগ করে, Redux DevTools-এর সংযোগ চালু করে, আর development-এ সাধারণ ভুল ধরার পরীক্ষা জুড়ে দেয়।
  • Provider: react-redux-এর এই component অ্যাপের ওপরে বসিয়ে store দিতে হয়। ভেতরে useSelector আর useDispatch দিয়ে পড়া আর পাঠানো হয়। React-Redux-ও ভেতরে useSyncExternalStore-ই ব্যবহার করে।

Immer: mutation-এর মতো লেখা, কিন্তু mutation নয়

line.qty += 1 লেখাটি reducer-এর নিয়ম ভাঙছে মনে হয়। এখানেই Immer. পর্ব ১০-এ আপনি spread দিয়ে নিজে immutable update লিখেছেন। Immer আপনাকে একটি draft দেয়, যা আসল state-এর একটি Proxy মোড়ক। আপনি যা লেখেন, Proxy তা ধরে রাখে, আর শেষে শুধু বদলানো অংশের নতুন কপি বানায়। নিচের ব্লকটি Immer-এর ধারণার শেখানোর মডেল, সম্পূর্ণ Immer নয়।

// শেখানোর মডেল: Immer-এর ধারণা, আসল Immer এর চেয়ে অনেক বেশি কিছু করে
function makeDraft(base, tellParent) {
  let copy = null;                         // প্রথম লেখার আগে কপি নেই
  const kids = new Map();
  const touch = () => {
    if (copy) return;
    copy = Array.isArray(base) ? [...base] : { ...base };
    tellParent();                          // ওপরের স্তরেও কপি লাগবে
  };
  const proxy = new Proxy(base, {
    get(_, key) {
      const value = (copy ?? base)[key];
      if (typeof value !== 'object' || value === null) return value;
      if (!kids.has(key)) kids.set(key, makeDraft(value, touch));
      return kids.get(key).proxy;
    },
    set(_, key, value) { touch(); copy[key] = value; return true; },
  });
  const finish = () => {
    if (!copy) return base;                // না বদলালে আগের object-ই
    for (const [key, kid] of kids) copy[key] = kid.finish();
    return copy;
  };
  return { proxy, finish };
}
function produce(base, recipe) {
  const d = makeDraft(base, () => {});
  recipe(d.proxy); return d.finish();
}
const before = { lines: [{ id: 'rice', qty: 1 }, { id: 'dal', qty: 2 }], coupon: { code: 'EID10' } };
const after = produce(before, (d) => { d.lines[0].qty += 1; });
console.log('qty:', before.lines[0].qty, '->', after.lines[0].qty);
console.log('root same?', before === after, '| lines same?', before.lines === after.lines);
console.log('rice same?', before.lines[0] === after.lines[0], '| dal same?', before.lines[1] === after.lines[1]);
console.log('coupon same?', before.coupon === after.coupon);

আউটপুট:

qty: 1 -> 2
root same? false | lines same? false
rice same? false | dal same? true
coupon same? true

before-এর চাল এখনো 1, অর্থাৎ পুরোনো state অক্ষত। বদলানো পথের তিনটি object নতুন: root, lines আর চালের লাইন। ডাল আর coupon আগের reference-ই রয়ে গেল। একে বলে structural sharing. এ জন্যই ডালের selector জানে তার কিছু বদলায়নি, আর selector-ভিত্তিক re-render ঠেকানো কাজ করে।

Node-এ চালিয়ে দেখা: thunk আর তিনটি ভুল

createAsyncThunk একটি async কাজের জন্য তিনটি action নিজে বানায়: pending, fulfilled আর rejected. Reducer pure থাকে, network-এর কাজ থাকে thunk-এর ভেতরে। Slice এগুলো extraReducers-এ ধরে। কুপন যাচাইয়ের ছোট উদাহরণ, যেখানে network নকল:

const fetchCoupon = createAsyncThunk('cart/fetchCoupon', async (code) => {
  await new Promise((r) => setTimeout(r, 10));   // নকল network
  if (code !== 'EID10') throw new Error('Invalid coupon');
  return { code, percent: 10 };
});

// createSlice-এর ভেতরে
extraReducers: (builder) => {
  builder
    .addCase(fetchCoupon.pending, (state) => { state.status = 'loading'; })
    .addCase(fetchCoupon.fulfilled, (state, action) => {
      state.status = 'idle';
      state.coupon = action.payload;
    })
    .addCase(fetchCoupon.rejected, (state, action) => {
      state.status = 'failed';
      state.coupon = null;
      console.log('   error:', action.error.message);
    });
},

এর সাথে একটি log middleware জুড়ে node demo.mjs চালানো হলো। সত্যিকারের run (Node 24.15.0, @reduxjs/toolkit 2.13.0, development mode; পরিষ্কার দেখাতে lines ছোট করা হয়েছে আর শেষের warning-এর লিংক বাদ):

$ node demo.mjs
{ type: 'cart/added', payload: { id: 'rice', price: 420 } }
cart/added => {"lines":[{"id":"rice","price":420,"qty":1}],"coupon":null,"status":"idle"}
cart/added => {"lines":[{"id":"rice","price":420,"qty":2}],"coupon":null,"status":"idle"}
…
state changed? true
cart/fetchCoupon/pending => {"lines":[…],"coupon":null,"status":"loading"}
cart/fetchCoupon/fulfilled => {"lines":[…],"coupon":{"code":"EID10","percent":10},"status":"idle"}
cart/fetchCoupon/pending => {"lines":[…],"coupon":{"code":"EID10","percent":10},"status":"loading"}
   error: Invalid coupon
cart/fetchCoupon/rejected => {"lines":[…],"coupon":null,"status":"failed"}
--- ভুল ১: reducer-এর বাইরে বদলানো
TypeError: Cannot assign to read only property 'qty' of object '#<Object>'
--- ভুল ২: draft বদলে return-ও
[Immer] An immer producer returned a new value *and* modified its draft. Either return a new value *or* modify the draft.
--- ভুল ৩: Date ঢোকানো
A non-serializable value was detected in an action, in the path: `payload.at`. Value: 1970-01-01T00:00:00.000Z
A non-serializable value was detected in the state, in the path: `cart.lines.2.at`. Value: 1970-01-01T00:00:00.000Z
  • ভুল ১: Immer state freeze করে রাখে। তাই store থেকে পাওয়া object-এ সরাসরি লিখলে error আসে।
  • ভুল ২: এক reducer-এ draft বদলানো অথবা নতুন মান ফেরত দেওয়া, যেকোনো একটি করা যায়। দুটো একসাথে নয়। state = x লিখলেও কাজ হয় না, কারণ তাতে শুধু local variable বদলায়।
  • ভুল ৩: State-এ শুধু serializable মান রাখুন। Date, Map, Promise, function রাখলে DevTools আর replay ঠিকমতো কাজ করে না। লক্ষ করুন, এটি শুধু সতর্কবার্তা, কাজ থামায় না। তারিখ হলে ISO লেখা রাখুন।

RTK Query: Redux Toolkit-এর সাথে এই ঐচ্ছিক অংশটি আসে। Server থেকে data আনা, cache, loading আর error সামলানোর কাজ এটি নিজে করে, আর hook বানিয়ে দেয়। পণ্যের তালিকার মতো server data-র জন্য নিজে thunk লেখার আগে এটি ভাবুন। একই সমস্যার আরেক সমাধান TanStack Query, যা পর্ব ১৯-এ বিস্তারিত দেখবেন। ধারণাটি একই: server state আর client state আলাদা।

৬ · Redux DevTools, time travel আর memoized selector

Redux-এর সবচেয়ে বড় উপহার তার খাতা। Redux DevTools একটি browser extension. configureStore এর সাথে সংযোগ নিজে থেকেই চালু করে। এই পর্বে extension চালিয়ে দেখানো হয়নি, তাই নিচে শুধু তার কাজের বর্ণনা:

  • Action list: প্রতিটি action ক্রম অনুযায়ী দেখায়, আর প্রতিটির পরের state আর পার্থক্য (diff).
  • Time travel: পুরোনো একটি action-এ ফিরে গেলে UI সেই সময়ের state দেখায়। আগের মডেলের replay-এর মতো ধারণা।
  • Export আর import: Action-এর তালিকা ফাইলে নিয়ে অন্য কম্পিউটারে আবার চালানো যায়।

Bug report-এ “ঝুড়ির দাম ভুল” শুনে আপনি action-এর তালিকা ধরে ঠিক সেই ধাপটি খুঁজে পান, যেখানে মান ভুল হয়েছে। State যদি ঝুড়ির ভেতরে যেকোনো জায়গা থেকে বদলানো যেত, এটি সম্ভব হতো না। ক্রিকেটের DRS রিপ্লের কথা ভাবুন: প্রতিটি বল রেকর্ড আছে বলেই ফিরে দেখা যায়।

Production-এ DevTools বন্ধ রাখার কথা ভাবুন। না হলে extension আছে এমন যে কেউ পুরো state আর action দেখতে পারেন। Vite-এ configureStore({ reducer, devTools: import.meta.env.DEV }) লেখা যায়। Store-এ এমনিতেই কোনো গোপন তথ্য রাখবেন না, কারণ browser-এর সবকিছু ব্যবহারকারীর হাতেই থাকে।

Derived data: store-এ নয়, selector-এ

ঝুড়ির মোট দাম state-এ রাখার দরকার নেই। lines থেকে সবসময় হিসাব করা যায়। এটি derived state. আলাদা রাখলে দুটি মান একদিন মিলবে না। Selector সংখ্যা ফেরালে কোনো সমস্যা নেই, কারণ সংখ্যা Object.is-এ সরাসরি মেলে। সমস্যা হয় যখন selector একটি নতুন array ফেরায়, যেমন filter. তখন memoized selector লাগে। Redux Toolkit এর জন্য createSelector দেয় (Reselect থেকে আসা)। মূল ধারণাটি নিচের শেখানোর মডেলে:

// শেখানোর মডেল: createSelector-এর ধারণা, শেষ ইনপুট আর শেষ ফল মনে রাখে
function createSelector(input, compute) {
  let hasRun = false, lastInput, lastResult;
  return (state) => {
    const value = input(state);
    if (hasRun && Object.is(value, lastInput)) return lastResult;
    hasRun = true;
    lastInput = value;
    lastResult = compute(value);
    return lastResult;
  };
}
let runs = 0;
const bigLines = (lines) => { runs += 1; return lines.filter((l) => l.qty >= 2); };
const selectPlain = (s) => s.lines.filter((l) => l.qty >= 2);
const selectMemo = createSelector((s) => s.lines, bigLines);

const s1 = { lines: [{ id: 'rice', qty: 2 }, { id: 'dal', qty: 1 }], drawerOpen: false };
const s2 = { ...s1, drawerOpen: true };                 // lines একই reference
const s3 = { ...s2, lines: [...s2.lines, { id: 'oil', qty: 3 }] };

console.log('plain, same state  :', selectPlain(s1) === selectPlain(s1));
const a = selectMemo(s1);
const b = selectMemo(s2);
console.log('memo, drawer change:', a === b, '| runs', runs);
const c = selectMemo(s3);
console.log('memo, lines change :', b === c, '| runs', runs, '| length', c.length);

আউটপুট:

plain, same state  : false
memo, drawer change: true | runs 1
memo, lines change : false | runs 2 | length 2

প্রথম লাইনে একই state, তবু দুটি আলাদা array. এ জন্যই সাধারণ filter selector প্রতিবার re-render ঘটায়। দ্বিতীয় লাইনে শুধু drawer বদলেছে, lines-এর reference একই, তাই হিসাব আর চলেনি (runs 1) আর একই array ফিরেছে। তৃতীয় লাইনে lines বদলেছে, তাই হিসাব আবার চলেছে। এই memo-র উপকার নির্ভর করে structural sharing-এর ওপর, যা গত অংশে দেখলেন।

৭ · Zustand — একটি hook-ই store

Zustand জার্মান শব্দ, অর্থ “অবস্থা”। Poimandres (pmndrs) নামের open-source দল ২০১৯ সালের দিকে এটি আনে। Action type নেই, reducer নেই, Provider নেই। লেখার সময়ে version 5.0.16. React 18 বা নতুন লাগে, কারণ এটি সরাসরি React-এর useSyncExternalStore ব্যবহার করে। Package-এর react.mjs ফাইলে create আর useStore মিলিয়ে মাত্র ২০ লাইনের মতো কোড, আর vanilla store-এর অংশটিও প্রায় ততটুকুই।

import { create } from 'zustand';
import type { Product } from './products';

export type Line = Product & { qty: number };
type Cart = {
  lines: Line[];
  add: (p: Product) => void;
  decrement: (id: string) => void;
  clear: () => void;
};

export const useCart = create<Cart>()((set) => ({
  lines: [],
  add: (p) =>
    set((s) => {
      const found = s.lines.some((l) => l.id === p.id);
      return {
        lines: found
          ? s.lines.map((l) => (l.id === p.id ? { ...l, qty: l.qty + 1 } : l))
          : [...s.lines, { ...p, qty: 1 }],
      };
    }),
  decrement: (id) =>
    set((s) => ({
      lines: s.lines
        .map((l) => (l.id === id ? { ...l, qty: l.qty - 1 } : l))
        .filter((l) => l.qty > 0),
    })),
  clear: () => set({ lines: [] }),
}));

export const selectCount = (s: Cart) => s.lines.reduce((sum, l) => sum + l.qty, 0);
export const selectTotal = (s: Cart) =>
  s.lines.reduce((sum, l) => sum + l.qty * l.price, 0);
  • create: একটি hook ফেরত দেয়, আর সেই hook-ই store. TypeScript-এ create<Cart>()(...), মাঝের ফাঁকা () সহ, এভাবেই লিখতে হয়।
  • set: ডিফল্টভাবে ওপরের স্তরে merge করে। তাই clear-এ শুধু lines দিলেই হয়। তবে nested object নিজে spread করতে হয়, Immer নেই। চাইলে zustand/middleware/immer যোগ করা যায়।
  • React-এর বাইরে: useCart.getState() আর useCart.setState() সাধারণ JavaScript থেকেই চলে।

persist: reload-এর পরও ঝুড়ি থাকুক

Reload দিলে module আবার চলে, তাই store আবার খালি হয়। persist একটি middleware, যা create-এর ভেতরের function-টিকে মুড়ে দেয়:

import { persist } from 'zustand/middleware';

export const useCart = create<Cart>()(persist((set) => ({
  // ... আগের মতোই
}), {
  name: 'bazarghar-cart',                     // localStorage-এর key
  version: 1,                                 // আকার বদলালে বাড়ান
  partialize: (s) => ({ lines: s.lines }),    // শুধু এটুকু জমা রাখুন
}));

এবার ঝুড়িতে চাল রেখে পাতা reload করা হলো। React ছাড়া Node-এ, একটি নকল localStorage দিয়ে persist চালিয়ে ভেতরের ক্রম ধরা হয়েছে। নিচের ধাপগুলো সেই real output আর package-এর source মিলিয়ে লেখা।

1const base = config(set, get, api);2onRehydrateStorage?.();3const saved = storage.getItem(name);4if (saved.version !== version) migrate();5set(merge(saved.state, base), true);6hasHydrated = true;7// পরে: প্রতিটি set-এর পর setItem
অবস্থা
lines
drawerOpen
saved.version
hasHydrated
  1. Module আবার চলল। Reload-এর পর আপনার function শুরুর state বানাল: lines ফাঁকা। localStorage-এ আগের ঝুড়ি আছে, চালের qty 1.
  2. Hydration শুরু। onRehydrateStorage দিলে সেটি এখানে ডাকা হয়। Real run-এ ঠিক এখানেই hydration শুরু ছাপা হয়েছে।
  3. Storage পড়া। getItem('bazarghar-cart') এই JSON আনে: {"state":{"lines":[…]},"version":1}. localStorage synchronous, তাই মান সঙ্গে সঙ্গেই পাওয়া যায়।
  4. Version মেলানো। জমা version 1, কোডের version-ও 1. সমান, তাই migrate লাগবে না।
  5. Merge. ডিফল্ট merge হলো { ...current, ...persisted }. lines এলো storage থেকে, drawerOpen আর function-গুলো এলো কোড থেকে। Function জমা থাকে না, JSON-এ তা সম্ভবও নয়।
  6. Hydration শেষ। hasHydrated এখন true. localStorage-এর ক্ষেত্রে এই সবই প্রথম render-এর আগেই ঘটে গেছে, তাই ব্যবহারকারী খালি ঝুড়ি দেখেন না।
  7. এরপর। ক্রেতা আবার চাল যোগ করলেন, qty 2. প্রতিটি set-এর পর partialize করা অংশ আবার লেখা হয়। drawerOpen কখনো জমা হয় না।

সত্যিকারের run (Zustand 5.0.16, Node 24.15.0; localStorage-এর জায়গায় নকল storage, কারণ Node-এ window নেই; version বদলালে কী হয় তাও চালিয়ে দেখা হয়েছে):

রিলোড: sync storage
  hydration শুরু
  storage.getItem bazarghar-cart
  hydration শেষ
  create-এর পরপরই [{"id":"rice","qty":1}] hasHydrated true
রিলোড: async storage
  hydration শুরু
  async getItem bazarghar-cart
  create-এর পরপরই [] hasHydrated false
  hydration শেষ
  একটু পরে [{"id":"rice","qty":1}] hasHydrated true
নতুন version: migrate
  hydration শুরু
  storage.getItem bazarghar-cart
  migrate from 1
  storage.setItem {"state":{"lines":[{"id":"rice","qty":1,"price":0}]},"version":2}
  hydration শেষ
  পরে [{"id":"rice","qty":1,"price":0}]
নতুন version: migrate নেই
  hydration শুরু
  storage.getItem bazarghar-cart
State loaded from storage couldn't be migrated since no migrate function was provided
  hydration শেষ
  পরে []

তিনটি শিক্ষা। Async storage হলে প্রথম render খালি থাকবে, তখন persist.hasHydrated() দেখে loading দেখাতে হয়। কোডের আকার বদলালে version বাড়ান আর migrate লিখুন। migrate না দিলে জমা ঝুড়ি বাদ পড়ে, আর শুধু একটি console বার্তা আসে।

নিরাপত্তা: localStorage ব্যবহারকারী নিজে বদলাতে পারেন, আর পাতার যেকোনো script পড়তে পারে। তাই জমা দামের ওপর ভরসা করবেন না। Order-এর সময় server-কে পণ্যের id নিয়ে নিজের database-এর দামে হিসাব করতে হবে। আর auth token এখানে রাখার আগে sessions ও tokens-এর আলোচনা পড়ুন।

Server rendering হলে সাবধান: Module-এর একটি store server-এ সব request মিলে ভাগ করে। এক ক্রেতার ঝুড়ি আরেকজনের পাতায় চলে যেতে পারে। Next.js-এ তাই request প্রতি আলাদা store লাগে। পর্ব ৫২ আর ৫৬-তে এই সীমারেখা দেখবেন। Redux-এও একই সতর্কতা।

Jotai: তৃতীয় একটি ধারা। একটি বড় store-এর বদলে অনেক ছোট atom, অনেকটা ছড়ানো useState যা সবাই ভাগ করে পড়তে পারে। Atom থেকে আরেক atom-ও বানানো যায়, তাই selector আলাদা করে লিখতে হয় না। Daishi Kato এর মূল লেখক, যিনি Zustand-এরও বর্তমান রক্ষণাবেক্ষণকারীদের একজন। লেখার সময়ে version 3.0.1. অনেক ছোট, পরস্পর-নির্ভর মান থাকলে এটি ভালো মানায়।

৮ · একই ঝুড়ি, দুই পথ — সত্যিকারের তুলনা

মতামত নয়, মাপ। create-vite 9.3.3-এর react-ts template থেকে তিনটি project বানানো হলো, React 19.3.1, Vite 8.3.5, TypeScript 7.0.2. তিনটিতেই হুবহু এক UI: তিনটি পণ্য, Badge, ProductList আর CartPanel, কাজ চারটি (যোগ, কমানো, খালি, মোট)। পার্থক্য শুধু state কোথায় থাকে।

# ~/bazarghar-context (Context + useReducer)
$ npm run build
dist/assets/index-CeHF0Snv.js  221.04 kB │ gzip: 69.19 kB

# ~/bazarghar-zustand
$ npm i zustand@5.0.16 && npm run build
dist/assets/index-tKTNV0nj.js  221.87 kB │ gzip: 69.59 kB

# ~/bazarghar-redux
$ npm i @reduxjs/toolkit@2.13.0 react-redux@9.3.1 && npm run build
dist/assets/index-JWH-oboj.js  243.73 kB │ gzip: 77.44 kB

# Zustand + persist
dist/assets/index-Dt_JA00Y.js  223.85 kB │ gzip: 70.43 kB
মাপ (একটি local run)ContextZustandRedux Toolkit
পুরো JS, gzip69.19 kB69.59 kB77.44 kB
Context-এর ওপরে বাড়তি, gzip—0.40 kB8.25 kB
পুরো JS, gzip ছাড়া221.04 kB221.87 kB243.73 kB
Build-এ module182127
নতুন runtime package0110 (type package সহ)
State-এর ফাইলে লাইন343140 (35 + 5)
মোট লাইন (ফাঁকা বাদে, tsx)848192
Provider লাগে?হ্যাঁনাহ্যাঁ
পাতা খোলা + ৫ কাজে ProductList render611
Action-এর খাতা, time travelনেইmiddleware দিয়েশুরু থেকেই

সংখ্যাগুলো ঠিকমতো পড়ুন। Bundle-এর ৬৯ kB-এর প্রায় সবটাই React আর React DOM. Redux Toolkit-এর ৮.২৫ kB-তে Immer, Redux, thunk, Reselect আর React-Redux আছে, তবে এটি এককালীন খরচ। Slice ৫০টি হলেও library-র এই অংশ আর বাড়ে না। ছোট ঝুড়িতে Redux-এর সব সুবিধা কাজে লাগে না, বড় দলের অ্যাপে লাগতে পারে। এই মাপ একটি ছোট demo-র, সাধারণ সিদ্ধান্ত নয়।

পরিস্থিতিযেদিকে ঝুঁকবেন
ছোট-মাঝারি অ্যাপ, ছোট দল, কম নিয়ম চাইZustand
বড় দল, অনেক feature, একই ধাঁচে লেখা জরুরিRedux Toolkit
“এই মান কেন বদলাল” জানা খুব জরুরি (জটিল ফর্ম, অর্থের হিসাব)Redux Toolkit, DevTools-এর জন্য
অনেক ছোট, পরস্পর-নির্ভর মানJotai
Theme, ভাষা, login করা ব্যবহারকারী, কম বদলায়Context
পণ্য, order, প্রোফাইল, যা server থেকে আসেTanStack Query বা RTK Query (পর্ব ১৯)

Community-তে এখানে মতভেদ আছে। কেউ বলেন Redux এখন বেশি ভারী, কেউ বলেন বড় অ্যাপে শৃঙ্খলা ছাড়া Zustand-এর store এলোমেলো হয়ে যায়। দুটোই সত্য হতে পারে। নতুন project-এ দলের অভ্যাস আর অ্যাপের আকার দেখে সিদ্ধান্ত নিন।

৯ · কখন কোনোটিই লাগে না, আর সাধারণ ভুল

এই পর্বের সবচেয়ে কাজের কথা হয়তো এটাই: global store না নেওয়াও একটি ভালো সিদ্ধান্ত। ২০১৬ সালে Dan Abramov নিজেই “You Might Not Need Redux” লিখেছিলেন। নতুন library নেওয়ার আগে নিচের ক্রমে প্রশ্ন করুন:

  • মানটি কি server থেকে আসে? তাহলে এটি server state. Cache library নিন, store-এ কপি রাখবেন না।
  • মানটি কি URL-এ মানায়? Search, filter, পাতা নম্বর URL-এ রাখলে link ভাগ করা যায়। পর্ব ২০-এ দেখবেন।
  • মানটি কি শুধু একটি component বা তার কাছাকাছি লাগে? useState আর পর্ব ১৬-এর lifting up-ই যথেষ্ট।
  • মানটি কি কম বদলায়? Context-ই যথেষ্ট।
  • মানটি অনেক দূরের component পড়ে আর ঘন ঘন বদলায়? এবার store নিন। বাজারঘরের ঝুড়ি ঠিক এই রকম।

সাধারণ ভুলগুলো এক জায়গায়:

ভুলফলঠিক পথ
পুরো store নেওয়াসব বদলে renderছোট selector
Selector-এ নতুন object বা arrayZustand 5-এ অসীম লুপ, Redux-এ বাড়তি renderuseShallow, createSelector
মোট দাম, সংখ্যা store-এ রাখাদুটি মান একদিন আলাদা হয়Selector-এ হিসাব
Server data store-এ কপি করাপুরোনো তথ্য, হাতে loading সামলানোQuery library
Form-এর প্রতিটি অক্ষর global store-এপ্রতি অক্ষরে অকারণ খরচLocal state (পর্ব ২১)
Reducer-এ network বা randomReplay ভেঙে যায়Thunk-এ রাখা, reducer pure
Date, Map, class state-এDevTools আর persist ভাঙেসরল JSON মান
Version না বাড়িয়ে persist-এর আকার বদলানোপুরোনো ঝুড়িতে অ্যাপ ভাঙতে পারেversion আর migrate

আরও গভীরে: Zustand-এর useStore আসলে useSyncExternalStore(api.subscribe, () => selector(api.getState()), () => selector(api.getInitialState())). তৃতীয় argument-টি hydration-এর সমস্যা এড়ায়: server আর প্রথম client render দুটোই শুরুর state দেখে, যদিও persist ততক্ষণে localStorage পড়ে ফেলেছে। React-Redux-এর useSelector ভেতরে useSyncExternalStoreWithSelector নামের একটি সহায়ক hook ব্যবহার করে, যা selector-এর ফল মনে রাখে। এই সব store-এ একটি action-এ সব selector চলে, তাই খরচ subscriber সংখ্যার সাথে রৈখিক (O(n)). সে জন্য selector ছোট রাখা জরুরি।

পরীক্ষা ও ইন্টারভিউতে: Context আর store-এর পার্থক্য? Context-এর মান বদলালে সব consumer render হয়, store-এ selector শুধু দরকারি জনকে render করায়। Redux-এর তিন নীতি? এক store, state শুধু পড়ার, pure reducer. Immer কী করে? Proxy draft-এ লেখা ধরে নতুন immutable state বানায়। useSyncExternalStore কেন? বাইরের store-এ নিরাপদে subscribe করে tearing ঠেকায়। Thunk কেন? Reducer pure রাখতে async কাজ তার বাইরে করে, শুরু আর শেষে action পাঠায়।

১০ · AI-এর যুগে store লেখা

Redux-এর বয়স দশ বছরের বেশি, তাই ইন্টারনেটে পুরোনো কোড অনেক। AI সেই পুরোনো ধাঁচ প্রায়ই ফিরিয়ে আনে। কোড চলেও যেতে পারে, তবু সেটি এখনকার পথ নাও হতে পারে।

AI-এর ভুলকীভাবে ধরবেন
হাতে লেখা switch, createStore, connectনতুন project-এ configureStore আর createSlice চান
import create from 'zustand'Version 5-এ default export নেই, build-ই আটকাবে
useCart(selector, shallow)Zustand 4-এর ধাঁচ। Version 5-এ useShallow
Selector-এ নতুন objectDev server-এ console পড়ুন
Token বা দাম persist-এ রাখা, ওপরে আস্থাকী জমা হচ্ছে DevTools-এর Application tab-এ দেখুন
ছোট অ্যাপেও দুটি store librarypackage.json-এর diff পড়ুন, প্রতিটি নতুন package-এর কারণ জিজ্ঞেস করুন
নিজে বানানো option, যা docs-এ নেইআপনার version-এর সরকারি docs খুলুন

AI-কে কোড লেখাতে গেলে version, সীমা আর যাচাই একসাথে বলে দিন:

আমি React 19, TypeScript আর Vite-এ একটি অনলাইন দোকান বানাচ্ছি। ঝুড়ির জন্য Zustand 5 ব্যবহার করব। ১. শুধু ঝুড়ির id, পরিমাণ আর নাম localStorage-এ রাখো, persist middleware-এর version আর partialize সহ। ২. মোট দাম store-এ রেখো না, selector দিয়ে বের করো। ৩. Selector-এ নতুন object ফেরাতে হলে useShallow ব্যবহার করো। ৪. নতুন কোনো package যোগ করার আগে কারণ বলো। ৫. কোডের পর বলো, কোন কোন component কখন re-render হবে, আর আমি কীভাবে সেটি মেপে দেখব। আমার জানা না থাকলে অনুমান কোরো না, প্রশ্ন করো।

AI-এর লেখা store হাতে পেলে

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১Flux কোন সমস্যা থেকে এলো? MVC-র দ্বিমুখী তীর বড় UI-তে কেন ঝামেলা করে?

২০১৪ সালে Facebook-এর প্রকৌশলীরা একটি talk-এ MVC-ধাঁচের দ্বিমুখী data flow নিয়ে সমস্যার কথা বলেন। প্রচলিত গল্প হলো chat-এর unread সংখ্যার একটি বাগ, যা বারবার ঠিক করার পরও ফিরে আসত। গল্পটির খুঁটিনাটি বিভিন্ন জায়গায় একটু আলাদা ভাবে বলা হয়, তবে মূল শিক্ষা স্পষ্ট।

ধরুন একটি বার্তা পড়া হলো। এতে message model বদলাল, যা thread model বদলাল, যা unread badge বদলাল। আবার badge ক্লিক করলে thread বদলায়। পর্ব ৪-এ দেখা MVC-তে প্রতিটি view কয়েকটি model-কে, আর প্রতিটি model কয়েকটি view-কে ছুঁতে পারে। মডেলের সংখ্যা বাড়লে সম্ভাব্য পথ গুণিতক হারে বাড়ে। একটি পরিবর্তন কোথা থেকে এলো, তা খুঁজে বের করা কঠিন হয়ে যায়। Cascading update-এর ক্রম আগে থেকে বলা যায় না।

Flux একটি সরল শর্ত দিল: data চলবে একদিকে। View সরাসরি store বদলাবে না, শুধু action পাঠাবে। সব action একটি dispatcher হয়ে store-এ যাবে, আর store-এর বদল view-তে আসবে। এতে নতুন কোনো ক্ষমতা আসেনি, বরং একটি ক্ষমতা কেড়ে নেওয়া হয়েছে। সরাসরি বদলানো যাবে না। ঠিক সেই কারণেই ক্রম অনুমানযোগ্য হলো।

Redux শর্তটি আরও কড়া করল। এক store, dispatcher নেই, আর reducer pure. এর ফলে time travel একটি “বিনা খরচের” উপহার হয়ে গেল। মজার ব্যাপার হলো, শুরুতে Redux বানানোই হয়েছিল সেই time travel দেখাতে। কড়াকড়ির দাম হলো বেশি কোড, যা পরে Redux Toolkit কমায়।

মূল উপলব্ধি: বড় সিস্টেমে স্বাধীনতার চেয়ে অনুমানযোগ্যতা বেশি দামি। সফটওয়্যারের অনেক ভালো নকশা আসলে একটি সীমা, যা ভুলের পথ বন্ধ করে দেয়।

প্র ০২একটি action-এ সব selector চলে। হাজারটি subscriber হলে এটি কি বড় সমস্যা?

আগে হিসাব, পরে মত। ধরে নিন একটি selector চলতে ১ মাইক্রোসেকেন্ড লাগে (অনুমিত সংখ্যা)। ১,০০০টি subscriber মানে প্রতি action-এ ১ মিলিসেকেন্ড। ৬০ FPS-এ একটি frame-এর হাতে থাকে প্রায় ১৬.৭ মিলিসেকেন্ড, অর্থাৎ এই কাজ বাজেটের ৬%. সাধারণ ক্ষেত্রে এটি কিছুই নয়।

সমস্যা শুরু হয় তিন জায়গায়। এক: selector ভারী হলে। ৫,০০০ পণ্যে filter আর sort প্রতিবার চালালে ওই ১ মাইক্রোসেকেন্ড আর থাকে না। তখন memoize করতে হয়। দুই: action অনেক ঘন ঘন এলে। ফর্মের প্রতি অক্ষর বা mouse move global store-এ পাঠালে সেকেন্ডে কয়েকশো action হতে পারে। তিন: selector নতুন reference ফেরালে। তখন শুধু selector নয়, render-এর আসল খরচও ফিরে আসে, আর সেটি অনেক বড়।

লক্ষ করুন, Context-এর সাথে তফাত কোথায়। Context-এ প্রতিটি consumer আবার render হয়, যা selector-এর চেয়ে অনেক ব্যয়বহুল। Store-এ সবার selector চলে, কিন্তু render হয় অল্প কয়েকজনের। অর্থাৎ O(n) কাজ এখানেও আছে, তবে তার ধ্রুবক ছোট।

আরও বড় অ্যাপে নকশাতেই এটি কমানো যায়। Redux-এর নথি বলে, বেশি component-কে store-এ যুক্ত করুন, প্রতিটি কম পড়ুক। যেমন তালিকা শুধু id পড়বে, আর প্রতিটি সারি নিজের পণ্য পড়বে। তখন এক সারি বদলালে শুধু সেই সারিটি render হবে। সারি অনেক হলে virtualization subscriber-এর সংখ্যাই কমিয়ে দেয়। Jotai-এর মতো atom-ভিত্তিক পথে খবর শুধু সেই atom-এর পাঠকের কাছে যায়, তাই সমস্যাটি অন্যভাবে এড়ানো যায়।

মূল উপলব্ধি: অনুমান না করে মাপুন। Profiler খুলে দেখুন সময় selector-এ যাচ্ছে, না render-এ। বেশিরভাগ ক্ষেত্রে সমস্যা selector চলা নয়, ভুল selector-এর জন্য অকারণ render.

প্র ০৩“Redux মরে গেছে” কথাটি কতটা ঠিক? এখন নতুন project-এ কি Redux শেখার দরকার আছে?

এই কথাটি কয়েক বছর পরপরই শোনা যায়, আর সবসময়ই আংশিক সত্য। React-এর শুরুর দিকে অনেক দল সবকিছু Redux-এ রাখত, এমনকি API-র data আর ফর্মের অক্ষরও। ফলে Redux হয়ে উঠেছিল প্রায় বাধ্যতামূলক, আর অনেকেই তার বাড়তি কোডে বিরক্ত ছিলেন। পরে তিনটি জিনিস বদলাল। Context আর hook এলো। TanStack Query আর SWR-এর মতো library server state-এর বড় অংশটি নিয়ে নিল। আর Zustand ও Jotai হালকা বিকল্প হিসেবে এলো।

ফলে Redux-এর “সবকিছুর জন্য” ব্যবহার সত্যিই কমেছে। কিন্তু তার মানে মৃত্যু নয়। বহু বড় অ্যাপ Redux-এ চলছে, আর সেগুলো রক্ষণাবেক্ষণের কাজ বহু বছর থাকবে। Redux Toolkit বাড়তি কোডের পুরোনো অভিযোগ অনেকটা মিটিয়েছে। আমাদের ঝুড়িতেই Redux-এর মোট লাইন ৯২, Zustand-এর ৮১, ফারাক খুব বড় নয়।

Redux-এর আসল শক্তি কড়াকড়ি। ত্রিশ জনের দলে প্রতিটি state পরিবর্তন একই ধাঁচে হলে নতুন সদস্য সহজে কোড পড়তে পারেন, আর action-এর log থেকে bug খোঁজা যায়। একা বা ছোট দলে সেই একই কড়াকড়ি বোঝা হতে পারে। তাই প্রশ্নটি “কোনটি ভালো” নয়, বরং “আপনার সমস্যায় কোন খরচ কম”।

শেখার দিক থেকে উত্তর সহজ। ধারণাগুলো শিখুন: action, reducer, selector, immutable update. useReducer, Zustand, এমনকি অন্য framework-এর store-এও এগুলোই ঘুরে ফিরে আসে। ইন্টারভিউয়ে Redux-এর প্রশ্ন এখনো আসতে পারে, আর পুরোনো project-এ কাজ করতে হলে এটি লাগবেই।

মূল উপলব্ধি: Tool-এর জনপ্রিয়তা ওঠানামা করে, ধারণা থেকে যায়। একটি library-র নাম নয়, তার পেছনের trade-off শিখুন।

প্র ০৪React-এর আগের subscription তো কাজ করত। তাহলে useSyncExternalStore বানাতে হলো কেন?

React 17 পর্যন্ত render ছিল একটানা। একবার শুরু হলে থামত না। তাই একটি render চলার মধ্যে JavaScript-এর অন্য কোনো কাজ চলার সুযোগই ছিল না। Store-এর মান একবার পড়লে পুরো render-এ সেটি একই থাকত।

React 18-এর concurrent rendering এই নিশ্চয়তা ভেঙে দিল। এখন time slicing-এ React render থামিয়ে browser-কে কাজের সুযোগ দেয়, তারপর আবার ফিরে আসে। ধরুন ১০০টি সারির একটি তালিকা render হচ্ছে। ৫০ নম্বর সারিতে React থামল। এই ফাঁকে একটি WebSocket বার্তায় store বদলে গেল। ৫১ নম্বর থেকে সারিগুলো নতুন মান পড়বে। ফল: একই পাতার অর্ধেক পুরোনো, অর্ধেক নতুন। ছিঁড়ে দুই ভাগ হওয়া ছবির মতো, তাই নাম tearing. React-এর নিজের state-এ এটি হয় না, কারণ React সেটি নিজে পরিচালনা করে। সমস্যা শুধু বাইরের store-এ।

React দল প্রথমে useMutableSource নামে একটি API চেষ্টা করে। অভিজ্ঞতার পর তারা আরও সরল পথ নেয়। useSyncExternalStore render-এ snapshot পড়ে। DOM বসানোর আগে আবার পড়ে মিলিয়ে দেখে। মান বদলে গেলে update-টি আবার শুরু করে, এবার blocking হিসেবে। অর্থাৎ নামের “sync” শব্দটি অর্থপূর্ণ: নিরাপত্তার বদলে এই update-এ concurrent সুবিধা ছেড়ে দেওয়া হয়।

এর একটি বাস্তব ফল আছে। Transition-এর ভেতরে store-এর update হলেও সেটি আলাদা ভাবে থামানো যায় না, যেমন useState-এ যায়। ভারী তালিকায় এটি টের পাওয়া যেতে পারে। সেখানে useDeferredValue দিয়ে দেখানোর অংশটি পিছিয়ে দেওয়ার পথ আছে, যা পর্ব ১৪-এ দেখেছেন।

মূল উপলব্ধি: একটি platform নতুন ক্ষমতা আনলে পুরোনো অনুমান ভেঙে যায়। ভালো API সেই ভাঙনটি সবার জন্য এক জায়গায় সারিয়ে দেয়, আর তার দাম (এখানে কিছুটা concurrency) খোলাখুলি জানায়।

প্র ০৫ঝুড়ি কি localStorage-এ থাকবে, না server-এ? নকশার সিদ্ধান্তটি কীভাবে নেবেন?

এটি নিছক কারিগরি নয়, পণ্যের প্রশ্নও। বাজারঘরের দুই ধরনের ক্রেতা ভাবুন: যিনি login করেননি, আর যিনি ফোনে বেছে ল্যাপটপে কিনবেন।

localStorage-এর পক্ষে: কোনো network লাগে না, তাই “যোগ” চাপলে সঙ্গে সঙ্গে সংখ্যা বাড়ে। দুর্বল সংযোগে এটি বড় সুবিধা। Login ছাড়াই চলে, server-এ কোনো খরচ নেই।

Server-এর পক্ষে: এক ব্যবহারকারীর সব ডিভাইসে একই ঝুড়ি থাকে। মজুত আর দাম সবসময় সঠিক থাকে। “ঝুড়ি ফেলে যাওয়া” ক্রেতাকে মনে করিয়ে দেওয়া যায়। Browser মুছলেও ঝুড়ি হারায় না।

বাস্তবে অনেক দোকান দুটোই করে। Login-এর আগে ঝুড়ি থাকে local. Login করলে local ঝুড়ি server-এর ঝুড়ির সাথে merge হয়, আর তখন একই পণ্য দুই জায়গায় থাকলে কী হবে, তার নিয়ম ঠিক করতে হয়: পরিমাণ যোগ হবে, না বড়টি থাকবে? এরপর server-ই সত্যের উৎস, আর client শুধু optimistic ভাবে আগে দেখায় (পর্ব ১৯)।

যেভাবেই করুন, একটি নিয়ম ভাঙা যাবে না। Client-এর দামে কখনো ভরসা নয়। ব্যবহারকারী DevTools খুলে ৪২০ টাকাকে ৪ টাকা করতে পারেন। তাই localStorage-এ দাম রাখলেও সেটি শুধু দেখানোর জন্য। Order-এর সময় server id আর পরিমাণ নিয়ে নিজের database থেকে দাম পড়বে, আর মজুত আবার যাচাই করবে। পর্ব ৪৩-এ দেখবেন, একই পণ্য দুজন একসাথে কিনলে কী হয়।

আরেকটি কম আলোচিত খরচ হলো পুরোনো ডেটা। ক্রেতা তিন মাস আগে ঝুড়িতে যে পণ্য রেখেছিলেন, সেটি হয়তো আর নেই। তাই পাতা খোলার সময় id দিয়ে server থেকে দাম আর প্রাপ্যতা আবার মিলিয়ে নিতে হয়। আর persist-এর version ঠিকমতো না বাড়ালে পুরোনো আকারের ডেটা নতুন কোডে ঢুকে পড়ে।

মূল উপলব্ধি: “কোথায় রাখব” প্রশ্নের উত্তর দেয় ব্যবহারকারীর যাত্রা আর আস্থার সীমা। Client দেখাতে পারে দ্রুত, কিন্তু চূড়ান্ত সত্য থাকে server-এ।

অনুশীলন

হিসাব, কোড পড়া আর নকশা। উত্তর দেখার আগে নিজে চেষ্টা করুন।

  1. Bundle-এর হিসাব: একটি build-এ gzip আকার Context দিয়ে 69.19 kB, Zustand দিয়ে 69.59 kB আর Redux Toolkit দিয়ে 77.44 kB. ধরুন সংযোগের গতি 1 Mbps. (ক) Context-এর সাথে তুলনায় দুটির বাড়তি নামানোর সময় কত মিলিসেকেন্ড? (খ) বাড়তি আকার কত শতাংশ? লেটেন্সি হিসাব বাদ দিন।

    1 Mbps = 1000 kbit/s, আর 1 kB = 8 kbit.

    (ক) Zustand: 69.59 − 69.19 = 0.40 kB = 3.2 kbit. সময় = 3.2 ÷ 1000 = 0.0032 s = 3.2 ms.

    Redux Toolkit: 77.44 − 69.19 = 8.25 kB = 66 kbit. সময় = 66 ÷ 1000 = 0.066 s = 66 ms.

    (খ) 0.40 ÷ 69.19 ≈ 0.58%, আর 8.25 ÷ 69.19 ≈ 11.9%.

    পুরো 69.19 kB নামাতে লাগে 553.5 ms. অর্থাৎ বড় অংশটি React নিজেই। ধীর সংযোগে ৬৬ ms তুচ্ছ নয়, তবে সিদ্ধান্তের একমাত্র ভিত্তিও নয়। JS parse আর চলার সময় এখানে ধরা হয়নি।

  2. ফল অনুমান করুন: একটি reducer-এ added একটি পণ্যের qty ১ বাড়ায় (না থাকলে qty: 1 দিয়ে যোগ করে), আর decremented ১ কমায় (০ হলে লাইনটি বাদ দেয়)। চাল ৪২০ টাকা, ডাল ১৩৫ টাকা। ক্রমে পাঠানো হলো: চাল যোগ, চাল যোগ, ডাল যোগ, চাল কমানো, ডাল কমানো, চাল যোগ। শেষে lines, মোট সংখ্যা আর মোট দাম কত? ৫ নম্বর action-এর পরের state কী ছিল?
    1. চাল যোগ: চাল 1
    2. চাল যোগ: চাল 2
    3. ডাল যোগ: চাল 2, ডাল 1
    4. চাল কমানো: চাল 1, ডাল 1
    5. ডাল কমানো: ডাল 0 হলো, তাই লাইন বাদ। চাল 1
    6. চাল যোগ: চাল 2

    শেষে lines = শুধু চাল, qty 2. মোট সংখ্যা 2, মোট দাম 2 × 420 = 840 টাকা। ৫ নম্বর action-এর পরে ছিল শুধু চাল, qty 1, দাম 420. Time travel-এ ঠিক এই ধাপে ফিরে যাওয়া যায়।

  3. Code review: একজন সহকর্মী লিখলেন: useCart-এর store-এ lines, totalPrice, products (server থেকে আনা), searchText আর isLoggedIn আছে। Header-এ const store = useCart();. আর persist পুরো store জমা রাখে, যার মধ্যে একটি authToken-ও আছে। অন্তত পাঁচটি সমস্যা ধরুন, আর ঠিক করার পথ বলুন।
    • পুরো store নেওয়া: searchText-এর প্রতি অক্ষরে Header render হবে। ছোট selector লাগবে।
    • totalPrice: derived data. বাদ দিয়ে selector-এ হিসাব।
    • products: server state. TanStack Query বা RTK Query-তে যাবে।
    • searchText: URL-এ রাখলে link ভাগ করা যায়, অথবা সেই component-এর local state.
    • authToken persist-এ: localStorage যেকোনো script পড়তে পারে, XSS-এ চুরি হতে পারে। partialize দিয়ে শুধু lines রাখুন, আর token-এর নিয়ম পর্ব ৪৫–৪৬ অনুযায়ী ঠিক করুন।
    • version নেই: আকার বদলালে পুরোনো ডেটা সমস্যা করবে।
  4. নিজে মেপে দেখুন: npm create vite@latest দিয়ে একটি react-ts project বানান। zustand যোগ করে এই পর্বের store আর তিনটি component লিখুন। প্রতিটি component-এর শুরুতে একটি render counter রাখুন। আগে selector দিয়ে, পরে const { add } = useCart() দিয়ে চালান। npm run build-এর gzip আকারও লিখে রাখুন। আপনার ফল কি এই পর্বের ৬, ১, ৬ আর ৬, ৬, ৬-এর সাথে মেলে?

    Dev server-এ main.tsx-এর StrictMode render দ্বিগুণ করে। তাই গোনার জন্য production build (npm run build, npm run preview) চালান, অথবা গোনার সময় StrictMode সরান।

    পাতা খোলায় তিনটি component-এর ১টি করে render হয়। পাঁচটি কাজের প্রতিটিতে lines বদলায়, তাই Badge আর CartPanel ৫ বার করে বাড়বে: মোট ৬। Selector-সহ ProductList ১-এই থাকবে। const { add } = useCart() দিলে সেটিও ৬ হবে। Version আলাদা হলে bundle-এর আকার আলাদা আসবে, সেটি স্বাভাবিক।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমRedux Toolkit অ্যাপে একটি ক্লিকের পথ সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 🖱️ Component dispatch(added(item)) ডাকে
  2. 📄 Action creator একটি সাধারণ action object বানায়
  3. 🛂 Middleware action-টি পরীক্ষা করে সামনে পাঠায়
  4. 🧮 Reducer আগের state থেকে নতুন state বানায়
  5. 📒 Store নতুন state রেখে subscriber-দের খবর দেয়
  6. 🔎 Selector মান মেলায়, বদলালে component আবার render হয়

React-এর Context-এর তুলনায় selector-সহ একটি বাইরের store-এর প্রধান সুবিধা কী?

  • State reload-এর পরও নিজে থেকে থাকে
  • শুধু যে component-এর পড়া অংশ বদলেছে, সেটিই আবার render হয়
  • Store-এ React-এর render একেবারেই লাগে না
  • Server rendering-এ store বেশি নিরাপদ

Zustand 5-এ useCart((s) => ({ lines: s.lines })) লিখলে অ্যাপ Maximum update depth exceeded error দিল। মূল কারণ কী?

  • lines নামটি Zustand-এ সংরক্ষিত
  • Selector store-এর state বদলে দিচ্ছে
  • Selector প্রতিবার নতুন object ফেরায়, তাই snapshot কখনো একই থাকে না
  • Provider দেওয়া হয়নি

Redux-এর reducer-কে pure রাখার সবচেয়ে সরাসরি লাভ কোনটি?

  • Action-এর তালিকা আবার চালালে একই state ফেরে, তাই time travel আর test সম্ভব হয়
  • Bundle-এর আকার ছোট হয়
  • Reducer সরাসরি API ডাকতে পারে
  • Selector আর লাগে না

Immer ব্যবহার করা একটি reducer-এ state.count = 5; return { count: 9 }; লেখা হলো। কী হবে?

  • State হবে count: 5
  • State হবে count: 9
  • State হবে count: 14
  • Immer error দেবে, কারণ draft বদলে নতুন মানও ফেরানো হয়েছে

Zustand-এর persist-এ partialize: (s) => ({ lines: s.lines }) দেওয়ার কাজ কী?

  • Component শুধু lines পড়তে পারবে
  • Storage-এ শুধু lines জমা হবে, store-এর বাকি অংশ নয়
  • পুরোনো version-এর ডেটা নতুন আকারে আনবে
  • Hydration বন্ধ হয়ে যাবে

একটি শপের ঝুড়িতে lines আছে। মোট দাম কোথায় রাখা সবচেয়ে ভালো?

  • Store-এ আলাদা totalPrice field-এ, প্রতি action-এ আপডেট করে
  • useEffect দিয়ে হিসাব করে useState-এ
  • lines থেকে selector-এ হিসাব করে
  • localStorage-এ, order-এর সময় সেখান থেকেই পড়ে

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Store থাকে React-এর বাইরে, আর useSyncExternalStore দুই জগৎকে নিরাপদে জোড়ে।
  2. Selector ছোট রাখুন। পুরো store নেওয়া আর নতুন object ফেরানো দুটোই সুবিধা নষ্ট করে।
  3. Redux Toolkit: action → middleware → reducer → store → selector. Immer আর DevTools এর বড় সহায়।
  4. Zustand: hook-ই store, Provider নেই। persist-এ partialize আর version ভুলবেন না।
  5. Derived data store-এ নয়, server data query library-তে। অনেক অ্যাপে global store লাগেই না।

পরের পর্বে: ঝুড়ি client-এর জিনিস, কিন্তু পণ্যের তালিকা নয়। পর্ব ১৯-এ দেখবেন server থেকে আসা data-র নিজের সমস্যা: cache, পুরোনো তথ্য, mutation আর optimistic update, TanStack Query দিয়ে।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান