CLOVER🍀

That was when it all began.

Learn ReactをVitest、React Testing Libraryを使ってやってみる(Updating Objects 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」の「Adding Interactivity」から「Updating Objects in State」をやってみます。

Updating Objects in State – React

Learn Reactをやってみる

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

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

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

Updating Objects in State – React

useImmer

今回の最後に、ネストしたオブジェクトの更新にuseStateではなくuseImmerを使うようにしています。

GitHub - immerjs/use-immer: Use immer to drive state with a React hooks

Learn Reactで紹介されているライブラリーですが、こちらを使うことでネストしたオブジェクトの更新を簡潔に書けるように
なります。

useImmerは、ImmerをReactのフックとして使えるようにしたライブラリーです。

GitHub - immerjs/immer: Create the next immutable state by mutating the current one

環境

今回の環境はこちら。

$ node --version
v22.15.0


$ npm --version
10.9.2

準備

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

$ npm create vite@latest updating-objects-in-state -- --template react-ts
$ cd updating-objects-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

今回はコンテンツに登場する、immer、use-immerを追加しています。

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

$ npm ls
updating-objects-in-state@0.0.0 /path/to/updating-objects-in-state
├── @eslint/js@9.26.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.3
├── @types/react@19.1.3
├── @vitejs/plugin-react@4.4.1
├── eslint-plugin-react-hooks@5.2.0
├── eslint-plugin-react-refresh@0.4.20
├── eslint@9.26.0
├── globals@16.0.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.0
├── 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.jsonのscriptsの内容。

  "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 Objects in Stateをやってみる

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

test/setup.ts

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

export function setup(jsx: ReactNode) {
  return {
    user: userEvent.setup(),
    ...render(jsx),
  };
}
Treat state as read-only

Treat state as read-only

src/MovingDot.tsx

import { useState } from 'react';

export function MovingDot() {
  const [position, setPosition] = useState({
    x: 0,
    y: 0,
  });

  return (
    <div
      data-testid="pointer-root"
      onPointerMove={(e) => {
        setPosition({
          x: e.clientX,
          y: e.clientY,
        });
      }}
      style={{
        position: 'relative',
        width: '100vw',
        height: '100vh',
      }}
    >
      <div
        style={{
          position: 'absolute',
          backgroundColor: 'red',
          borderRadius: '50%',
          transform: `translate(${position.x}px, ${position.y}px)`,
          left: -10,
          top: -10,
          width: 20,
          height: 20,
        }}
        data-testid="pointer-area"
      />
    </div>
  );
}

そのままだとちょっとテストが書きにくかったので、今回初めてdata-testidを使ってみました。

test/MovingDot.test.tsx

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

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

  const pointerArea = screen.getByTestId('pointer-area');
  expect(pointerArea.style['transform']).toStrictEqual('translate(0px, 0px)');

  expect(container).toMatchSnapshot();

  const pointerRoot = screen.getByTestId('pointer-root');
  await user.pointer({
    pointerName: 'mouse',
    target: pointerRoot,
    coords: { clientX: 100, clientY: 50 },
  });

  expect(pointerArea.style['transform']).toStrictEqual('translate(100px, 50px)');
  expect(container).toMatchSnapshot();
});

今回、初めてuser#click以外のイベントを扱ってみました。user#pointerの使い方がわからなくてだいぶハマりましたが…。

Pointer | Testing Library

手動での動作確認は、こんな感じで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 { MovingDot } from "./MovingDot";

export function App() {
  return (
    <MovingDot />
  );
}
Copying objects with the spread syntax

Updating Objects in State / Copying objects with the spread syntax

useStateでオブジェクトを扱う場合は、スプレッド演算子で以前の値をコピーして、新しいフィールドだけ値を指定すると
便利ですね、という話です。

src/Form.tsx

import { useState, type ChangeEvent } from 'react';

type Person = {
  firstName: string;
  lastName: string;
  email: string;
};

