k8o

React19で変化したuseReducerの型から学ぶTypeScriptの型推論

@types/reactのバージョン19ではuseReducerの型の変更が行われました。これまではreducer関数から状態の型を推論していましたが、バージョン19からはreducer関数と初期値から状態の型を推論する形に変更されました。この記事では、この変更を元にTypeScriptにおける型の推論方法について提案します。

公開: 2025年5月24日(土)
更新: 2025年5月24日(土)
閲覧数206 views

はじめに

@types/reactのバージョン19では、React本体の変更に対する追従の他に幾つかの変更が加えられました(Changelog)。 その中の1つとしてuseReducerの型の変更があります。

useReducerの型の変更は型推論の向上を目的として行われました。 これまではuseReducerを用いるためにreducer関数から状態の型を推論していました。しかし、バージョン19からは初期値を含めた引数全体から型を推論する形に変更されました。 これにより、これまで通りの使い心地を保ちつつ、冗長な型定義を防ぐことに成功しました。

以下の例は、型の推論方法の変更によって、React18のuseReducerで型エラーの発生する記述が、React19で起きないようになったものです。

// React18
// [any, Dispatch<Action>]
const [state, dispatch] = useReducer(
  // 💣 Parameter 'state' implicitly has an 'any' type.
  (prevCount) => prevCount + 1,
  0,
);

// React19
// [number, ActionDispatch<[]>]
const [state, dispatch] = useReducer(
  // prevCount: number
  (prevCount) => prevCount + 1,
  0,
);

この記事では、React19におけるuseReducerの型定義の変更点に焦点を当て、以前は型エラーとなっていたケースがどのように解消されたのかを解説します。 さらに、これらの変化を紐解きながら、TypeScriptにおける型の推論方法について考察します。

useReducer

useReducerはReactの状態を管理する機能の1つです(状態について)。

状態を更新する方法を指定するreducer関数と初期値を指定する値を引数に取り、状態と状態を更新するための関数を配列で返します。

const [state, dispatch] = useReducer(reducer, {
  id: 1,
  name: 'k8o',
  age: 26,
});

reducer関数は状態の更新方法を指定するので、古い状態とそれに対する指示を引数に取り、新しい状態を返します。

type Action = { type: 'age-increment' } | { type: 'age-decrement' };

const reducer = (state: State, action: Action): State => {
  const type = action.type;
  switch (type) {
    case 'age-increment':
      return { ...state, age: state.age + 1 };
    case 'age-decrement':
      return { ...state, age: state.age - 1 };
    default:
      throw new Error(type satisfies never);
  }
};

上記のreducer関数は、年齢を1歳増やす更新方法と年齢を1歳減らす更新方法を持ちます。 State型は状態の型で、Action型は更新方法を指示する値の型です。

このreducer関数を持つuseReducerが返すdispatch関数は、Action型の値を引数に渡して実行します。 実行するとreducer関数に定義した方法で状態を更新します。

const [state, dispatch] = useReducer(reducer, {
  id: 1,
  name: 'k8o',
  age: 26,
});
const increment = () => {
  dispatch({ type: 'age-increment' });
};
const decrement = () => {
  dispatch({ type: 'age-decrement' });
};

この例ではidnameの更新方法を提供されていません。そのため、ageを1歳増やす、減らす以外の更新ができません。

このように、useReducerreducer関数で指定した更新方法でしか状態を更新できないので、厳密な状態管理が可能になります。

初期値の与え方

useReducerを使うときの初期値を設定する方法は2つの方法があります。

1.初期値を直接渡す方法
const [state, dispatch] = useReducer(reducer, initialArg);

これは一番シンプルな方法です。initialArgがそのまま最初の状態になります。

2.「初期値を作るための関数」を渡す方法
const [state, dispatch] = useReducer(reducer, initialArg, init);

こちらの方法では、initialArgを材料にしてinitという関数が実行され、その結果init(initialArg)が最初の状態になります。

2つ目の方法がある理由

1つ目の方法でも関数による計算結果を初期値にできます。

const [state, dispatch] = useReducer(reducer, init(initialArg));

実際に、この方法で返されるstatedispatchは2つ目の方法と全く同じです。 1つ目の方法で記述できるのであれば、2つ目の方法は不要のようにも見えます。

それにもかかわらず、2つ目の方法がある理由はコンポーネントの初回の計算以降のinit(initialArg)の実行を避けるためにあります。

1つ目の方法では引数にinit(initialArg)を記述しているので、コンポーネントを計算するたびにinit(initialArg)が実行されてしまいます。 しかし、2つ目の方法はinitinitialArgを別々で渡しているので、Reactは初回の計算でのみinit(initialArg)を実行するように最適化してくれます。

