この記事の目次
展示名を編集中に取消を押したら、表示用の名前まで変わっていた。今度は戻す処理を足したら、確定した名前まで初期値に戻る。複数のクリック処理が同じ値をそれぞれ書き換えると、何を保つべきか追いにくくなります。
JSX、props、useStateが分かる人向けに、架空の音の展示の編集欄を作ります。ここでの「確定」はブラウザーのメモリー内で表示名を更新する操作で、サーバー保存ではありません。React / React DOM 19.2.0、Vite 7.1.7、Node.js 24系に固定します。
取消で戻す先は、初期値ではなく最後の確定値
必要な情報を、savedという確定済みの名前、draftという編集中の文字、modeという閲覧中か編集中かの印へ分けます。savedとdraftは常に同じではありません。取消で破棄してよいのはdraftだけです。
最初に許す操作を表にします。ここで「変更しない」とした組合せは、画面のボタンを無効にするだけでなく、更新を決める関数でも拒否します。UIから呼ばれないはずの順序でも、同じ規則が残るためです。
| 今の状態 | 操作 | 次の状態と値 |
|---|---|---|
| 閲覧中 | 編集開始 | 編集中へ。savedをdraftに写す |
| 編集中 | 入力変更 | draftだけ変更 |
| 編集中 | 取消 | 閲覧中へ。draftをsavedへ戻す |
| 編集中 | 有効な確定 | 前後の空白を除いたdraftをsavedへ反映 |
| 編集中 | 空白だけで確定 | 編集中のまま、理由を表示 |
| 閲覧中 | 変更・取消・確定 | 状態を変えない |
reducerは「現在のstateと、起きた操作actionから、次のstateを返す関数」です。React公式の更新規則の整理のように、ボタン側は何が起きたかを伝え、値の更新はこの関数へ集めます。
状態の持ち主を決める記事は、複数部品で同じ値を共有する場所の話です。今回は一つの編集欄でも起きる、操作順序と値の関係を決めます。useReducerへ替えるだけで正しい順序が自動生成されるわけではありません。
画面から独立した更新関数を先に作る
空フォルダで次を実行します。
npm init -y
npm install --save-exact react@19.2.0 react-dom@19.2.0
npm install --save-dev --save-exact vite@7.1.7
editor.mjsを保存します。通信、DOM操作、時刻や乱数を使わず、渡されたstateを書き換えない関数にします。
export const initialState = {
mode: 'view', saved: '森の音', draft: '森の音', error: ''
};
export function reducer(state, action) {
switch (action.type) {
case 'start':
return state.mode === 'view'
? { ...state, mode: 'edit', draft: state.saved, error: '' } : state;
case 'change':
return state.mode === 'edit'
? { ...state, draft: action.value, error: '' } : state;
case 'cancel':
return state.mode === 'edit'
? { ...state, mode: 'view', draft: state.saved, error: '' } : state;
case 'commit': {
if (state.mode !== 'edit') return state;
const name = state.draft.trim();
return name === ''
? { ...state, error: '空白以外の名前を入力してください。' }
: { mode: 'view', saved: name, draft: name, error: '' };
}
default:
throw new Error('未定義の操作です: ' + action.type);
}
}
入力のたびにsavedへ書かず、commitを通ったときだけ更新します。閲覧中の既知の操作は同じstateを返しますが、typeの誤字は開発上の間違いとして例外にします。外部から届く任意のデータをそのままdispatchする構成ではありません。
dispatchは操作を伝え、表示はstateを読む
index.htmlには次を書きます。
<!doctype html>
<html lang="ja"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>展示名の編集練習</title>
<style>
body { margin: 16px; font-family: sans-serif; line-height: 1.8; }
main { max-width: 560px; margin: auto; }
input { box-sizing: border-box; width: 100%; font: inherit; }
button { font: inherit; margin: 6px; }
p { overflow-wrap: anywhere; }
:focus-visible { outline: 3px solid #174ea6; outline-offset: 3px; }
</style></head><body><div id="root"></div>
<script type="module" src="/main.jsx"></script></body></html>
main.jsxでreducerを読み込みます。入力欄は残し、閲覧中はreadonlyにします。表示を外して入力欄そのものを作り直すことを、この状態遷移の確認へ混ぜません。
import React, { useReducer } from 'react';
import { createRoot } from 'react-dom/client';
import { initialState, reducer } from './editor.mjs';
function App() {
const [state, dispatch] = useReducer(reducer, initialState);
const editing = state.mode === 'edit';
return <main>
<h1>展示名の編集(架空の練習)</h1>
<p>確定済み:<span id="saved">{state.saved}</span></p>
<button type="button" disabled={editing}
onClick={() => dispatch({ type: 'start' })}>編集開始</button>
<label htmlFor="name">展示名の下書き</label>
<input id="name" value={state.draft} readOnly={!editing}
aria-invalid={state.error ? 'true' : undefined} aria-describedby="help error"
onChange={event => dispatch({ type: 'change', value: event.target.value })} />
<p id="help">{editing ? '編集中です。確定するまで表示名は変わりません。' : '閲覧中です。編集開始で変更できます。'}</p>
<p id="error" role="status">{state.error}</p>
<button type="button" disabled={!editing}
onClick={() => dispatch({ type: 'cancel' })}>取消</button>
<button type="button" disabled={!editing}
onClick={() => dispatch({ type: 'commit' })}>確定</button>
</main>;
}
createRoot(document.getElementById('root')).render(<App />);
npx vite --host 127.0.0.1で開発用に開きます。編集開始後「川の音」へ変えても、確定済みは「森の音」です。取消なら両方が「森の音」、改めて編集して確定すれば両方が「川の音」になります。
useReducerのリファレンスでは、dispatch後の新しいstateを次のレンダーで読むことを確認できます。dispatchの直後に同じイベント内でstateをログへ出しても、その時点の値がすぐ書き換わるわけではありません。確認は更新後の画面か、reducerの戻り値で行います。
同じオブジェクトを返す失敗と、取消先を間違える失敗
第一の失敗は、changeでstate.draft = action.value; return state;とすることです。元のstateを直接変更して同じ参照を返すため、Reactが更新を見送る原因になります。完成例のように新しいオブジェクトを返し、入力と表示が一緒に更新されるか確認します。開発時にreducerが追加実行されても、入力stateを変えない純粋な関数なら同じ計算を保てます。
第二の失敗は、cancelでinitialStateを返すことです。「川の音」を一度確定し、次に「風の音」を編集中に取り消すと、「森の音」まで戻ってしまいます。修正は現在のsavedを保持してdraftだけを戻すことです。この操作順をもう一度行い、取消後に「川の音」が残れば修正できています。
空白だけの確定をボタンのdisabledだけで防ぐ方法にも注意します。別の呼出し元からdispatchできるため、値の条件はreducer側にも必要です。ここではtrimした結果を見て、空ならmodeをeditに保ち、savedを更新しません。
関数へ操作列を渡し、画面の制約を外して確かめる
15分程度の追加課題として、check.mjsを作ります。これは画面のボタンが無効でも、規則自体が残るかを確かめる完成例です。
import { initialState, reducer } from './editor.mjs';
let state = reducer(initialState, { type: 'commit' });
console.log(state === initialState);
state = reducer(state, { type: 'start' });
state = reducer(state, { type: 'change', value: ' 川の音 ' });
state = reducer(state, { type: 'commit' });
console.log(state.mode, state.saved);
state = reducer(state, { type: 'start' });
state = reducer(state, { type: 'change', value: '風の音' });
state = reducer(state, { type: 'cancel' });
console.log(state.mode, state.saved, state.draft);
node check.mjsの解答はtrue、view 川の音、view 川の音
川の音の三行です。空白だけのchangeとcommitも足すと、editのままでエラーがあり、savedは川の音のままになります。
画面ではnpx vite buildとnpx vite preview --host 127.0.0.1で公開用の結果を開き、同じ操作、TabとEnter、幅320pxを確認します。小さな真偽値一つならuseStateで足りる場合もあります。通信の開始や保存処理はreducer内へ入れず、Effectと操作を分ける記事で扱う実行場所を別に決めます。カリキュラムのReact課題へ進む際は、この操作表を使って、許可しない順序まで説明できるか確かめてください。
TRY A LESSON
記事の内容を、課題で確かめる
記載内容へのご質問や訂正のご連絡は、hello@junkbranding.comまでお願いします。