تغییر state به صورت immutable (ذخیره عدد شمارنده)

?How to Change State to Immutable

22 بهمن 1399
تغییر state به صورت immutable (ذخیره ی عدد شمارنده)

در این قسمت می خواهیم به state خود رسیدگی کنیم و نکات مهمی از آن را به شما گوشزد کنیم. ابتدا به فایل reducer.js بروید و یک state جدید را اضافه کنید:

const initialState = {
    counter: 0,
    results: []
}

این state در ابتدا یک کد آرایه خالی است. سپس به کامپوننت Counter.js رفته و یک دکمه معمولی به آن اضافه کنید:

return (
    <div>
        <CounterOutput value={this.props.ctr} />
        <CounterControl label="Increment" clicked={this.props.onIncrementCounter} />
        <CounterControl label="Decrement" clicked={this.props.onDecrementCounter} />
        <CounterControl label="Add 10" clicked={this.props.onAddCounter} />
        <CounterControl label="Subtract 15" clicked={this.props.onSubtractCounter} />
        <hr />
        <button>Store Result</button>
    </div>
);

قبل از اضافه کردن دکمه عادی از یک تگ <hr> استفاده کرده ام تا یک خط افقی ایجاد شود و دکمه ما از دیگر دکمه ها جدا باشد. این دکمه قرار است عدد فعلی شمارنده را در یک <ul> در صفحه ذخیره کند بنابراین باید این <ul> را نیز به صفحه اضافه کنیم:

return (
    <div>
        <CounterOutput value={this.props.ctr} />
        <CounterControl label="Increment" clicked={this.props.onIncrementCounter} />
        <CounterControl label="Decrement" clicked={this.props.onDecrementCounter} />
        <CounterControl label="Add 10" clicked={this.props.onAddCounter} />
        <CounterControl label="Subtract 15" clicked={this.props.onSubtractCounter} />
        <hr />
        <button>Store Result</button>
        <ul>
            <li></li>
        </ul>
    </div>
);

هدف ما این است که یک action برای این دکمه dispatch شود که در reducer نتیجه را به آرایه results اضافه کند. از طرفی زمانی که روی یکی از <li> ها کلیک می کنم می خواهم همان <li> حذف شود بنابراین نیاز به نوعی id نیز داریم.

برای شروع باید دو prop دیگر را به mapDispatchToProps اضافه می کنیم چرا که به دو کار دیگر نیاز دارم:

const mapDispatchToProps = dispatch => {
    return {
        onIncrementCounter: () => dispatch({ type: 'INCREMENT' }),
        onDecrementCounter: () => dispatch({ type: 'DECREMENT' }),
        onAddCounter: () => dispatch({ type: 'ADD', val: 10 }),
        onSubtractCounter: () => dispatch({ type: 'SUBTRACT', val: 15 }),
        onStoreResult: () => dispatch({ type: 'STORE_RESULT' }),
        onDeleteResult: () => dispatch({ type: 'DELETE_RESULT' })
    };
};

احتمالا برایتان سوال شده باشد که چرا مقدار شمارنده را در onStoreResult به همراه type ارسال نکرده ام. دلیلش این است که نیازی به این کار نیست! مقدار شمارنده به عنوان بخشی از state برنامه ما در reducer.js موجود است و نیازی نیست آن را دوباره به reducer.js ارسال کنیم. حالا به قسمت JSX می رویم تا این دو prop را به عناصر متناسب خود متصل کنیم:

return (
    <div>
        <CounterOutput value={this.props.ctr} />
        <CounterControl label="Increment" clicked={this.props.onIncrementCounter} />
        <CounterControl label="Decrement" clicked={this.props.onDecrementCounter} />
        <CounterControl label="Add 10" clicked={this.props.onAddCounter} />
        <CounterControl label="Subtract 15" clicked={this.props.onSubtractCounter} />
        <hr />
        <button onClick={this.props.onStoreResult}>Store Result</button>
        <ul>
            <li onClick={this.props.onDeleteResult}></li>
        </ul>
    </div>
);

فعلا <li> هیچ محتوایی ندارد و آن را به عنوان قالب کلی نوشته ایم، بعدا به صورت پویا یک لیست از این <li> ها ایجاد خواهیم کرد. فعلا می خواهیم روی button خود تمرکز کنیم. اگر کدها را ذخیره کرده و به مرورگر بروید و روی دکمه Store Result کلیک کنید، مطابق با انتظار ما هیچ اتفاقی نمی افتد اما هیچ خطایی نیز دریافت نمی کنیم و این مسئله مهمی است! چرا؟ این مسئله به ما می گوید که ما می توانیم Action هایی را dispatch کنیم (ارسال کنیم) که هیچگاه در reducer دریافت نمی شوند و این مسئله باعث خطا نخواهد شد.

