React / Next.js公開 約13分

React useReducerで編集の遷移をまとめる:取消と確定を分ける

取消を押したのに編集内容が残る、未編集でも確定処理が走る場合に。保存済みの名前と下書きを分け、操作ごとの状態遷移をreducerへ集めます。画面操作と関数単体の両方で、許す更新を確認します。

この記事の目次
  1. 取消で戻す先は、初期値ではなく最後の確定値
  2. 画面から独立した更新関数を先に作る
  3. dispatchは操作を伝え、表示はstateを読む
  4. 同じオブジェクトを返す失敗と、取消先を間違える失敗
  5. 関数へ操作列を渡し、画面の制約を外して確かめる

展示名を編集中に取消を押したら、表示用の名前まで変わっていた。今度は戻す処理を足したら、確定した名前まで初期値に戻る。複数のクリック処理が同じ値をそれぞれ書き換えると、何を保つべきか追いにくくなります。

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

記事の内容を、課題で確かめる

前提になるJavaScriptの練習 →

React・TypeScriptの学習内容 →

記事についてのお問い合わせ

記載内容へのご質問や訂正のご連絡は、hello@junkbranding.comまでお願いします。

← 記事一覧に戻る