CLOVER🍀

That was when it all began.

Learn ReactをVitest、React Testing Libraryを使ってやってみる(Updating Arrays in State)

これは、なにをしたくて書いたもの?

こちらの「Learn React」をやってみるシリーズの続きです。

Learn ReactをVitest、React Testing Library、jest-domを使ってやってみる(Describing the UI) - CLOVER🍀

Learn ReactをVitest、React Testing Library、jest-domを使ってやってみる(Keeping Components Pure) - CLOVER🍀

Learn ReactをVitest、React Testing Libraryを使ってやってみる(Understanding Your UI as a Tree) - CLOVER🍀

Learn ReactをVitest、React Testing Libraryを使ってやってみる(Responding to Events) - CLOVER🍀

Learn ReactをVitest、React Testing Libraryを使ってやってみる(State: A Component's Memory) - CLOVER🍀

Learn ReactをVitest、React Testing Libraryを使ってやってみる(Queueing a Series of State Updates) - CLOVER🍀

Learn ReactをVitest、React Testing Libraryを使ってやってみる(Updating Objects in State) - CLOVER🍀

今回は、「Learn React」の「Adding Interactivity」から「Updating Arrays in State」をやってみます。

Updating Arrays in State – React

「Adding Interactivity」の最後ですね。

Learn Reactをやってみる

Learn Reactをただ写経するのではなく、以下の条件で進めていきます。再掲です。

  • 環境はViteで構築する
  • TypeScriptで書く
  • Vitest、React Testing Libraryを使ったテストを書く

今回の対象はこちらのページです。

Updating Arrays in State – React

環境

今回の環境はこちら。

$ node --version
v22.15.1


$ npm --version
10.9.2

準備

Viteを使って、Node.jsプロジェクトを作成します。

$ npm create vite@latest updating-arrays-in-state -- --template react-ts
$ cd updating-arrays-in-state

依存ライブラリー、テスト用のライブラリーをインストール。

$ npm i
$ npm i immer use-immer
$ npm i -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom @types/react @types/react-dom
$ npm i -D prettier

今回はuse-immerを使用します。

インストールしたライブラリーの一覧とバージョンはこちら。

$ npm ls
updating-arrays-in-state@0.0.0 /path/to/updating-arrays-in-state
├── @eslint/js@9.27.0
├── @testing-library/dom@10.4.0
├── @testing-library/jest-dom@6.6.3
├── @testing-library/react@16.3.0
├── @testing-library/user-event@14.6.1
├── @types/react-dom@19.1.5
├── @types/react@19.1.4
├── @vitejs/plugin-react@4.4.1
├── eslint-plugin-react-hooks@5.2.0
├── eslint-plugin-react-refresh@0.4.20
├── eslint@9.27.0
├── globals@16.1.0
├── immer@10.1.1
├── jsdom@26.1.0
├── prettier@3.5.3
├── react-dom@19.1.0
├── react@19.1.0
├── typescript-eslint@8.32.1
├── typescript@5.8.3
├── use-immer@0.11.0
├── vite@6.3.5
└── vitest@3.1.3

自動生成された不要なファイルは削除しておきます。

$ find src -type f | grep -vE 'main.tsx|vite-env.d.ts' | xargs rm -rf
$ rmdir src/assets

Viteで作成したプロジェクトから、追加・変更した設定ファイルを載せておきます。

tsconfig.json

{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" },
    { "path": "./tsconfig.test.json" }
  ]
}

tsconfig.test.json

{
  "extends": "./tsconfig.app.json",
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo",
    "types": ["@testing-library/jest-dom"]
  },
  "include": [
    "src",
    "test",
    "vitest.config.ts",
    "vitest.setup.ts"
  ]
}

vitest.config.ts

import { defineConfig } from 'vite';

export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: ['./vitest.setup.ts'],
  },
});

vitest.setup.ts

import '@testing-library/jest-dom/vitest';

.prettierrc.json

{
  "singleQuote": true,
  "printWidth": 100
}

