به روز رسانی آرایه‌ها در State

آرایه‌ها در جاوا اسکریپت mutable (قابل تغییر مستقیم) هستند، اما توصیه می‌شود هنگامی که آنها را در state ذخیره می‌کنید، با آنها به شکل غیرقابل تغییر برخورد کنید. درست مانند object ها، هنگامی که می‌خواهید یک آرایه را در state ذخیره کنید، لازم است که یک آرایه جدید ساخته (یا یک آرایه موجود را کپی کنید)، و سپس state را ست کنید تا از آرایه جدید استفاده کنید.

You will learn

  • چگونه با state ری‌اکت به آرایه آیتم اضافه کرده، آیتم‌های موجود را حذف کرده یا تغییر دهیم
  • چگونه یک object داخل یک آرایه را به روز رسانی کنیم
  • چگونه به کمک Immer هنگام کپی کردن آرایه از تکرار زیاد جلوگیری کنیم

به روز رسانی آرایه‌ها

در جاوا اسکریپت، آرایه‌ها تنها یک نوع دیگری از object هستند. مانند آبجکت‌ها، شما باید با آرایه‌ها به شکل read-only رفتار کنید. این یعنی شما نباید آیتم‌های آرایه را به شکل arr[0] = 'bird' مقدار دهی مجدد کنید، و همچنین نباید از متد‌هایی که آرایه را mutate می‌کنند، همچون push() و pop() استفاده کنید.

در عوض، هر بار که می‌خواهید یک آرایه را به روز رسانی کنید، باید یک آرایه جدید را به تابع ست state خود بدهید. برای انجام این عمل، شما می‌توانید با فراخوانی متد‌های non-mutating همچون filter() و map() یک آرایه جدید از آرایه اصلی بسازید. سپس، می‌توانید state را با آرایه حاصل جدید ست کنید.

در اینجا یک جدول مرجع از عملیات‌های رایج بر روی آرایه‌ها را مشاهده می‌کنید. در هنگام کار کردن با آرایه‌ها در state ری‌اکت، باید از استفاده از متد‌های ستون سمت راست خودداری کرده، و در عوض از متد‌های ستون سمت چپ استفاده کنید:

خودداری شود (آرایه را mutate می‌کند)ترجیح داده شود (آرایه جدید ایجاد می‌کند)
اضافه کردنpush, unshiftconcat, [...arr] spread syntax (example)
حذف کردنpop, shift, splicefilter, slice (example)
جایگزین کردنsplice, arr[i] = ... assignmentmap (example)
مرتب سازیreverse, sortcopy the array first (example)

به عنوان یک جایگزین، شما می‌توانید از Immer استفاده کنید که به شما امکان استفاده از متد‌های هر دو ستون را می‌دهد.

Pitfall

متاسفانه، slice و splice با وجود نام‌گذاری مشابه تفاوت زیادی با یکدیگر دارند:

  • slice به شما اجازه می‌دهد یک آرایه یا قسمتی از آن را کپی کنید.
  • splice آرایه را mutate می‌کند (جهت افزودن یا حذف کردن آیتم‌ها).

در ری‌اکت، شما از slice (بدون حرف p!) بسیار بیشتر استفاده می‌کنید چرا که شما نمی‌خواهید object ها یا آرایه‌های در state را mutate کنید. به روز رسانی object ها توضیح می‌دهد که mutation چیست و چرا برای state توصیه نمی‌شود.

افزودن به آرایه

متد push() آرایه را mutate می‌کند، چیزی که شما آن را نمی‌خواهید:

import { useState } from 'react';

let nextId = 0;

