✍️

Reactのpropsはreadonlyにするべきか?

2024/02/21に公開

Reactのコンポーネントに渡るpropsですが、イミュータブルであるためpropsの値を直接書き換えるようなことは通常しません。したとしても恩恵は特にないからです。しかし、実際には書き換えることもできるし警告が出ないケースもあります。

そこで明示的にpropsの型に readonly をつけるかどうかを考えました。間違っている点や、もっとこうした方が良いというアドバイス、ぜひコメントしてください。

エラーになるケース

まず、propsを書き換えてエラーになってくれるケースから考えます。

interface Props {
  name: string;
}

export default function Foo(props: Props) {
  props.name = "overwrite!";
}

これは実行時に Cannot assign to read only property 'name' of object というエラーになります。

ネストされたオブジェクトはエラーにならない

一方でネストされたオブジェクトを書き換えるとどうなるでしょう。

interface Props {
  obj: {
    name: string;
  };
}

export default function Foo(props: Props) {
  props.obj.name = "overwrite!";
}

これは実行時にエラーにはならず書き換えができてしまいます。

readonly で防ぐ

そもそもpropsがイミュータブルであるなら、実行エラーではなく静的検査で警告を出したくなります。そこで readonly をつけることで警告が出るようになります。

interface Props {
  readonly name: string;
  readonly obj: {
    readonly name: string;
  };
}

export default function Foo(props: Props) {
  props.name = "overwrite!";
  props.obj.name = "overwrite!";
}

これで TS2339: Property name does not exist on type { readonly name: string; } といった警告が出てきます。良いですね。

readonly が付いているかを手動でチェックするのは面倒なのでESLintの力を借ります。ESLintにはprefer-readonly-parameter-typesというルールがあります。これを有効にすると、引数が readonly ではない場合に警告を出してくれます。

readonly 全部つける?

ESLintの力によってpropsはイミュータブルになりました。しかし、すべての値を検査するには当然全てに readonly が必要です。面倒ですね。TypeScriptには Readonly<T> という便利な機能がありますが、これはネストされたオブジェクトには適用されません。

interface Props {
  name: string;
  obj: {
    name: string;
  };
}

export default function Foo(props: Readonly<Props>) {
  props.obj.name = "overwrite!"; // obj.nameはreadonlyじゃない
}

ReadonlyDeep<T> を使う?

type-festというライブラリが提供する ReadonlyDeep<T> を使うとその名の通り再帰的にすべてのプロパティを readonly にしてくれます。便利ですね。

interface Props {
  name: string;
  obj: {
    name: string;
  };
}

export default function Foo(props: ReadonlyDeep<Props>) {
  props.obj.name = "overwrite!"; // 警告が出る
}

でもこのためだけにライブラリいれるか悩みます。うーん?

分割代入されたら無に帰す

そもそも引数を受け取る際に分割代入するとどうなるでしょうか。

interface Props {
  name: string;
  obj: {
    name: string;
  };
}

export default function Foo({ name }: ReadonlyDeep<Props>) {
  name = "overwrite!";
}

警告も出ず普通に書き換えることができてしまいます。これは分割代入した際に新しく name オブジェクトが生成されるからですね。では分割代入を禁止するESLintルールを導入する…? 個人的には引数で分割代入は使わないので良いのですが、わりとよく見る書き方なので禁止するのは過剰な気がします。

propsをそのまま受け取る vs 分割代入で受け取る

余談ですが、propsの引数の受け取り方流派は、以下の2つに分かれる気がします。

// そのまま受け取る派
export default function Foo(props) {
  props.name;
  props.foo;
  props.bar;
}
// 分割代入で受け取る派
export default function Foo({ name, foo, bar }) {
  name;
  foo;
  bar;
}

個人的には前者の「そのまま受け取る派」所属です。理由は、関数内の変数がprops由来なのか、関数内で定義されたものなのかが明示的だからです。こうすることでpropsを変更してしまうケアレスミスが防げます。

現時点の結論

完全なイミュータブルを実現するのが難しいのでreadonly にしなくて良い気がする

(でも、イミュータブルだと嬉しい気がするなぁ…)

ムーザルちゃんねる

Discussion