آیا می دانید چطور خطایی اتفاق نمی افتد؟ زمانی که action ما به reducer می رسد به دستور switch برخورد می کند اما از آنجایی که با هیچ کدام از case ها تطابق ندارد، به حالت پیش فرض return state پس از دستور switch می رسد و state فعلی را بر می گرداند. به همین خاطر است که با کلیک روی این دکمه، شمارنده تغییری نمی کند.

برای رفع این مشکل باید یک Case جدید در دستور switch اضافه کنیم و در آن نسخه ای immutable از state را برگردانیم. اگر یادتان باشد در جلسه قبل گفتیم از آنجایی که شمارنده (counter) تنها عضو state است می توانیم مستقیما یک State جدید را برگردانیم تا جایگزین State اول شود اما دیگر counter تنها عضو state نیست. به نظر شما مشکل کجاست؟ حالا که sate به شکل زیر است:

const initialState = {
    counter: 0,
    results: []
}

اگر درون case ها فقط شیء ای را برگردانیم که دارای counter باشد (مثلا تمام عملیات های INCREMENT و DECREMENT و ... این کار را می کنند):

    switch (action.type) {
        case 'INCREMENT':
            return {
                counter: state.counter + 1
            }
        case 'DECREMENT':
            return {
                counter: state.counter - 1
            }
        case 'ADD':
            return {
                counter: state.counter + action.val
            }
        case 'SUBTRACT':
            return {
                counter: state.counter - action.val
            }
    }

عضو دیگر state که همان آرایه results است حذف می شود! توجه داشته باشید که برخلاف دستور setState که شیء state برگردانده شده را با شیء state قبلی ادغام می کرد، اینجا شیء برگرانده شده جایگزین شیء قبلی می شود (یعنی ما state جدید را برمی گردانیم). به همین دلیل تمام دستورات درون switch باعث حذف شدن results می شوند. اینجاست که مجبور هستیم state قبلی را کپی کنیم.

معمولا همه سریعا از روش زیر استفاده می کنند که یک روش کاملا غلط است:

case 'INCREMENT':
        const newState = state;
        newState.counter = state.counter + 1;
        return newState;

چرا این روش غلط است؟ به دلیل اینکه در این روش ما state قدیمی را mutate می کنیم ولی قرار شد هیچ گاه در react چنین کاری نکنیم و در فصل های اول این سری توضیح دادیم که چنین کاری چه مشکلاتی را به وجود می آورد. ما می خواهیم state همیشه به صورت immutable تغییر پیدا کند.

یک راه حل ساده استفاده از Object.assign است:

case 'INCREMENT':
        const newState = Object.assign({}, state);
        newState.counter = state.counter + 1;
        return newState;

با استفاده از Object.assign از شیء state یک کپی گرفته و آن را درون یک شیء خالی (پارامتر اول) می ریزیم بنابراین state را به صورت immutable تغییر داده ایم چرا که newState یک شیء کاملا جدید و مستقل از state خواهد بود. البته باید توجه داشته باشید که Object.assign شیء state را deep clone نمی کند، یعنی با اینکه آرایه results درون state است اما آن را تغییر نخواهد داد و محتوای آن مستقلا کپی نمی شود.

روش خلاصه تر این کار که روش پیشنهادی من است و در طول دوره از آن استفاده می کنیم، استفاده از اپراتور spread است:

case 'INCREMENT':
    return {
      ...state,
      counter: state.counter + 1
    }

وقتی از state... استفاده می کنیم، تمام خصوصیات و مقدار (key/value) های شیء state کپی شده و سپس درون این شیء جدید که return می کنیم قرار داده می شوند، یعنی یک counter و یک results. در خط بعد از این دستور گفته ایم counter را یک واحد اضافه کن. حالا از آنجایی که با دستور state... یک counter در این شیء قرار گرفته است و ما یک counter دیگر نیز تعریف کرده ایم، Counter ما جایگزین counter قبلی می شود و بدین شکل state به صورت immutable بروز رسانی خواهد شد. واضح است که در این کد results را تغییر نخواهیم داد.