init関数が複雑で時間のかかるものだった場合、コンポーネントの再計算のたびにその重い処理が何度も実行され、パフォーマンス低下に繋がります。 そのようなケースでは2つ目の方法が有効です。

このように、useReducerはパフォーマンスへの配慮から、初期値を効率的に設定するための方法を提供しています。

useReducerをさらに詳しく知りたい場合

最後に少しReact寄りの説明をしましたが、この記事ではReactの哲学に則った説明を最小限にし、単純な関数としての性質だけを説明しました。 詳しく知りたい場合は、React公式のドキュメントが丁寧に解説していますので、そちらを読むことをおすすめします。

型の変更

さて、useReducerの基本的な振る舞いを確認したところで、@types/reactに書かれたuseReducerの型を見ていきましょう。 ここで紹介する型は@types/reactバージョン19.1.4からuseReducerに関する型を抜粋したものです(React19の型React18の型)。

それぞれの型の動きを手元で確認したい型のためにTypeScriptのPlaygrounduseReducer1useReducer2として確認できる環境を用意しました。

React18

React18のuseReducerの型は以下のようになっています(namespaceは適当です)。

declare namespace React18 {
  type Dispatch<A> = (value: A) => void;
  type DispatchWithoutAction = () => void;
  type Reducer<S, A> = (prevState: S, action: A) => S;
  type ReducerWithoutAction<S> = (prevState: S) => S;
  type ReducerState<R extends Reducer<any, any>> =
    R extends Reducer<infer S, any> ? S : never;
  type ReducerAction<R extends Reducer<any, any>> =
    R extends Reducer<any, infer A> ? A : never;
  type ReducerStateWithoutAction<
    R extends ReducerWithoutAction<any>,
  > = R extends ReducerWithoutAction<infer S> ? S : never;

  function useReducer<R extends ReducerWithoutAction<any>, I>(
    reducer: R,
    initializerArg: I,
    initializer: (arg: I) => ReducerStateWithoutAction<R>,
  ): [ReducerStateWithoutAction<R>, DispatchWithoutAction];
  function useReducer<R extends ReducerWithoutAction<any>>(
    reducer: R,
    initializerArg: ReducerStateWithoutAction<R>,
    initializer?: undefined,
  ): [ReducerStateWithoutAction<R>, DispatchWithoutAction];
  function useReducer<R extends Reducer<any, any>, I>(
    reducer: R,
    initializerArg: I & ReducerState<R>,
    initializer: (arg: I & ReducerState<R>) => ReducerState<R>,
  ): [ReducerState<R>, Dispatch<ReducerAction<R>>];
  function useReducer<R extends Reducer<any, any>, I>(
    reducer: R,
    initializerArg: I,
    initializer: (arg: I) => ReducerState<R>,
  ): [ReducerState<R>, Dispatch<ReducerAction<R>>];
  function useReducer<R extends Reducer<any, any>>(
    reducer: R,
    initialState: ReducerState<R>,
    initializer?: undefined,
  ): [ReducerState<R>, Dispatch<ReducerAction<R>>];
}

最初にあるいくつかの型は、関数の型を表現するために利用する共通の型です。

Dispatch型は戻り値の型で、DispatchWithoutAction型はreducer関数に状態の更新方法を指示する引数がない時の型です。

type Dispatch<A> = (value: A) => void;
type DispatchWithoutAction = () => void;

reducer関数に状態の更新方法を指示する引数がない時の例を挙げると、以下のような形になります。

const [count, dispatch] = useReducer(
  (prevCount: number) => prevCount + 1,
  0,
);
const increment = () => {
  dispatch();
};

このケースでは、Actionreducer関数の第2引数)がないので状態の更新方法が1通りしかないです。今後このケースは単にActionがないケースと呼びます。

Reducer型は状態を更新するための関数の型で、ReducerWithoutAction型はActionがない時の型です。

type Reducer<S, A> = (prevState: S, action: A) => S;
type ReducerWithoutAction<S> = (prevState: S) => S;

ReducerState型はReducer型から状態の型を抽出するための型で、ReducerStateWithoutAction型はReducerWithoutAction型から状態の型を抽出するための型です。

type ReducerState<R extends Reducer<any, any>> =
  R extends Reducer<infer S, any> ? S : never;
type ReducerStateWithoutAction<R extends ReducerWithoutAction<any>> =
  R extends ReducerWithoutAction<infer S> ? S : never;

ReducerAction型はReducer型からAction型を抽出するための型です。

type ReducerAction<R extends Reducer<any, any>> =
  R extends Reducer<any, infer A> ? A : never;