export function Form() {
  const [person, setPerson] = useState<Person>({
    firstName: 'Barbara',
    lastName: 'Hepworth',
    email: 'bhepworth@sculpture.com',
  });

  const handleFirstNameChange = (e: ChangeEvent<HTMLInputElement>) =>
    setPerson({
      ...person,
      firstName: e.target.value,
    });

  const handleLastNameChange = (e: ChangeEvent<HTMLInputElement>) =>
    setPerson({
      ...person,
      lastName: e.target.value,
    });

  const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) =>
    setPerson({
      ...person,
      email: e.target.value,
    });

  return (
    <>
      <label>
        First name:
        <input value={person.firstName} onChange={handleFirstNameChange} />
      </label>
      <label>
        Last name:
        <input value={person.lastName} onChange={handleLastNameChange} />
      </label>
      <label>
        Email:
        <input value={person.email} onChange={handleEmailChange} />
      </label>
      <p>
        {person.firstName} {person.lastName} ({person.email})
      </p>
    </>
  );
}

test/Form.test.tsx

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

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

  expect(container).toMatchSnapshot();

  const firstName = screen.getByLabelText('First name:');
  expect(firstName).toHaveValue('Barbara');

  await user.clear(firstName);
  await user.type(firstName, 'foo');
  expect(firstName).toHaveValue('foo');

  const lastName = screen.getByLabelText('Last name:');
  expect(lastName).toHaveValue('Hepworth');

  await user.clear(lastName);
  await user.type(lastName, 'bar');
  expect(lastName).toHaveValue('bar');

  const email = screen.getByLabelText('Email:');
  expect(email).toHaveValue('bhepworth@sculpture.com');

  await user.clear(email);
  await user.type(email, 'foo-bar@example.com');
  expect(email).toHaveValue('foo-bar@example.com');

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

今回はuser#clearuser#typeを使っています。

Utility APIs | Testing Library

user#clearがないと、現在の値にそのまま追加になってしまいます。またuser#typeは、入力フィールドに対するテキスト操作用途
であればuser#keyboardではなくこちらを使うべきだとされています。

Keyboard | Testing Library

Updating a nested object

Updating Objects in State / Updating a nested object

ネストしたオブジェクトの場合は、階層ごとにコピーが必要です、という話です。

src/Form2.tsx

import { useState, type ChangeEvent } from 'react';

type Person = {
  name: string;
  artwork: {
    title: string;
    city: string;
    image: string;
  };
};

export function Form2() {
  const [person, setPerson] = useState<Person>({
    name: 'Niki de Saint Phalle',
    artwork: {
      title: 'Blue Nana',
      city: 'Hamburg',
      image: 'https://i.imgur.com/Sd1AgUOm.jpg',
    },
  });

  const handleNameChange = (e: ChangeEvent<HTMLInputElement>) =>
    setPerson({
      ...person,
      name: e.target.value,
    });

  const handleTitleChnage = (e: ChangeEvent<HTMLInputElement>) =>
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        title: e.target.value,
      },
    });

  const handleCityChange = (e: ChangeEvent<HTMLInputElement>) =>
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        city: e.target.value,
      },
    });

  const handleImageChange = (e: ChangeEvent<HTMLInputElement>) =>
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        image: e.target.value,
      },
    });

  return (
    <>
      <label>
        Name:
        <input value={person.name} onChange={handleNameChange} />
      </label>
      <label>
        Title:
        <input value={person.artwork.title} onChange={handleTitleChnage} />
      </label>
      <label>
        City:
        <input value={person.artwork.city} onChange={handleCityChange} />
      </label>
      <label>
        Image:
        <input value={person.artwork.image} onChange={handleImageChange} />
      </label>
      <p>
        <i>{person.artwork.title}</i>
        {' by '}
        {person.name}
        <br />
        (located in {person.artwork.city})
      </p>
      <img src={person.artwork.image} alt={person.artwork.title} />
    </>
  );
}

test/Form2.test.tsx

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

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

  expect(container).toMatchSnapshot();

  const name = screen.getByLabelText('Name:');
  expect(name).toHaveValue('Niki de Saint Phalle');

  await user.clear(name);
  await user.type(name, 'my name');
  expect(name).toHaveValue('my name');

  const title = screen.getByLabelText('Title:');
  expect(title).toHaveValue('Blue Nana');

  await user.clear(title);
  await user.type(title, 'My Title');
  expect(title).toHaveValue('My Title');

  const city = screen.getByLabelText('City:');
  expect(city).toHaveValue('Hamburg');

  await user.clear(city);
  await user.type(city, 'My City');
  expect(city).toHaveValue('My City');

  const image = screen.getByLabelText('Image:');
  expect(image).toHaveValue('https://i.imgur.com/Sd1AgUOm.jpg');

  await user.clear(image);
  await user.type(image, 'https://i.imgur.com/Mx7dA2Y.jpg');
  expect(image).toHaveValue('https://i.imgur.com/Mx7dA2Y.jpg');

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