package.jsonscriptsの内容。

  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview",
    "typecheck": "tsc -b",
    "typecheck:watch": "tsc -b --w",
    "format": "prettier --write src/**/*.{ts,tsx,css} test/**/*.{ts,tsx}",
    "test": "vitest run",
    "test:update": "vitest run -u",
    "test:watch": "vitest watch"
  },

Learn ReactのAdding InteractivityのUpdating Arrays in Stateをやってみる

テストコードではTesting Libraryのuser-eventを使いますが、セットアップ部分は共通化しておきます。

`test/setup.ts{

import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';

export function setup(jsx: ReactNode) {
  return {
    user: userEvent.setup(),
    ...render(jsx),
  };
}
Adding to an array

Updating Arrays in State / Adding to an array

配列の中身を直接変更しても、うまくいかないという悪い例から。

src/BadList.tsx

import { useState } from 'react';

type Artist = {
  id: number;
  name: string;
};

let nextId = 0;

export function BadList() {
  const [name, setName] = useState('');
  //const [artists, setArtists] = useState<Artist[]>([]);
  const [artists] = useState<Artist[]>([]);

  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>
    </>
  );
}

test/BadList.test.tsx

import { expect, test } from 'vitest';
import { setup } from './setup';
import { BadList } from '../src/BadList';
import { screen, within } from '@testing-library/react';

test('Updating Arrays in State, BadList test', async () => {
  const { user, container } = setup(<BadList />);

  expect(within(screen.getByRole('list')).queryAllByRole('listitem')).toHaveLength(0);

  expect(container).toMatchSnapshot();

  const text = screen.getByRole('textbox');
  const button = screen.getByRole('button');

  await user.type(text, 'a');
  await user.click(button);

  expect(within(screen.getByRole('list')).queryAllByRole('listitem')).toHaveLength(0);

  expect(container).toMatchSnapshot();

  await user.type(text, 'b');
  await user.click(button);

  expect(within(screen.getByRole('list')).queryAllByRole('listitem')).toHaveLength(1);
  expect(screen.getByRole('listitem')).toHaveTextContent('a');

  expect(container).toMatchSnapshot();
});

手動での動作確認は、こんな感じでAppコンポーネントに組み込むコンポーネントを変えながら行っています。

src/main.tsx

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.tsx';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

src/App.tsx

import { BadList } from './BadList';

export function App() {
  return <BadList />;
}

改善版。

src/List.tsx

import { useState } from 'react';

type Artist = {
  id: number;
  name: string;
};

let nextId = 0;

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

  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>
    </>
  );
}

test/List.test.tsx

import { screen, within } from '@testing-library/react';
import { expect, test } from 'vitest';
import { List } from '../src/List';
import { setup } from './setup';

test('Updating Arrays in State, List test', async () => {
  const { user, container } = setup(<List />);

  expect(within(screen.getByRole('list')).queryAllByRole('listitem')).toHaveLength(0);

  expect(container).toMatchSnapshot();

  const text = screen.getByRole('textbox');
  const button = screen.getByRole('button');

  await user.type(text, 'a');
  await user.click(button);

  expect(within(screen.getByRole('list')).getAllByRole('listitem')).toHaveLength(1);
  expect(within(screen.getByRole('list')).getByRole('listitem')).toHaveTextContent('a');

  expect(container).toMatchSnapshot();

  await user.type(text, 'b');
  await user.click(button);

  expect(within(screen.getByRole('list')).getAllByRole('listitem')).toHaveLength(2);
  expect(
    within(screen.getByRole('list'))
      .getAllByRole('listitem')
      .map((e) => e.textContent),
  ).toStrictEqual(['a', 'ab']);

  expect(container).toMatchSnapshot();

  await user.type(text, 'c');
  await user.click(button);

  expect(within(screen.getByRole('list')).getAllByRole('listitem')).toHaveLength(3);
  expect(
    within(screen.getByRole('list'))
      .getAllByRole('listitem')
      .map((e) => e.textContent),
  ).toStrictEqual(['a', 'ab', 'abc']);

  expect(container).toMatchSnapshot();
});
Removing from an array

Updating Arrays in State / Removing from an array

配列から要素の削除。

src/List2.tsx

import { useState } from 'react';

type Artist = {
  id: number;
  name: string;
};

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

export function List2() {
  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>
    </>
  );
}

test/List2.test.tsx

import { screen, within } from '@testing-library/react';
import { expect, test } from 'vitest';
import { List2 } from '../src/List2';
import { setup } from './setup';

test('Updating Arrays in State, List2 test', async () => {
  const { user, container } = setup(<List2 />);

  expect(screen.getAllByRole('listitem')).toHaveLength(3);
  expect(
    screen.getAllByRole('listitem').flatMap((e) =>
      Array.from(e.childNodes)
        .filter((e) => e.nodeType === Node.TEXT_NODE)
        .map((e) => e.textContent)
        .join(''),
    ),
  ).toStrictEqual(['Marta Colvin Andrade ', 'Lamidi Olonade Fakeye ', 'Louise Nevelson ']);

  expect(container).toMatchSnapshot();

  await user.click(within(screen.getAllByRole('listitem')[1]).getByRole('button'));

  expect(screen.getAllByRole('listitem')).toHaveLength(2);
  expect(
    screen.getAllByRole('listitem').flatMap((e) =>
      Array.from(e.childNodes)
        .filter((e) => e.nodeType === Node.TEXT_NODE)
        .map((e) => e.textContent)
        .join(''),
    ),
  ).toStrictEqual(['Marta Colvin Andrade ', 'Louise Nevelson ']);

  expect(container).toMatchSnapshot();
});
Transforming an array

Updating Arrays in State / Transforming an array

配列の要素を変更(変換)。

src/ShapeEditor.tsx

import { useState } from 'react';

type Shape = {
  id: number;
  type: string;
  x: number;
  y: number;
};

const initialShapes: Shape[] = [
  { 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 function ShapeEditor() {
  const [shapes, setShapes] = useState(initialShapes);

  const handleClick = () => {
    const nextShapes = shapes.map((shape) => {
      if (shape.type === 'square') {
        return shape;
      } else {
        return {
          ...shape,
          y: shape.y + 50,
        };
      }
    });

    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,
          }}
          data-testid={'shape-' + shape.id}
        />
      ))}
    </>
  );
}

test/ShapeEditor.test.tsx

import { screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { ShapeEditor } from '../src/ShapeEditor';
import { setup } from './setup';

test('Updating Arrays in State, ShapeEditor test', async () => {
  const { user, container } = setup(<ShapeEditor />);

  expect(screen.getByTestId('shape-0')).toHaveStyle({ left: '50px', top: '100px' });
  expect(screen.getByTestId('shape-1')).toHaveStyle({ left: '150px', top: '100px' });
  expect(screen.getByTestId('shape-2')).toHaveStyle({ left: '250px', top: '100px' });

  expect(container).toMatchSnapshot();

  await user.click(screen.getByRole('button'));

  expect(screen.getByTestId('shape-0')).toHaveStyle({ left: '50px', top: '150px' });
  expect(screen.getByTestId('shape-1')).toHaveStyle({ left: '150px', top: '100px' });
  expect(screen.getByTestId('shape-2')).toHaveStyle({ left: '250px', top: '150px' });

  expect(container).toMatchSnapshot();

  await user.click(screen.getByRole('button'));

  expect(screen.getByTestId('shape-0')).toHaveStyle({ left: '50px', top: '200px' });
  expect(screen.getByTestId('shape-1')).toHaveStyle({ left: '150px', top: '100px' });
  expect(screen.getByTestId('shape-2')).toHaveStyle({ left: '250px', top: '200px' });

  expect(container).toMatchSnapshot();
});
Replacing items in an array

Updating Arrays in State / Replacing items in an array

配列の要素を置換。

src/CounterList.tsx

import { useState } from 'react';

const initialCounters: number[] = [0, 0, 0];

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

  const handleIncrementClick = (index: number) => {
    const nextCounters = counters.map((c, i) => {
      if (i === index) {
        return c + 1;
      } else {
        return c;
      }
    });

    setCounters(nextCounters);
  };

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

test/CounterList.test.tsx

import { screen, within } from '@testing-library/react';
import { expect, test } from 'vitest';
import { CounterList } from '../src/CounterList';
import { setup } from './setup';

test('Updating Arrays in State, CounterList test', async () => {
  const { user, container } = setup(<CounterList />);

  expect(
    screen.getAllByRole('listitem').flatMap((e) =>
      Array.from(e.childNodes)
        .filter((e) => e.nodeType === Node.TEXT_NODE)
        .map((e) => e.textContent),
    ),
  ).toStrictEqual(['0', '0', '0']);

  expect(container).toMatchSnapshot();

  await user.click(within(screen.getAllByRole('listitem')[0]).getByRole('button'));

  expect(
    screen.getAllByRole('listitem').flatMap((e) =>
      Array.from(e.childNodes)
        .filter((e) => e.nodeType === Node.TEXT_NODE)
        .map((e) => e.textContent),
    ),
  ).toStrictEqual(['1', '0', '0']);

  expect(container).toMatchSnapshot();

  await user.click(within(screen.getAllByRole('listitem')[2]).getByRole('button'));

  expect(
    screen.getAllByRole('listitem').flatMap((e) =>
      Array.from(e.childNodes)
        .filter((e) => e.nodeType === Node.TEXT_NODE)
        .map((e) => e.textContent),
    ),
  ).toStrictEqual(['1', '0', '1']);

  expect(container).toMatchSnapshot();

  await user.click(within(screen.getAllByRole('listitem')[1]).getByRole('button'));

  expect(
    screen.getAllByRole('listitem').flatMap((e) =>
      Array.from(e.childNodes)
        .filter((e) => e.nodeType === Node.TEXT_NODE)
        .map((e) => e.textContent),
    ),
  ).toStrictEqual(['1', '1', '1']);

  expect(container).toMatchSnapshot();

  await user.click(within(screen.getAllByRole('listitem')[0]).getByRole('button'));

  expect(
    screen.getAllByRole('listitem').flatMap((e) =>
      Array.from(e.childNodes)
        .filter((e) => e.nodeType === Node.TEXT_NODE)
        .map((e) => e.textContent),
    ),
  ).toStrictEqual(['2', '1', '1']);

  expect(container).toMatchSnapshot();
});
Inserting into an array

Updating Arrays in State / Inserting into an array

配列に要素を挿入。

src/List3.tsx

import { useState } from 'react';

type Artist = {
  id: number;
  name: string;
};

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

let nextId = initialArtists.length;

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

  const handleClick = () => {
    const insertAt = 1;

    const nextArtists = [
      ...artists.slice(0, insertAt),
      { id: nextId++, name: name },
      ...artists.slice(insertAt),
    ];

    setArtists(nextArtists);
  };

  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>
    </>
  );
}

test/List3.test.tsx

import { screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { List3 } from '../src/List3';
import { setup } from './setup';

test('Updating Arrays in State, List3 test', async () => {
  const { user, container } = setup(<List3 />);

  expect(screen.getAllByRole('listitem')).toHaveLength(3);
  expect(screen.getAllByRole('listitem').map((e) => e.textContent)).toStrictEqual([
    'Marta Colvin Andrade',
    'Lamidi Olonade Fakeye',
    'Louise Nevelson',
  ]);

  expect(container).toMatchSnapshot();

  await user.type(screen.getByRole('textbox'), 'Hello World');
  await user.click(screen.getByRole('button'));

  expect(screen.getAllByRole('listitem')).toHaveLength(4);
  expect(screen.getAllByRole('listitem').map((e) => e.textContent)).toStrictEqual([
    'Marta Colvin Andrade',
    'Hello World',
    'Lamidi Olonade Fakeye',
    'Louise Nevelson',
  ]);

  expect(container).toMatchSnapshot();

  await user.clear(screen.getByRole('textbox'));
  await user.type(screen.getByRole('textbox'), 'Foo Bar');
  await user.click(screen.getByRole('button'));

  expect(screen.getAllByRole('listitem')).toHaveLength(5);
  expect(screen.getAllByRole('listitem').map((e) => e.textContent)).toStrictEqual([
    'Marta Colvin Andrade',
    'Foo Bar',
    'Hello World',
    'Lamidi Olonade Fakeye',
    'Louise Nevelson',
  ]);

  expect(container).toMatchSnapshot();
});
Making other changes to an array

Updating Arrays in State / Making other changes to an array

配列の要素を変更すると、予期しない副作用を生むという話。

src/List4.tsx

import { useState } from 'react';

type Artwork = {
  id: number;
  title: string;
};

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

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

  const 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>
    </>
  );
}

test/List4.test.tsx

import { screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { List4 } from '../src/List4';
import { setup } from './setup';

test('Updating Arrays in State, List4 test', async () => {
  const { user, container } = setup(<List4 />);

  expect(screen.getAllByRole('listitem')).toHaveLength(3);
  expect(screen.getAllByRole('listitem').map((e) => e.textContent)).toStrictEqual([
    'Big Bellies',
    'Lunar Landscape',
    'Terracotta Army',
  ]);

  expect(container).toMatchSnapshot();

  await user.click(screen.getByRole('button'));

  expect(screen.getAllByRole('listitem')).toHaveLength(3);
  expect(screen.getAllByRole('listitem').map((e) => e.textContent)).toStrictEqual([
    'Terracotta Army',
    'Lunar Landscape',
    'Big Bellies',
  ]);

  expect(container).toMatchSnapshot();
});
Updating objects inside arrays

Updating Arrays in State / Updating objects inside arrays

src/BucketList.tsx

import { useState } from 'react';

type Artwork = {
  id: number;
  title: string;
  seen: boolean;
};

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

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

  const handleToggleMyList = (artworkId: number, nextSeen: boolean) => {
    setMyList(
      myList.map((artwork) => {
        if (artwork.id === artworkId) {
          return { ...artwork, seen: nextSeen };
        } else {
          return artwork;
        }
      }),
    );
  };

  const handleToggleYourList = (artworkId: number, nextSeen: boolean) => {
    setYourList(
      yourList.map((artwork) => {
        if (artwork.id === artworkId) {
          return { ...artwork, seen: nextSeen };
        } else {
          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,
}: {
  artworks: Artwork[];
  onToggle: (artworkId: number, nextSeen: boolean) => void;
}) {
  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>
  );
}

test/BucketList.test.tsx

import { screen, within } from '@testing-library/react';
import { expect, test } from 'vitest';
import { BucketList } from '../src/BucketList';
import { setup } from './setup';

test('Updating Arrays in State, BadBucketList test', async () => {
  const { user, container } = setup(<BucketList />);

  expect(screen.getAllByRole('list')).toHaveLength(2);
  expect(within(screen.getAllByRole('list')[0]).getAllByRole('listitem')).toHaveLength(3);
  expect(within(screen.getAllByRole('list')[1]).getAllByRole('listitem')).toHaveLength(3);

  expect(
    within(screen.getAllByRole('list')[0])
      .getAllByRole('checkbox')
      .map((e: HTMLElement) => (e as HTMLInputElement).checked),
  ).toStrictEqual([false, false, true]);
  expect(
    within(screen.getAllByRole('list')[1])
      .getAllByRole('checkbox')
      .map((e: HTMLElement) => (e as HTMLInputElement).checked),
  ).toStrictEqual([false, false, true]);

  expect(container).toMatchSnapshot();

  await user.click(within(screen.getAllByRole('list')[0]).getAllByRole('checkbox')[0]);

  expect(
    within(screen.getAllByRole('list')[0])
      .getAllByRole('checkbox')
      .map((e: HTMLElement) => (e as HTMLInputElement).checked),
  ).toStrictEqual([true, false, true]);
  expect(
    within(screen.getAllByRole('list')[1])
      .getAllByRole('checkbox')
      .map((e: HTMLElement) => (e as HTMLInputElement).checked),
  ).toStrictEqual([false, false, true]);

  expect(container).toMatchSnapshot();

  await user.click(within(screen.getAllByRole('list')[1]).getAllByRole('checkbox')[1]);

  expect(
    within(screen.getAllByRole('list')[0])
      .getAllByRole('checkbox')
      .map((e: HTMLElement) => (e as HTMLInputElement).checked),
  ).toStrictEqual([true, false, true]);
  expect(
    within(screen.getAllByRole('list')[1])
      .getAllByRole('checkbox')
      .map((e: HTMLElement) => (e as HTMLInputElement).checked),
  ).toStrictEqual([false, true, true]);

  expect(container).toMatchSnapshot();
});
Write concise update logic with Immer

Updating Arrays in State / Updating objects inside arrays / Write concise update logic with Immer

Immerを使って配列の要素を更新する例。

src/BucketList2.tsx

import { useImmer } from 'use-immer';

type Artwork = {
  id: number;
  title: string;
  seen: boolean;
};

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

export function BucketList2() {
  const [myList, updateMyList] = useImmer(initialList);
  const [yourList, updateYourList] = useImmer(initialList);

  const handleToggleMyList = (id: number, seen: boolean) => {
    updateMyList((draft) => {
      const artwork = draft.find((a) => a.id === id);
      artwork!.seen = seen;
    });
  };

  const handleToggleYourList = (id: number, seen: boolean) => {
    updateYourList((draft) => {
      const artwork = draft.find((a) => a.id === id);
      artwork!.seen = seen;
    });
  };

  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,
}: {
  artworks: Artwork[];
  onToggle: (artworkId: number, nextSeen: boolean) => void;
}) {
  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>
  );
}

test/BucketList2.test.tsx

import { screen, within } from '@testing-library/react';
import { expect, test } from 'vitest';
import { BucketList2 } from '../src/BucketList2';
import { setup } from './setup';

test('Updating Arrays in State, BadBucketList2 test', async () => {
  const { user, container } = setup(<BucketList2 />);

  expect(screen.getAllByRole('list')).toHaveLength(2);
  expect(within(screen.getAllByRole('list')[0]).getAllByRole('listitem')).toHaveLength(3);
  expect(within(screen.getAllByRole('list')[1]).getAllByRole('listitem')).toHaveLength(3);

  expect(
    within(screen.getAllByRole('list')[0])
      .getAllByRole('checkbox')
      .map((e: HTMLElement) => (e as HTMLInputElement).checked),
  ).toStrictEqual([false, false, true]);
  expect(
    within(screen.getAllByRole('list')[1])
      .getAllByRole('checkbox')
      .map((e: HTMLElement) => (e as HTMLInputElement).checked),
  ).toStrictEqual([false, false, true]);

  expect(container).toMatchSnapshot();

  await user.click(within(screen.getAllByRole('list')[0]).getAllByRole('checkbox')[0]);

  expect(
    within(screen.getAllByRole('list')[0])
      .getAllByRole('checkbox')
      .map((e: HTMLElement) => (e as HTMLInputElement).checked),
  ).toStrictEqual([true, false, true]);
  expect(
    within(screen.getAllByRole('list')[1])
      .getAllByRole('checkbox')
      .map((e: HTMLElement) => (e as HTMLInputElement).checked),
  ).toStrictEqual([false, false, true]);

  expect(container).toMatchSnapshot();

  await user.click(within(screen.getAllByRole('list')[1]).getAllByRole('checkbox')[1]);

  expect(
    within(screen.getAllByRole('list')[0])
      .getAllByRole('checkbox')
      .map((e: HTMLElement) => (e as HTMLInputElement).checked),
  ).toStrictEqual([true, false, true]);
  expect(
    within(screen.getAllByRole('list')[1])
      .getAllByRole('checkbox')
      .map((e: HTMLElement) => (e as HTMLInputElement).checked),
  ).toStrictEqual([false, true, true]);

  expect(container).toMatchSnapshot();
});

おわりに

今回は「Learn React」の「Adding Interactivity」から「Updating Arrays in State」をやってみました。

Reactでの配列の扱いを丁寧に見れたと思います。あまりしっかりやらなくてもよいかな?と思って始めたのですが、
JavaScriptやTypeScriptの復習にもなったので良かったです。

今回で「Adding Interactivity」はおしまいですね。次からは「Managing State」に入っていきます。

次のテーマからは、少しペースを変えようと思います。