これは、なにをしたくて書いたもの?
こちらの「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
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
の使い方がわからなくてだいぶハマりましたが…。
手動での動作確認は、こんな感じで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#clear
とuser#type
を使っています。
Utility APIs | Testing Library
user#clear
がないと、現在の値にそのまま追加になってしまいます。またuser#type
は、入力フィールドに対するテキスト操作用途
であればuser#keyboard
ではなくこちらを使うべきだとされています。
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} /> </> ); }
useState
がuseImmer
に変わり、
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」はあとひとつでおしまいです。