Updating Objects in State / Updating a nested object / Write concise update logic with Immer

Immerを使って、ネストしたオブジェクトの更新を簡潔に実装します。

src/Form3.tsx

import { type ChangeEvent } from 'react';
import { useImmer } from 'use-immer';

type Person = {
  name: string;
  artwork: {
    title: string;
    city: string;
    image: string;
  };
};

export function Form3() {
  const [person, updatePerson] = useImmer<Person>({
    name: 'Niki de Saint Phalle',
    artwork: {
      title: 'Blue Nana',
      city: 'Hamburg',
      image: 'https://i.imgur.com/Sd1AgUOm.jpg',
    },
  });

  const handleNameChange = (e: ChangeEvent<HTMLInputElement>) =>
    updatePerson((draft) => {
      draft.name = e.target.value;
    });

  const handleTitleChnage = (e: ChangeEvent<HTMLInputElement>) =>
    updatePerson((draft) => {
      draft.artwork.title = e.target.value;
    });

  const handleCityChange = (e: ChangeEvent<HTMLInputElement>) =>
    updatePerson((draft) => {
      draft.artwork.city = e.target.value;
    });

  const handleImageChange = (e: ChangeEvent<HTMLInputElement>) =>
    updatePerson((draft) => {
      draft.artwork.image = e.target.value;
    });

  return (
    <>
      <label>
        Name:
        <input value={person.name} onChange={handleNameChange} />
      </label>
      <label>
        Title:
        <input value={person.artwork.title} onChange={handleTitleChnage} />
      </label>
      <label>
        City:
        <input value={person.artwork.city} onChange={handleCityChange} />
      </label>
      <label>
        Image:
        <input value={person.artwork.image} onChange={handleImageChange} />
      </label>
      <p>
        <i>{person.artwork.title}</i>
        {' by '}
        {person.name}
        <br />
        (located in {person.artwork.city})
      </p>
      <img src={person.artwork.image} alt={person.artwork.title} />
    </>
  );
}

useStateuseImmerに変わり、

  const [person, updatePerson] = useImmer<Person>({
    name: 'Niki de Saint Phalle',
    artwork: {
      title: 'Blue Nana',
      city: 'Hamburg',
      image: 'https://i.imgur.com/Sd1AgUOm.jpg',
    },
  });

更新がこういった感じになります。

  const handleTitleChnage = (e: ChangeEvent<HTMLInputElement>) =>
    updatePerson((draft) => {
      draft.artwork.title = e.target.value;
    });

test/Form3.test.tsx

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

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

  expect(container).toMatchSnapshot();

  const name = screen.getByLabelText('Name:');
  expect(name).toHaveValue('Niki de Saint Phalle');

  await user.clear(name);
  await user.type(name, 'my name');
  expect(name).toHaveValue('my name');

  const title = screen.getByLabelText('Title:');
  expect(title).toHaveValue('Blue Nana');

  await user.clear(title);
  await user.type(title, 'My Title');
  expect(title).toHaveValue('My Title');

  const city = screen.getByLabelText('City:');
  expect(city).toHaveValue('Hamburg');

  await user.clear(city);
  await user.type(city, 'My City');
  expect(city).toHaveValue('My City');

  const image = screen.getByLabelText('Image:');
  expect(image).toHaveValue('https://i.imgur.com/Sd1AgUOm.jpg');

  await user.clear(image);
  await user.type(image, 'https://i.imgur.com/Mx7dA2Y.jpg');
  expect(image).toHaveValue('https://i.imgur.com/Mx7dA2Y.jpg');

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

おわりに

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

今までは単純な値をステートとして扱っていましたが、オブジェクトやネストしたオブジェクトの更新がテーマになりました。
useImmerも知ることができましたし、今後の役に立ちそうな気がします。

「Adding Interactivity」はあとひとつでおしまいです。