続いて、useReducer自体の型を見ていきます。 useReducerはオーバーロード関数で5つに分けて実装されています。

1つ目はActionがなく、初期化関数を持つものです。

function useReducer<R extends ReducerWithoutAction<any>, I>(
  reducer: R,
  initializerArg: I,
  initializer: (arg: I) => ReducerStateWithoutAction<R>,
): [ReducerStateWithoutAction<R>, DispatchWithoutAction];

2つ目はActionがなく、初期化関数も持たないものです。

function useReducer<R extends ReducerWithoutAction<any>>(
  reducer: R,
  initializerArg: ReducerStateWithoutAction<R>,
  initializer?: undefined,
): [ReducerStateWithoutAction<R>, DispatchWithoutAction];

3つ目、4つ目の関数はActionを持ち、初期化関数も持つものです。 3つ目はuseReducerの2つ目の型引数のI型とReducerState型の交差型を初期化関数に渡すもので、4つ目はI型だけを初期化関数に渡すものです。

function useReducer<R extends Reducer<any, any>, I>(
  reducer: R,
  initializerArg: I & ReducerState<R>,
  initializer: (arg: I & ReducerState<R>) => ReducerState<R>,
): [ReducerState<R>, Dispatch<ReducerAction<R>>];
function useReducer<R extends Reducer<any, any>, I>(
  reducer: R,
  initializerArg: I,
  initializer: (arg: I) => ReducerState<R>,
): [ReducerState<R>, Dispatch<ReducerAction<R>>];

5つ目の関数はActionを持ち、初期化関数を持たないものです。

function useReducer<R extends Reducer<any, any>>(
  reducer: R,
  initialState: ReducerState<R>,
  initializer?: undefined,
): [ReducerState<R>, Dispatch<ReducerAction<R>>];

どの関数もreducer関数からReducer<S, A>型かReducerWithoutAction<S>型を推論して、そこから状態の型やAction型、Dispatch<ReducerAction<R>>型を求めています。

React19

React19のuseReducerの型は以下のようになっています。 React18の型と比べて、オーバーロード関数の数が2つまで減って、読みやすくなりました。

declare namespace Reducer {
  type AnyActionArg = [] | [any];
  type ActionDispatch<ActionArg extends AnyActionArg> = (
    ...args: ActionArg
  ) => void;

  function useReducer<S, A extends AnyActionArg>(
    reducer: (prevState: S, ...args: A) => S,
    initialState: S,
  ): [S, ActionDispatch<A>];
  function useReducer<S, I, A extends AnyActionArg>(
    reducer: (prevState: S, ...args: A) => S,
    initialArg: I,
    init: (i: I) => S,
  ): [S, ActionDispatch<A>];
}

AnyActionArg型はAction自体、ActionDispatch型は更新関数の型です。

type AnyActionArg = [] | [any];
type ActionDispatch<ActionArg extends AnyActionArg> = (
  ...args: ActionArg
) => void;

1つ目の関数は初期化関数を持たない場合に適用される型です。

function useReducer<S, A extends AnyActionArg>(
  reducer: (prevState: S, ...args: A) => S,
  initialState: S,
): [S, ActionDispatch<A>];

2つ目の関数は初期化関数を持つ場合に適用されます。

function useReducer<S, I, A extends AnyActionArg>(
  reducer: (prevState: S, ...args: A) => S,
  initialArg: I,
  init: (i: I) => S,
): [S, ActionDispatch<A>];

React19では、reducer関数から各型を推論するのではなく、状態の型(S型)とAction型(A型)を推論してから各型の推論するようになりました。

明示的な型付け方法の変更

React19になって、useReducerの受け取る型引数が変更されました。

React18ではreducer関数の型(Reducer<S, A>型かReducerWithoutAction<S>型)を渡していたところから、React19では状態の型(S型)とAction型(AnyActionArg型)を渡すように変更されました。

// React18
const [state, dispatch] = useReducer<Reducer<State, Action>>(
  reducer,
  initialArg,
  init,
);
const [state, dispatch] = useReducer<Reducer<State, Action>, I>(
  reducer,
  initialArg,
  init,
);

// React19
const [state, dispatch] = useReducer<State, [Action]>(
  reducer,
  initialArg,
  init,
);
const [state, dispatch] = useReducer<State, I, [Action]>(
  reducer,
  initialArg,
  init,
);

初期化関数を用いるときに利用する型引数I型を渡す順番には違和感を覚えますが、内部の型を使わずに型を渡せるようになったのは良い変化だと感じました。

