🥑

ReactのuseReducerを使ってオブジェクトの配列を良い感じにstate管理したい

2022/02/06に公開

やりたいこと

配列内のオブジェクトを追加・削除・編集できるようにしたい。

オブジェクト一つの場合

useStateを使う。

type User = {
  name: string
  age: number
}

const Form = () => {
  const [input, setInput] = useState<User>({ name: '', age: 0 })

  return (
   <form>
      <label>
        Name:
        <input
          name="name"
          onChange={(e) => setInput({ ...input, name: e.target.value })}
        />
      </label>
      <label>
        Age:
        <input
          name="age"
          onChange={(e) => setInput({ ...input, age: Number(e.target.value) })}
        />
      </label>
      <input type="submit" value="Submit" />
    </form>
  )
}

多分こんな感じになると思う。
ただしフォームによっては、stateにオブジェクトの配列を入れたくなるときがある。

オブジェクトの配列の場合

useReducerを使う。

Reducerの設定

// Userオブジェクトのkeyとvalueをセットにした型を定義
type UserKeyValue =
  | {
      key: 'name'
      value: string
    }
  | {
      key: 'age'
      value: number
    }

type ReducerAction =
  | ({
      type: 'set'
      index: number
    } & UserKeyValue)
  | { type: 'add' }
  | {
      type: 'remove'
      index: number
    }
  | {
      type: 'reset'
    }

const initialState: User = { name: '', age: 0 }

const reducer = (state: User[], action: ReducerAction) => {
  switch (action.type) {
    // 現在の配列の後ろにオブジェクトを一つ追加する
    case 'add':
      return [...state, initialState]

    // 配列内のindexで指定したオブジェクトのkeyにvalueをセットする
    case 'set':
      return [
        ...state.slice(0, action.index),
        { ...state[action.index], [action.key]: action.value },
        ...state.slice(action.index + 1),
      ]

    // 配列内のindexで指定したオブジェクトを配列から削除する
    case 'remove':
      return [...state.slice(0, action.index), ...state.slice(action.index + 1)]

    // 初期値にリセット
    case 'reset':
      return [initialState]

    // これはなくても良いかも
    default:
      return state
  }
}

useReducerを使ってformを作る

const ReducerForm = () => {
  const [inputs, dispatch] = useReducer(reducer, [initialState])

  return (
    <form>
      {/* inputsにはUserの配列が入っている */}
      {inputs.map((_, index) => (
        <InputUser
          index={index}
          inputs={inputs}
          dispatch={dispatch}
          key={index}
        />
      ))}
      <div>
        <button type="button" onClick={() => dispatch({ type: 'add' })}>
          追加
        </button>
      </div>
      <div>
        <button type="button" onClick={() => dispatch({ type: 'reset' })}>
          リセット
        </button>
      </div>
      <div>
        <input type="submit" value="Submit" />
      </div>
    </form>
  )
}

const InputUser = ({
  index,
  inputs,
  dispatch,
}: {
  index: number
  inputs: User[]
  dispatch: React.Dispatch<UserInputsReducerAction>
}) => {
  return (
    <div>
      <label>
        Name:
        <input
          name="name"
          onChange={(e) =>
            dispatch({
              type: 'set',
              index: index,
              key: 'name',
              value: e.target.value,
            })
          }
          value={inputs[index].name}
        />
      </label>
      <label>
        Age:
        <input
          name="age"
          onChange={(e) =>
            dispatch({
              type: 'set',
              index: index,
              key: 'age',
              value: Number(e.target.value),
            })
          }
          value={inputs[index].age}
        />
      </label>
      <button type="button" onClick={() => dispatch({ type: 'remove', index })}>
        削除
      </button>
    </div>
  )
}

こんな感じに書けばOK
ネストしたオブジェクトでも上手くアクションを定義すれば操作できる。
useReducer便利。

デモ

consoleでstateが期待した通りなっているか確認できます。

ちなみに

実際に使う場面

ここでは簡略化するためUserの配列を用いているが、単なるオブジェクトの配列だと、State自体を分けて管理したほうが良い場合が多そう。
実際は下記の例のようにUserオブジェクトに関連した複数の項目を入力したい場合での使用を想定している。

type User {
  name: string
  age: number
  details: UserDetail[]
}
// この場合reducerで管理するのは、UserDetailの配列になる

オブジェクトのキーとバリューをセットにしたUnionTypeを定義する

上記でやってるようにUserKeyValueを定義すると、propertyが増えたときに辛くなる。
なので下記のように定義すると良さそう。

type KeyValueUnionTypeWithKey<T, K extends keyof T> = K extends keyof T
  ? { key: K; value: Pick<T, K>[K] }
  : never
type KeyValueUnion<T> = KeyValueUnionTypeWithKey<T, keyof T>

type UserKeyValue = KeyValueUnion<User>
// こんな感じに期待した結果が得られる
// {
//   key: "age";
//   value: number;
// } | {
//   key: "name";
//   value: string;
// }

この際、条件式を省略すると期待した結果が得られないので注意が必要。

type KeyValueUnionTypeWithKey<T, K extends keyof T> = {
  key: K
  value: Pick<T, K>[K]
}
type KeyValueUnion<T> = KeyValueUnionTypeWithKey<T, keyof T>

type UserKeyValue = KeyValueUnion<User>
// こうなる
// {
//   key: keyof User;
//   value: string | number;
// }
GitHubで編集を提案

Discussion