export default function List() {
  const [name, setName] = useState('');
  const [artists, setArtists] = useState([]);

  return (
    <>
      <h1>Inspiring sculptors:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={() => {
        artists.push({
          id: nextId++,
          name: name,
        });
      }}>Add</button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

در عوض، باید یک آرایه جدید ایجاد کنید که شامل آیتم‌های فعلی آرایه و یک آیتم جدید در انتها است. برای انجام این عمل، چندین راه وجود دارد، اما آسان ترین راه این است که از سینتکس ... array spread (پخش آرایه) استفاده کنید:

setArtists( // Replace the state
[ // with a new array
...artists, // that contains all the old items
{ id: nextId++, name: name } // and one new item at the end
]
);

حال، کد به طرز صحیح عمل می‌کند:

import { useState } from 'react';

let nextId = 0;

export default function List() {
  const [name, setName] = useState('');
  const [artists, setArtists] = useState([]);

  return (
    <>
      <h1>Inspiring sculptors:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={() => {
        setArtists([
          ...artists,
          { id: nextId++, name: name }
        ]);
      }}>Add</button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

همچنین، سینتکس array spread به شما این امکان را می‌دهد که با قرار دادن آن پیش از آرایه اصلی ...artists، آیتم را به ابتدای آرایه prepend کنید:

setArtists([
{ id: nextId++, name: name },
...artists // Put old items at the end
]);

بدین‌گونه، عملگر spread می‌تواند کار متد push() را با افزودن به انتهای آرایه و کار unshift() را با افزودن به ابتدای آرایه انجام دهد. آن را در sandbox فوق امتحان کنید!

حذف کردن از آرایه

آسان ترین راه برای حذف کردن یک آیتم از یک آرایه این است که آن را فیلتر کنید. به بیان دیگر، شما یک آرایه جدید ایجاد می‌کنید که آن آیتم را شامل نمی‌شود. برای انجام این عمل، از متد filter استفاده کنید:

import { useState } from 'react';

let initialArtists = [
  { id: 0, name: 'Marta Colvin Andrade' },
  { id: 1, name: 'Lamidi Olonade Fakeye'},
  { id: 2, name: 'Louise Nevelson'},
];

export default function List() {
  const [artists, setArtists] = useState(
    initialArtists
  );

  return (
    <>
      <h1>Inspiring sculptors:</h1>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>
            {artist.name}{' '}
            <button onClick={() => {
              setArtists(
                artists.filter(a =>
                  a.id !== artist.id
                )
              );
            }}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </>
  );
}

دکمه “Delete” را چند بار فشار داده و به click handler آن نگاه کنید.

setArtists(
artists.filter(a => a.id !== artist.id)
);

در اینجا، artists.filter(a => a.id !== artist.id) به معنی این است که “یک آرایه ایجاد کن که شامل artist هایی باشد که ID آنها با artist.id متفاوت است”. به عبارتی دیگر، دکمه “Delete” هر artist همان artist را با فیلتر از آرایه خارج می‌کند، و سپس درخواست یک رندر مجدد با آرایه حاصل را می‌دهد. توجه کنید که filter آرایه اصلی را تغییر نمی‌دهد.

تغییر دادن آرایه

اگر می‌خواهید تعدادی از آیتم‌ها، یا تمامی آیتم‌های آرایه را تغییر دهید، می‌توانید از map() برای ایجاد یک آرایه جدید استفاده کنید. تابعی که به map می‌دهید می‌تواند تصمیم بگیرد که با هر آیتم آرایه، بر حسب داده یا شماره آن (یا هر دو) چه کند.

در این مثال، یک آرایه مختصات دو دایره و یک مربع را درون خود نگهداری می‌کند. هنگامی که دکمه را فشار می‌دهید، فقط دایره‌ها را به مقدار 50 پیکسل به پایین حرکت می‌دهد. این عمل با ایجاد یک آرایه جدید از داده‌ها توسط map() انجام می‌شود:

import { useState } from 'react';

let initialShapes = [
  { id: 0, type: 'circle', x: 50, y: 100 },
  { id: 1, type: 'square', x: 150, y: 100 },
  { id: 2, type: 'circle', x: 250, y: 100 },
];

export default function ShapeEditor() {
  const [shapes, setShapes] = useState(
    initialShapes
  );

  function handleClick() {
    const nextShapes = shapes.map(shape => {
      if (shape.type === 'square') {
        // No change
        return shape;
      } else {
        // Return a new circle 50px below
        return {
          ...shape,
          y: shape.y + 50,
        };
      }
    });
    // Re-render with the new array
    setShapes(nextShapes);
  }

  return (
    <>
      <button onClick={handleClick}>
        Move circles down!
      </button>
      {shapes.map(shape => (
        <div
          key={shape.id}
          style={{
          background: 'purple',
          position: 'absolute',
          left: shape.x,
          top: shape.y,
          borderRadius:
            shape.type === 'circle'
              ? '50%' : '',
          width: 20,
          height: 20,
        }} />
      ))}
    </>
  );
}

جایگزین کردن آیتم در یک آرایه

این که بخواهیم یک یا چند آیتم آرایه را جایگزین کنیم امری رایج است. مقدار دهی‌هایی مانند arr[0] = 'bird' آرایه اصلی را mutate می‌کنند، پس در عوض شما باید برای این کار نیز از map استفاده کنید.

برای جایگزین کردن یک آیتم، با استفاده از map یک آرایه جدید ایجاد کنید. داخل فراخوانی map، شما شماره جایگاه (index) آیتم را به عنوان آرگومان دوم دریافت می‌کنید. از آن برای تشخیص این استفاده کنید که آیتم اصلی (آرگومان اول) را بازگردانید یا مقدار دیگری را.

import { useState } from 'react';

let initialCounters = [
  0, 0, 0
];

export default function CounterList() {
  const [counters, setCounters] = useState(
    initialCounters
  );

  function handleIncrementClick(index) {
    const nextCounters = counters.map((c, i) => {
      if (i === index) {
        // Increment the clicked counter
        return c + 1;
      } else {
        // The rest haven't changed
        return c;
      }
    });
    setCounters(nextCounters);
  }

  return (
    <ul>
      {counters.map((counter, i) => (
        <li key={i}>
          {counter}
          <button onClick={() => {
            handleIncrementClick(i);
          }}>+1</button>
        </li>
      ))}
    </ul>
  );
}

Insert (الحاق) کردن داخل یک آرایه

گاهی اوقات، ممکن است که بخواهید یک آیتم را در یک موقعیت خاص به غیر از ابتدا یا انتهای آرایه insert کنید. برای انجام این عمل، می‌توانید از سینتکس ... array spread به همراه متد slice() استفاده کنید. متد slice() به شما این امکان را می‌دهد که یک “slice (برش)” از آرایه را ببرید. برای insert کردن یک آیتم، شما آرایه ای ایجاد خواهید کرد که برش قبل از نقطه insert کردن را spread می‌کند، سپس آیتم جدید را جایگذاری می‌کند و سپس باقی آرایه اصلی را قرار می‌دهد.

در این مثال، دکمه Insert همیشه در موقعیت 1 insert می‌کند:

import { useState } from 'react';

let nextId = 3;
const initialArtists = [
  { id: 0, name: 'Marta Colvin Andrade' },
  { id: 1, name: 'Lamidi Olonade Fakeye'},
  { id: 2, name: 'Louise Nevelson'},
];

export default function List() {
  const [name, setName] = useState('');
  const [artists, setArtists] = useState(
    initialArtists
  );

  function handleClick() {
    const insertAt = 1; // Could be any index
    const nextArtists = [
      // Items before the insertion point:
      ...artists.slice(0, insertAt),
      // New item:
      { id: nextId++, name: name },
      // Items after the insertion point:
      ...artists.slice(insertAt)
    ];
    setArtists(nextArtists);
    setName('');
  }

  return (
    <>
      <h1>Inspiring sculptors:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={handleClick}>
        Insert
      </button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

اعمال تغییرات دیگر بر یک آرایه

برخی اعمال تنها با استفاده از سینتکس spread و متد‌های non-mutating مانند map() و filter() قابل انجام نیستند. برای مثال، ممکن است بخواهید که یک آرایه را معکوس کرده یا مرتب سازی کنید. متد‌های reverse() و sort() در جاوا اسکریپت آرایه اصلی را mutate می‌کنند، پس شما نمی‌توانید از آنها به طور مستقیم استفاده کنید.

با این حال، شما می‌توانید ابتدا آرایه را کپی کرده، و سپس تغییرات را بر آن اعمال کنید.

برای مثال:

import { useState } from 'react';

const initialList = [
  { id: 0, title: 'Big Bellies' },
  { id: 1, title: 'Lunar Landscape' },
  { id: 2, title: 'Terracotta Army' },
];

export default function List() {
  const [list, setList] = useState(initialList);

  function handleClick() {
    const nextList = [...list];
    nextList.reverse();
    setList(nextList);
  }

  return (
    <>
      <button onClick={handleClick}>
        Reverse
      </button>
      <ul>
        {list.map(artwork => (
          <li key={artwork.id}>{artwork.title}</li>
        ))}
      </ul>
    </>
  );
}

در اینجا، شما ابتدا از سینتکس[...list] spread برای ایجاد یک کپی از آرایه اصلی استفاده می‌کنید. حال که یک کپی از آن دارید، می‌توانید از متد‌های mutating مانند nextList.reverse() یا nextList.sort() استفاده کنید، یا حتی آیتم‌ها را به شکل nextList[0] = "something" به طور مجزا مقدار دهی کنید.

با این حال، حتی اگر آرایه را کپی کنید، نمی‌توانید آیتم‌های موجود در داخل آن را به طور مستقیم mutate کنید. این امر به خاطر آن است که عملیات کپی به طور سطحی انجام می‌شود—آرایه جدید همان آیتم‌هایی را شامل خواهد شد که در آرایه اصلی بودند. بنابراین، اگر شما یک object داخل آرایه کپی شده را تغییر دهید، در‌واقع دارید یک state موجود را mutate می‌کنید. برای مثال، چنین کدی مشکل دارد.

const nextList = [...list];
nextList[0].seen = true; // Problem: mutates list[0]
setList(nextList);

گرچه nextList و list دو آرایه متفاوت هستند، nextList[0] و list[0] به یک آبجکت اشاره می‌کنند. بنابراین با تغییر دادن nextList[0].seen، شما دارید list[0].seen را نیز تغییر می‌دهید. این کار یک mutate کردن state است، که باید از انجام آن خودداری کنید! شما می‌توانید این مشکل را به طرز مشابهی با بروزرسانی object های جاوا اسکریپت تو‌در‌تو رفع کنید—بجای mutate کردن، آیتم‌هایی را که می‌خواهید تغییر دهید کپی کنید. چگونگی این کار را در بخش بعدی مشاهده می‌کنید.

به روز رسانی object های داخل آرایه‌ها

Object ها واقعا در “داخل” آرایه‌ها قرار ندارند. ممکن است که در کد به نظر برسد که “داخل” آنها هستند، اما هر object داخل آرایه خود یک مقدار مستقل است، که آرایه به آن “اشاره می‌کند”. به این خاطر است که باید هنگام تغییر field های تو‌در‌تو مانند list[0] مراقب باشید. ممکن است آرایه artwork شخص دیگری به همان آیتم آرایه اشاره کند!

هنگام به روز رسانی state های تو‌در‌تو، باید از نقطه ای که می‌خواهید در آن تغییر ایجاد کنید تا بالاترین سطح کپی‌هایی ایجاد کنید. بیاید ببینیم که این عمل چگونه اتفاق می‌افتد.

در این مثال، دو لیست artwork مجزا مقدار state اولیه یکسانی دارند. آنها باید از هم جدا باشند، اما بخاطر یک mutation، به طور اتفاقی state آنها یکسان شده است، و تیک زدن باکس در یک لیست بر لیست دیگر نیز تاثیر می‌گذارد.

import { useState } from 'react';

let nextId = 3;
const initialList = [
  { id: 0, title: 'Big Bellies', seen: false },
  { id: 1, title: 'Lunar Landscape', seen: false },
  { id: 2, title: 'Terracotta Army', seen: true },
];

export default function BucketList() {
  const [myList, setMyList] = useState(initialList);
  const [yourList, setYourList] = useState(
    initialList
  );

  function handleToggleMyList(artworkId, nextSeen) {
    const myNextList = [...myList];
    const artwork = myNextList.find(
      a => a.id === artworkId
    );
    artwork.seen = nextSeen;
    setMyList(myNextList);
  }

  function handleToggleYourList(artworkId, nextSeen) {
    const yourNextList = [...yourList];
    const artwork = yourNextList.find(
      a => a.id === artworkId
    );
    artwork.seen = nextSeen;
    setYourList(yourNextList);
  }

  return (
    <>
      <h1>Art Bucket List</h1>
      <h2>My list of art to see:</h2>
      <ItemList
        artworks={myList}
        onToggle={handleToggleMyList} />
      <h2>Your list of art to see:</h2>
      <ItemList
        artworks={yourList}
        onToggle={handleToggleYourList} />
    </>
  );
}

function ItemList({ artworks, onToggle }) {
  return (
    <ul>
      {artworks.map(artwork => (
        <li key={artwork.id}>
          <label>
            <input
              type="checkbox"
              checked={artwork.seen}
              onChange={e => {
                onToggle(
                  artwork.id,
                  e.target.checked
                );
              }}
            />
            {artwork.title}
          </label>
        </li>
      ))}
    </ul>
  );
}

مشکل در کد مشابه زیر است:

const myNextList = [...myList];
const artwork = myNextList.find(a => a.id === artworkId);
artwork.seen = nextSeen; // Problem: mutates an existing item
setMyList(myNextList);

علی‌رغم این که خود آرایه myNextList جدید است، آیتم‌ها همان آیتم‌های در آرایه اصلی myList هستند. بنابراین، تغییر دادن artwork.seen آیتم artwork اصلی را تغییر می‌دهد. این آیتم artwork در لیست yourList نیز وجود دارد، که باعث بروز باگ می‌شود. در نظر گرفتن چنین باگ‌هایی می‌تواند دشوار باشد، اما خوشبختانه اگر از mutate کردن state خودداری کنید این باگ‌ها بروز پیدا نمی‌کنند.

شما می‌توانید با استفاده از mapیک آیتم قدیمی را بدون mutate کردن با نسخه به روز رسانی شده اش جایگزین کنید.

setMyList(myList.map(artwork => {
if (artwork.id === artworkId) {
// Create a *new* object with changes
return { ...artwork, seen: nextSeen };
} else {
// No changes
return artwork;
}
}));

در اینجا ... سینتکس object spread اسفاده شده برای ساخت کپی از یک آبجکت می‌باشد.

با استفاده از این روش، هیچ کدام از state های موجود mutate نمی‌شوند، و باگ برطرف می‌شود:

import { useState } from 'react';

let nextId = 3;
const initialList = [
  { id: 0, title: 'Big Bellies', seen: false },
  { id: 1, title: 'Lunar Landscape', seen: false },
  { id: 2, title: 'Terracotta Army', seen: true },
];

export default function BucketList() {
  const [myList, setMyList] = useState(initialList);
  const [yourList, setYourList] = useState(
    initialList
  );

  function handleToggleMyList(artworkId, nextSeen) {
    setMyList(myList.map(artwork => {
      if (artwork.id === artworkId) {
        // Create a *new* object with changes
        return { ...artwork, seen: nextSeen };
      } else {
        // No changes
        return artwork;
      }
    }));
  }

  function handleToggleYourList(artworkId, nextSeen) {
    setYourList(yourList.map(artwork => {
      if (artwork.id === artworkId) {
        // Create a *new* object with changes
        return { ...artwork, seen: nextSeen };
      } else {
        // No changes
        return artwork;
      }
    }));
  }

  return (
    <>
      <h1>Art Bucket List</h1>
      <h2>My list of art to see:</h2>
      <ItemList
        artworks={myList}
        onToggle={handleToggleMyList} />
      <h2>Your list of art to see:</h2>
      <ItemList
        artworks={yourList}
        onToggle={handleToggleYourList} />
    </>
  );
}

function ItemList({ artworks, onToggle }) {
  return (
    <ul>
      {artworks.map(artwork => (
        <li key={artwork.id}>
          <label>
            <input
              type="checkbox"
              checked={artwork.seen}
              onChange={e => {
                onToggle(
                  artwork.id,
                  e.target.checked
                );
              }}
            />
            {artwork.title}
          </label>
        </li>
      ))}
    </ul>
  );
}

به طور کلی، شما باید تنها object هایی را mutate کنید که تازه ایجاد شده اند. اگر بخواهید یک artwork جدید را insert کنید، می‌توانید آن را mutate کنید، اما اگر با object ای که از قبل در state وجود داشته کار می‌کنید، باید ابتدا از آن یک کپی ایجاد کنید.

به کمک Immer منطق به روز رسانی را به شکل مختصر بنویسید

به روز رسانی آرایه‌های تو‌در‌تو بدون mutation می‌تواند کمی تکراری شود. درست مانند object ها:

  • به طور کلی، احتمالا شما به به روز رسانی state با عمق بیشتر از چند لایه نیاز پیدا نمی‌کنید. اگر object های state شما عمق بسیار زیادی دارند، توصیه می‌شود که ساختار آنها را به طوری تغییر دهید که هموار شوند.
  • اگر نمی‌خواهید که ساختار state خود را تغییر دهید، می‌توانید از Immer استفاده کنید، که به شما امکان نوشتن به سینتکس mutate کننده و رایج را می‌دهد و خود در پشت صحنه ایجاد کپی را ترتیب می‌دهد.

در اینجا مثال لیست Art Bucket با Immer بازنویسی شده است:

{
  "dependencies": {
    "immer": "1.7.3",
    "react": "latest",
    "react-dom": "latest",
    "react-scripts": "latest",
    "use-immer": "0.5.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  },
  "devDependencies": {}
}

دقت کنید که با استفاده از Immer، چگونه عملیات artwork.seen = nextSeen که مشابه mutation است درست کار می‌کند:

updateMyTodos(draft => {
const artwork = draft.find(a => a.id === artworkId);
artwork.seen = nextSeen;
});

دلیل این امر آن است که شما state اصلی را mutate نمی‌کنید، بلکه یک draft یا پیش‌نویس مخصوص که توسط Immer ایحاد شده را mutate می‌کنید. به طور مشابه، می‌توانید متد‌های mutate کننده مانند push() و pop() را بر محتوای draft اعمال کنید.

در پشت صحنه، Immer همیشه state آینده را از از اول و با توجه به تغییراتی که بر draft اعمال کرده اید می‌سازد. این امر event handler های شما را بسیار مختصر و بدون هیچ mutate کردنی نگه می‌دارد.

Recap

  • شما می‌توانید آرایه‌ها را درون state قرار دهید، اما نمی‌توانید بر آنها تغییری اعمال کنید.
  • بجای mutate کردن یک آرایه، یک نسخه جدید از آن ایجاد کنید، و state را به آن به روز رسانی کنید.
  • شما می‌توانید از سینتکس [...arr, newItem] array spread برای ایجاد آرایه با آیتم‌های جدید استفاده کنید.
  • شما می‌توانید از filter() و map() برای ایجاد آرایه‌های جدید با آیتم‌های فیلتر شده و تغییر یافته استفاده کنید.
  • شما می‌توانید از Immer برای مختصر نگاه داشتن کد خود استفاده کنید.

Challenge 1 of 4:
به روز رسانی یک آیتم در سبد خرید

منطق handleIncreaseClick را به طوری کامل کنید که فشردن ”+” عدد مربوطه را افزایش دهد:

import { useState } from 'react';

const initialProducts = [{
  id: 0,
  name: 'Baklava',
  count: 1,
}, {
  id: 1,
  name: 'Cheese',
  count: 5,
}, {
  id: 2,
  name: 'Spaghetti',
  count: 2,
}];

export default function ShoppingCart() {
  const [
    products,
    setProducts
  ] = useState(initialProducts)

  function handleIncreaseClick(productId) {

  }

  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>
          {product.name}
          {' '}
          (<b>{product.count}</b>)
          <button onClick={() => {
            handleIncreaseClick(product.id);
          }}>
            +
          </button>
        </li>
      ))}
    </ul>
  );
}