なお、React19ではuseReducerに型引数を渡さないことがベストプラクティスとされているので、推論でカバーできないときにのみ型引数を利用するようにしましょう。

The new best practice is not to pass type arguments to useReducer.

型の推論の向上

はじめに紹介したReact18では型推論がうまく働かず、型エラーが発生してしまう例についてです。

このエラーは、React18のuseReducerではreducer関数からのみ状態の型を推論することが原因で起きています。reducer関数の型が不十分なので、状態の型を推論する方法がなく、型エラーが生じていたのです。

そのため、React18ではreducer関数が型を持っていない状態で、useReducerの宣言はできませんでした。

type Action = { type: 'age-increment' } | { type: 'age-decrement' };

// [any, Dispatch<Action>]
const [state, dispatch] = useReducer(
  // 💣 Parameter 'state' implicitly has an 'any' type.
  (state, action: Action) => {
    const type = action.type;
    switch (type) {
      case 'age-increment':
        return { ...state, age: state.age + 1 };
      case 'age-decrement':
        return { ...state, age: state.age - 1 };
      default:
        throw new Error(type satisfies never);
    }
  },
  {
    id: 1,
    name: 'k8o',
    age: 26,
  },
);

React19では状態の型を初期値からも推論できるの問題なく宣言できます。

// [
//   {
//     id: number,
//     name: string,
//     age: number
//   },
//   ActionDispatch<[action: Action]>,
// ]
const [state, dispatch] = useReducer(
  (state, action: Action) => {
    const type = action.type;
    switch (type) {
      case 'age-increment':
        return { ...state, age: state.age + 1 };
      case 'age-decrement':
        return { ...state, age: state.age - 1 };
      default:
        throw new Error(type satisfies never);
    }
  },
  {
    id: 1,
    name: 'k8o',
    age: 26,
  },
);

これは、React19のuseReducerが状態の型をreducer関数だけに頼らず、初期値からも推論するように変更されたためです。

実は、React19におけるuseReducerの型が変更はこの変更のために行われたものです。

Improve [react] useReducer type inference · DefinitelyTyped/DefinitelyTyped · Discussion #63607 · GitHub

