UI стейт и sealed классы

В одном из андроид чатиков увидел вопрос: что лучше для стейта экрана: sealed или data класс? И там ответы типа it depends.

Обычно я так же на все вопросы подобного характера отвечаю. Но тут у меня ответ конкретный, поэтому мне стало интересно вообще разобраться откуда это взялось и почему люди продолжают использовать sealed классы для стейтов. Какой-то стадный эффект. Поверхностное гугление привело меня на десятки статей, где люди рассказывают про Loading-Content-Error sealed классами. Вообще во всех примерах - LCE. Как будто бы вообще других экранов не существует. Помню лет пять назад, когда я вкатывался в MVI с каким скрипом пытался осознать, что можно стейт и не sealed классом делать, ведь во всех материалах показывали только такой вариант.

Кто это вообще начал? Помню ещё до времён повального котлина в эпохальном докладе Джейка Вортона был прообраз этого безобразия с помощью статических фабричных методов в джаве. Это, признаться, самое старое что я вспомнил. Наверняка идея и тогда была не нова. И это действительно на первый взгляд в котлине ложится лучше всего на sealed. С одним но, с тем подходом не нужно было в when всё заворачивать чтобы достать любое поле. А это дикое количество бойлерплейта.

Не поймите меня неправильно, я люблю sealed классы. В своих пет проектах я буквально в каждом юзкейсе сейчас делаю свой внутренний Result, чтобы снаружи красиво можно (и нужно) было результат или ошибку обработать. Запросы в интернет и базу тоже всегда sealed обёртки. Так что хейтером меня сложно назвать.

Но состояние экрана описать sealed чаще всего в разы сложнее чем data классом. Посмотрим что пишет сам гугл у себя. nowinandroid:

sealed interface InterestsUiState {
    object Loading : InterestsUiState
    data class Interests(val topics: List<FollowableTopic>) : InterestsUiState
    object Empty : InterestsUiState
}

Всё как в этих самых учебниках. Попробуем помасштабировать. Предположим нам на этот экран нужно добавить состояние при котором показывается pull-to-refresh поверх списка данных. Что нам делать? Добавить Boolean поле в Interests? Добавить новый дата класс InterestsRefreshing? Все решения как будто так себе. И это экран, стейт которого описывается тремя полями. Каждое следующее поле будет добавлять экрану столько новых вариаций, что вы sealed наследниками это или реализовать не сможете, или это будет достигаться дублированием кучи кода в этих наследниках. В вырожденном случае у вас получится sealed класс с одним наследником - data классом, который содержит все поля. С чем боролись, на то и напоролись, называется.

Тот же самый пример в виде дата класса это в разы более расширяемый и лаконичный код:

data class InterestsUiState(
    val isLoading: Boolean = false,
    val topics: List<FollowableTopic>? = null,
)

Копировать - кайф, добавлять поля - легко, доставать поля - элементарно, меньше кода писать - класс.

В плюсы sealed, конечно, можно записать невозможность перейти в некорректное состояние. Но это единственный плюс как по мне, в корректное состояние тоже перейти то ещё приключение. Часто из-за этого говорят, что sealed лучше для маленьких экранов, с чем я тоже могу поспорить. Добавление любого поля чаще всего проще реализовать переписыванием sealed на data перерефакторив кучу уже написанного кода. Так какой смысл так сразу не писать?

Если подытожить, то я бы просто рекомендовал не пытаться сову на глобус натягивать, если у вас прям очевидное подмножество наследников, которое никогда не будет расширяться - идти с sealed. Иначе всегда data класс.