پس همین کار را برای همه انجام می دهیم:

const reducer = (state = initialState, action) => {
    switch (action.type) {
        case 'INCREMENT':
            const newState = Object.assign({}, state);
            newState.counter = state.counter + 1;
            return newState;
        // return {
        //     ...state,
        //     counter: state.counter + 1
        // }
        case 'DECREMENT':
            return {
                ...state,
                counter: state.counter - 1
            }
        case 'ADD':
            return {
                ...state,
                counter: state.counter + action.val
            }
        case 'SUBTRACT':
            return {
                ...state,
                counter: state.counter - action.val
            }
        case 'STORE_RESULT':
            return {
                ...state,
                results: state.results.concat(state.counter)
            }
    }

نکات کد بالا:

  • من مورد case اول را با همان روش طولانی تر تغییر دادم تا بدانید آن روش هم موجود است.
  • من action دکمه Store Result را نیز در آخر اضافه کرده ام.
  • برای اضافه کردن یک آیتم جدید به آرایه ها دو روش رایج وجود دارد: یکی استفاده از push و دیگری استفاده از concat. اگر از push استفاده کنید، آرایه اصلی ویرایش شده و تغییر می کند (که می شود تغییر غیر immutable) و اگر از concat استفاده کنید مقدار جدید شما با آرایه قبلی جمع شده و یک آرایه کاملا جدید ساخته و برگردانده می شود که همان حالت immutable خواهد بود بنابراین حتما از concat استفاده کنید.

حالا به Counter.js برمی گردیم و در mapStateToProps این state جدید را نیز اضافه می کنیم:

const mapStateToProps = state => {
    return {
        ctr: state.counter,
        storedResults: state.results
    };
};

حالا می توانیم با استفاده از storedResults مجموعه <li> های خود را به صورت پویا ایجاد کنیم. این <li> ها به دو مورد نیاز داشتند:

  • عدد فعلی شمارنده
  • یک id خاص و یکتا برای شناسایی و حذف

 ما هنوز این id را تعریف نکرده ایم، بنابراین بهتر است به reducer برگردیم و علاوه بر عدد فعلی شمارنده یک id خاص را نیز تعریف کنیم:

case 'STORE_RESULT':
        return {
            ...state,
            results: state.results.concat({ id: new Date(), value: state.counter })
        }

از آنجایی که هیچ id خاصی نداریم من از Date استفاده کرده ام که زمان فعلی را به ما می دهد (زمان دقیق کلیک روی دکمه و ارسال action آن). از آنجایی که این زمان تا میلی ثانیه نیز حساب می شود مطمئن هستم که برای هر کلیک یک مقدار یکتا خواهد بود.

حالا به Counter.js می رویم و <li> ها را به صورت پویا ایجاد می کنیم:

render() {
    return (
        <div>
            <CounterOutput value={this.props.ctr} />
            <CounterControl label="Increment" clicked={this.props.onIncrementCounter} />
            <CounterControl label="Decrement" clicked={this.props.onDecrementCounter} />
            <CounterControl label="Add 10" clicked={this.props.onAddCounter} />
            <CounterControl label="Subtract 15" clicked={this.props.onSubtractCounter} />
            <hr />
            <button onClick={this.props.onStoreResult}>Store Result</button>
            <ul>
                {this.props.storedResults.map(strResult => (
                    <li key={strResult.id} onClick={this.props.onDeleteResult}>{strResult.value}</li>
                ))}
            </ul>
        </div>
    );
}

قبلا استفاده از تابع map را یاد گرفته ایم و می دانیم که روی تک تک اعضای آرایه storedResults اجرا شده و برای هر کدام از آن ها یک <li> با مشخصات بالا می سازد. حالا اگر به مرورگر بروید کدهای ما به طور صحیح کار می کنند و با هر کلیک روی button عدد فعلی شمارنده در یک <li> جدید ذخیره می شود.

کار باقی مانده ما پیاده سازی مکانیسم حذف <li> ها است که در قسمت بعد آن را انجام خواهیم داد.

تمام فصل‌های سری ترتیبی که روکسو برای مطالعه‌ی دروس سری دوره جامع آموزش ری اکت توصیه می‌کند:
نویسنده شوید
دیدگاه‌های شما

در این قسمت، به پرسش‌های تخصصی شما درباره‌ی محتوای مقاله پاسخ داده نمی‌شود. سوالات خود را اینجا بپرسید.