The problem useReducer does not infer the state parameter from the initial state passed in: const [state, dispatch] = useReducer( (state, action: Action) =&gt; { // ... }, 0, ); This should infer stat...

github.com

useReducerから学ぶ型推論

React18からReact19のuseReducerの型変更から、関数を引数にもつ関数の型を定義する方法を学ぶことができます。

React18では、reducer関数という主体となる型があり、そこから状態の型やAction型を推論する形でした。 一方、React19では状態の型とAction型を主体として、そこからreducer関数の型やActionDispatch型を推論する形になりました。

React18とReact19のuseReducerの型の違いを図示したもの

これを一般化して、以下のように定義します。

  • React18式: 主体となる型があり、それから従属する型を推論させる方法
  • React19式: 値の主従関係を決めずに基本的な方から型を推論させる方法
React18式とReact19式の型推論法の違いを図示したもの

React19式は型の定義が簡潔で可読性も高くなりやすく利用しやすい使いやすい型になっていますが、React18式は値の主従関係を型に持ち込むような複雑な定義となります。

そのため、React19式は汎用的に使い、React18式は型の上でも主従関係を明確にした強い制約を持たせたい場合に有効です。React19式だけが良いわけではなく、React18式も有効なケースがある事に注意が必要です。

配列をフィルタする関数を例に

例として、配列をフィルタする関数を考えます。

type Filter<T> = (value: T) => boolean;
type Value<F extends Filter<any>> =
  F extends Filter<infer U> ? U : never;

// React18版
const arrayFilter1 = <F extends Filter<any>>(
  array: Value<F>[],
  filter: F,
): Value<F>[] => {
  return array.filter(filter);
};

// React19版
const arrayFilter2 = <T>(array: T[], filter: Filter<T>): T[] => {
  return array.filter(filter);
};

arrayFilter1はReact18式の型定義で、arrayFilter2はReact19式の型定義です。

それぞれの関数の型は以下のように推論されます。

// result1: any[]
const result1 = arrayFilter1(
  [1, 2, 3],
  // val: any
  (val) => val > 0,
);

// result1: number[]
const result1 = arrayFilter1<Filter<number>>(
  [1, 2, 3],
  // val: number
  (val) => val > 0,
);

// result2: number[]
const result2 = arrayFilter2(
  [1, 2, 3],
  // val: number
  (val) => val > 0,
);

arrayFilter1は関数から型を推論しているので、関数に型情報を付与するもしくは、型引数を渡さないと型がany[]になってしまいます。 一方、arrayFilter2は配列からも型を推論しているので、型引数を渡さなくても型が特定できるようになっています。

例に挙げた関数であれば、フィルタ関数を主役と考えてフィルタ関数が対応する型のみを配列の値に許可させたい場合はarrayFilter1のように、配列の値からも値の型を推論可能にして作用させるフィルタ関数の型を推論させたい場合はarrayFilter2のようにするのが良いでしょう。

React18式が有効なケース

React18式が有効なケースを紹介します。

ここでは、部分適用を実装した関数を紹介します。部分適用とはある関数から引数の一部分を代入した状態の関数を作り出すような操作を意味します(厳密な定義ではない)。

f(x,y,z)g(x)(y,z)f(x, y, z) \to g(x)(y, z)

この関数は適用元の関数が存在する上で代入する値が存在すると考えられます。そのため、部分適用の制約を型の上でも表現したい場合はReact18式が有効です。

type TargetFunc<T, U extends any[], K> = (arg: T, ...args: U) => K;
type FirstArg<T> = T extends (arg: infer U, ...args: never) => void
  ? U
  : never;
type RestArg<T> = T extends (arg: never, ...args: infer U) => void
  ? U
  : never;

const partial = <F extends TargetFunc<any, any, any>>(
  func: F,
  value: FirstArg<F>,
): ((...rest: RestArg<F>) => ReturnType<F>) => {
  return (...rest) => func(value, ...rest);
};

const plus3 = partial((num1: number, num2: number) => num1 + num2, 3);

plus3は、2つの数値を足し合わせる関数をベースに、一方の数値を3へ固定した関数になります。

この関数は適用元の関数が主役となるように作られているので、適用元の関数に型がない場合は型エラーが発生します。 また、適用元の関数が受け取れない値を第二引数に渡したときは第二引数だけにエラーが発生します。

これらのエラーは適用元の関数を主として考える上であえて発生させた(ある種リンターのような意味合いを持つ)ものです。このような制約をあえて設けたいケースでは、React18式を用いることがおすすめです。

React19式が使われるケース

React19式が使われるケースは巷にあふれています。

例えば配列に実装されているreduceメソッドは、配列の要素から型を推論する形で実装されています。

// TypeScriptの定義を抽出したもの
interface Array<T> {
  reduce<U>(
    callbackfn: (
      previousValue: U,
      currentValue: T,
      currentIndex: number,
      array: T[],
    ) => U,
    initialValue: U,
  ): U;
}

// sum: number, prev: number, curr: number
const sum1 = [1, 2, 3].reduce((prev, curr) => prev + curr, 0);

// sum: number, prev: number, curr: number
const sum2 = [1, 2, 3].reduce(
  (prev: number, curr: number) => prev + curr,
  0,
);

sum1の計算では、callbackfnの引数であるpreviousValuecurrentValueの型は配列と初期値から推論しています。

そして、sum2の計算では、callbackfnの引数であるpreviousValuecurrentValueの型は明示的に指定しています。この時は、配列、初期値、previousValuecurrentValueの型に矛盾ないことが求められます。

previousValuecurrentValueが関数の型を決定するだけではなく、配列と初期値からも型を決定できる柔軟な型の実装になっています。 これが、React19式の便利で汎用性の高いところです。このようにReact19式は柔軟で使いやすい形なので、ほとんどのパターンではReact19式で実装するのが良いでしょう。

この考えを元にすると、useReducerreducerに従って状態が決まるわけではないので、React19におけるuseReducerの型の変更は良い変更だと考えられます。

さらに、useReducerは配列のreduce関数に因んで名付けられているので、そういう意味でも配列のreduceに近い型の実装になったのは良いことだと思いました(なぜリデューサと呼ばれるのか?)。

おわりに

React19では、useReducerの型が変更され、よりシンプルで使いやすくなりました。特に、型推論の向上により、冗長な型定義を避けることができるようになりました。 この変更は、useReducerを使用するReact・TypeScriptユーザーにとって、より快適な開発体験をもたらせてくれました。

さらに、React19の型変更を通じて、TypeScriptの型推論について考察しました。作成したい型の振る舞いを考え、React18式の型とReact19式の型を使い分けることで、より柔軟で堅牢な型定義が可能になります。

今回の考察は日頃、身近で複雑な機能の型を実装して実際の型と比較することで生まれました。皆さんも普段ライブラリで利用するような機能の型について思いを馳せてみてはいかがでしょうか。

この記事はどうでしたか?

500文字以内でご記入ください

ブログの購読

k8oのブログを購読する

k8oのブログを購読することで、最新の情報を受け取ることができます。

登録いただいたメールアドレスは、購読のためにのみ使用されます。