Олег Марков
Слайс UI - паттерн ui-slice и работа со списками в пользовательском интерфейсе
Введение
Термин «Слайс UI» или «ui-slice» обычно используют, когда говорят о том, как представить состояние пользовательского интерфейса в виде слайса структур и как работать с этим состоянием так, чтобы код оставался простым и предсказуемым.
Чаще всего это касается экранов со списками: таблицы, коллекции карточек, списки задач, чатов, новостей. Вы храните элементы списка как слайс, меняете этот слайс (добавляете, удаляете, сортируете), а UI «просто» должен отразить изменения.
На практике вокруг этого «просто» возникает много вопросов:
- как хранить состояние строки/ячейки (selected, loading, error);
- как синхронизировать слайс данных и видимые элементы;
- как безопасно изменять слайс при обновлениях с сервера;
- как не потерять пользовательские изменения (выбор, раскрытие, редактирование);
- как сделать код читаемым и тестируемым.
В этой статье я покажу вам, как можно организовать слой «Слайс UI» (ui-slice) так, чтобы он был:
- предсказуемым;
- простым в отладке;
- удобным для тестов;
- независимым от конкретного фреймворка (Android/iOS/Web/desktop);
- безопасным в многопоточном коде (если вы работаете на Go или другом языке с конкуррентностью).
Основная идея: вы описываете один «UI-элемент» как структуру, а UI-слой работает не с отдельными полями, а со слайсом этих элементов и заранее определенным набором операций над этим слайсом.
Что такое ui-slice и зачем он нужен
Слайс как источник правды для UI
Когда мы говорим «ui-slice», мы подразумеваем, что:
- список элементов UI представлен в виде слайса;
- каждое изменение UI отражается как изменение этого слайса;
- сам слайс считается «источником правды» (single source of truth) для видимого списка.
В простом виде это выглядит так:
// ItemUI описывает один элемент списка в UI
type ItemUI struct {
ID string // Уникальный идентификатор элемента
Title string // Текст заголовка
Selected bool // Флаг - выбран ли элемент в UI
Loading bool // Флаг - находится ли элемент в состоянии загрузки
Error string // Текст ошибки, связанной с элементом (если есть)
}
// UISlice - это просто слайс элементов UI
type UISlice []ItemUI
Смотрите, я показываю вам самые базовые поля: идентификатор, текст, флаги и ошибка. В реальном проекте структура будет богаче, но принцип остается тем же: один элемент UI = одна структура.
UI-слой (виджеты, компоненты, экраны) не хранит в себе отдельные поля для каждого элемента. Он читает только UISlice и реагирует на его изменения.
Какие задачи решает ui-slice
Паттерн ui-slice помогает решить несколько типичных задач:
Синхронизация состояния
Вы меняете только данные (слайс), UI автоматически обновляется. Не нужно вручную «поддерживать» одновременно и данные, и отдельные внутренние состояния виджетов.Управление выбором и фокусом
Логика выбора (selected, highlighted, expanded) описывается на уровне слайса, а не разбросана по разным компонентам.Переиспользование и тестирование
Все операции над ui-slice можно тестировать как обычные функции, без запуска всего UI.Упрощение архитектуры
Controller/Presenter/ViewModel возвращает всегда один тип – UISlice. UI не знает о доменной модели напрямую.
Базовое устройство ui-slice
Структура элемента UI
Давайте разберемся, что обычно входит в элемент ui-slice. Ниже пример чуть более насыщенной структуры:
// ItemUI описывает состояние одной строки списка в UI
type ItemUI struct {
ID string // Уникальный ID для стабильного сопоставления
Title string // Основной текст строки
Subtitle string // Дополнительная подпись
IconURL string // Ссылка на иконку (если есть)
Selected bool // Отмечен ли элемент
Disabled bool // Можно ли с ним взаимодействовать
Loading bool // Идет ли по нему операция
ErrorText string // Локальная ошибка, связанная с действием по элементу
}
Обратите внимание: здесь мы описываем именно UI-состояние, а не бизнес-домен. Скажем, в доменной модели задачи может не быть флагов Selected, Disabled, Loading, но для UI они нужны.
Частая практика:
- делать отдельные типы для доменной модели (Task, User, Message);
- конвертировать их в ItemUI с помощью функции маппинга;
- хранить в ui-slice только ItemUI (или аналогичные структуры).
Тип слайса и базовые операции
Определяем тип ui-slice и функцию-конструктор:
// UISlice - тип для списка элементов UI
type UISlice []ItemUI
// NewUISlice создает UISlice из слайса доменных моделей
func NewUISlice(tasks []Task) UISlice {
ui := make(UISlice, 0, len(tasks))
for _, t := range tasks {
ui = append(ui, mapTaskToItemUI(t)) // Маппинг доменной модели в UI-модель
}
return ui
}
Где:
// Task - пример доменной модели
type Task struct {
ID string
Title string
Done bool
}
// mapTaskToItemUI преобразует Task в ItemUI
func mapTaskToItemUI(t Task) ItemUI {
return ItemUI{
ID: t.ID,
Title: t.Title,
Subtitle: "", // Доп. поле, если нужно
Selected: false, // В домене нет, но в UI будет
Disabled: t.Done, // Например, выполненные задачи делаем disabled
}
}
Теперь вы можете поддерживать ui-slice отдельно от доменной модели и четко видеть, какие поля относятся только к UI.
Операции над ui-slice
В реальном приложении ui-slice почти никогда не остается статичным. Вы будете:
- добавлять элементы;
- удалять элементы;
- обновлять часть полей;
- сбрасывать состояние (например, при рефреше данных);
- менять порядок элементов.
Иммутабельный и мутабельный подход
Есть два распространенных подхода:
Иммутабельный
Каждая операция над слайсом возвращает новый ui-slice.
Удобно в реактивных фреймворках и для предсказуемости.Мутабельный
Операции изменяют слайс «на месте».
Удобно в производительных и низкоуровневых частях (как в Go), но требует аккуратности.
Давайте реализуем оба подхода, чтобы вы увидели отличие.
Иммутабельные операции
Предположим, мы хотим всегда возвращать новый UISlice.
// WithToggledSelection возвращает новый UISlice
// с инвертированным флагом Selected у элемента с указанным ID
func (s UISlice) WithToggledSelection(id string) UISlice {
// Копируем исходный слайс
out := make(UISlice, len(s))
copy(out, s)
for i, item := range out {
if item.ID == id {
// Инвертируем флаг Selected
out[i].Selected = !item.Selected
break
}
}
return out
}
Комментарий:
- мы не меняем исходный слайс;
- создаем новый слайс той же длины и копируем данные;
- изменяем элемент только в новом слайсе.
Такой подход упрощает отладку: вы можете хранить «историю состояний» и откатываться назад (time travel debugging).
Добавление элемента:
// WithAppendedItem возвращает новый UISlice
// с добавленным элементом в конец
func (s UISlice) WithAppendedItem(item ItemUI) UISlice {
out := make(UISlice, len(s), len(s)+1)
copy(out, s)
out = append(out, item)
return out
}
Удаление элемента:
// WithRemovedByID возвращает новый UISlice без элемента с указанным ID
func (s UISlice) WithRemovedByID(id string) UISlice {
out := make(UISlice, 0, len(s))
for _, item := range s {
if item.ID == id {
// Пропускаем этот элемент
continue
}
out = append(out, item)
}
return out
}
Вы можете комбинировать такие функции-операции в цепочки:
// Здесь мы показываем как можно последовательно применить несколько операций
slice := NewUISlice(tasks)
slice = slice.
WithAppendedItem(newItem).
WithToggledSelection(newItem.ID).
WithRemovedByID(oldID)
Мутабельные операции
В некоторых случаях вам важна производительность и вы готовы работать с изменением «на месте». Тогда операции могут выглядеть так:
// ToggleSelectionInPlace изменяет флаг Selected у элемента прямо в исходном UISlice
func (s UISlice) ToggleSelectionInPlace(id string) {
for i := range s {
if s[i].ID == id {
s[i].Selected = !s[i].Selected
return
}
}
}
Удаление «на месте» обычно делают через стандартный прием со сдвигом:
// RemoveByIDInPlace удаляет элемент по ID, сдвигая хвост слайса
func (s *UISlice) RemoveByIDInPlace(id string) {
a := *s
for i := range a {
if a[i].ID == id {
// Здесь мы перемещаем элементы справа от i на одну позицию влево
copy(a[i:], a[i+1:])
// Укорачиваем слайс на 1
*s = a[:len(a)-1]
return
}
}
}
Обратите внимание:
- функция принимает указатель на UISlice, потому что изменяет сам слайс (его длину);
- такой код нужно аккуратно использовать при конкурентном доступе: нельзя менять слайс, к которому одновременно обращаются в других горутинах.
Связь ui-slice с UI-фреймворком
Подход с «рендер-функцией»
Обычно архитектура строится по схеме:
- Храним текущее состояние UISlice где-то в модели/сторе.
- На каждое изменение ui-slice вызываем функцию рендеринга UI.
Условно:
type UIUpdater interface {
RenderList(items UISlice)
}
Контроллер или ViewModel:
type ListController struct {
ui UIUpdater // Интерфейс для обновления UI
uiSlice UISlice // Текущее состояние списка в виде ui-slice
}
// SetItems полностью заменяет список
func (c *ListController) SetItems(tasks []Task) {
// Обновляем uiSlice
c.uiSlice = NewUISlice(tasks)
// Передаем обновленный список в UI
c.ui.RenderList(c.uiSlice)
}
// OnItemClicked вызывается, когда пользователь нажал на элемент
func (c *ListController) OnItemClicked(id string) {
// Меняем состояние выбранности
c.uiSlice = c.uiSlice.WithToggledSelection(id)
// Обновляем UI
c.ui.RenderList(c.uiSlice)
}
Как видите, UIUpdater скрывает детали конкретного фреймворка. Вы можете реализовать его по-разному для web, mobile, desktop.
Рекомендации по обновлению UI
Чтобы ui-slice действительно помогал, полезно придерживаться нескольких правил:
UI обновляется только из одного места
Все изменения UI-состояния идут через контроллер/стор, а не напрямую из разных мест.UI не меняет ui-slice напрямую
Компоненты UI вызывают методы контроллера (OnItemClicked, OnRefresh, OnLoadMore), а он уже меняет ui-slice.Слайс – единый формат для списка
Даже если у вас несколько типов строк (заголовок, обычная строка, футер), все равно используйте один тип слайса, а различайте элементы по полю Type.
Сложные элементы в ui-slice
Разные типы элементов в одном списке
Частая задача – в одном списке показывать:
- обычные элементы;
- разделители;
- заголовки секций;
- заглушки (empty state);
- индикатор загрузки.
В ui-slice это удобно решается через поле Type.
type ItemType int
const (
ItemTypeContent ItemType = iota // Обычная строка с контентом
ItemTypeHeader // Заголовок секции
ItemTypeDivider // Разделитель
ItemTypeLoading // Элемент с индикатором загрузки
ItemTypeEmptyState // Заглушка "нет данных"
)
type ItemUI struct {
ID string
Type ItemType // Тип элемента
Title string
Subtitle string
Selected bool
Disabled bool
Loading bool
ErrorText string
}
Дальше ваши операции над слайсом могут работать с разными типами:
// WithLoadingFooter добавляет или обновляет "хвост" загрузки
func (s UISlice) WithLoadingFooter(show bool) UISlice {
// Сначала удаляем предыдущий хвост, если был
out := make(UISlice, 0, len(s)+1)
for _, item := range s {
if item.Type == ItemTypeLoading {
// Этот элемент заменим новым при необходимости
continue
}
out = append(out, item)
}
if show {
// Добавляем элемент, который UI отрисует как индикатор загрузки
out = append(out, ItemUI{
ID: "loading-footer",
Type: ItemTypeLoading,
Title: "Загрузка...",
})
}
return out
}
Здесь я добавляю «служебный» элемент в ui-slice, а UI просто знает, что Type=ItemTypeLoading нужно отрисовать как спиннер.
Группировка и секции
Если у вас есть секции, ui-slice по-прежнему остается линейным слайсом, но каждый заголовок секции – отдельный элемент.
// BuildSectionedSlice создает список задач, разбитых по статусу
func BuildSectionedSlice(tasks []Task) UISlice {
var out UISlice
pending := filterTasksByStatus(tasks, StatusPending)
done := filterTasksByStatus(tasks, StatusDone)
if len(pending) > 0 {
out = append(out, ItemUI{
ID: "header-pending",
Type: ItemTypeHeader,
Title: "В работе",
})
out = append(out, mapTasksToItems(pending)...)
}
if len(done) > 0 {
out = append(out, ItemUI{
ID: "header-done",
Type: ItemTypeHeader,
Title: "Завершенные",
})
out = append(out, mapTasksToItems(done)...)
}
if len(out) == 0 {
out = append(out, ItemUI{
ID: "empty",
Type: ItemTypeEmptyState,
Title: "Нет задач",
})
}
return out
}
Такой подход позволяет легко:
- перестраивать секции;
- добавлять фильтры и сортировку;
- тестировать генерацию списка без UI.
Обработка ошибок и состояний загрузки
Локальные ошибки по элементам
Бывают операции, привязанные к одному элементу: «удалить задачу», «отправить сообщение». Здесь ui-slice хорошо помогает отображать локальные состояния и ошибки.
// WithItemLoading устанавливает флаг Loading для одного элемента
func (s UISlice) WithItemLoading(id string, loading bool) UISlice {
out := make(UISlice, len(s))
copy(out, s)
for i := range out {
if out[i].ID == id {
out[i].Loading = loading
if loading {
// Сбрасываем предыдущую ошибку при новой попытке
out[i].ErrorText = ""
}
break
}
}
return out
}
// WithItemError устанавливает текст ошибки для одного элемента
func (s UISlice) WithItemError(id string, errText string) UISlice {
out := make(UISlice, len(s))
copy(out, s)
for i := range out {
if out[i].ID == id {
out[i].Loading = false
out[i].ErrorText = errText
break
}
}
return out
}
Смотрите, я специально сбрасываю Loading при установке ошибки. Так UI легко понимает, что операция завершилась и можно показать ошибку.
Глобальные состояния списка
Кроме локальных ошибок, у списка может быть глобальное состояние:
- loading (первичная загрузка);
- error (ошибка загрузки всего списка);
- empty (нет данных).
Обычно эти состояния выносятся в отдельную структуру:
// UIState описывает общее состояние экрана со списком
type UIState struct {
Items UISlice // Собственно ui-slice
Loading bool // Идет ли глобальная загрузка
ErrorText string // Текст глобальной ошибки (если есть)
CanRetry bool // Можно ли повторить
EmptyText string // Текст для пустого состояния
Refreshing bool // Флаг "pull to refresh"
HasMoreData bool // Есть ли еще страницы для догрузки
}
Контроллер может поддерживать UIState:
type ListController struct {
ui UIUpdater
state UIState
}
func (c *ListController) LoadInitial() {
// Устанавливаем состояние загрузки
c.state.Loading = true
c.state.ErrorText = ""
c.state.Items = nil
c.updateUI()
// Здесь запускаем асинхронную загрузку
go c.loadFromServer()
}
func (c *ListController) loadFromServer() {
tasks, err := fetchTasks()
if err != nil {
// В случае ошибки обновляем состояние
c.state.Loading = false
c.state.ErrorText = "Не удалось загрузить список"
c.updateUI()
return
}
// В случае успеха строим ui-slice
c.state.Loading = false
c.state.ErrorText = ""
c.state.Items = NewUISlice(tasks)
if len(tasks) == 0 {
c.state.EmptyText = "Нет задач"
}
c.updateUI()
}
func (c *ListController) updateUI() {
// UIUpdater может принимать сразу весь UIState,
// а не только список элементов
c.ui.RenderList(c.state)
}
Здесь я показываю общий подход: ui-slice отвечает за структуру списка, а UIState – за «рамку» вокруг него (загрузка, ошибки, пустое состояние).
Конкурентный доступ и безопасность
Почему слайс нужно защищать
Если вы пишете на Go, вы почти наверняка столкнетесь с конкурентными операциями:
- одна горутина загружает данные с сервера и обновляет ui-slice;
- другая горутина обрабатывает клики пользователя и тоже меняет ui-slice;
- третья горутина рендерит UI на основании ui-slice.
Слайс в Go – это структура, которая содержит:
- указатель на массив элементов;
- длину;
- емкость.
Если вы одновременно меняете его из нескольких горутин без синхронизации, вы легко получите data race.
Использование мьютекса
Самый прямой способ – защитить ui-slice мьютексом.
type SafeUIStore struct {
mu sync.RWMutex
data UIState
}
// Get возвращает копию текущего UIState
func (s *SafeUIStore) Get() UIState {
s.mu.RLock()
defer s.mu.RUnlock()
// Здесь важно скопировать данные, а не отдавать ссылку
state := s.data
// Копируем слайс, чтобы вызывающий не мог его изменить
if state.Items != nil {
itemsCopy := make(UISlice, len(state.Items))
copy(itemsCopy, state.Items)
state.Items = itemsCopy
}
return state
}
// Update применяет функцию-мутацию к состоянию
func (s *SafeUIStore) Update(fn func(state *UIState)) {
s.mu.Lock()
defer s.mu.Unlock()
fn(&s.data)
}
Теперь любые изменения состояния проходят через Update:
// Пример использования хранилища
func (c *ListController) OnItemClicked(id string) {
c.store.Update(func(state *UIState) {
state.Items = state.Items.WithToggledSelection(id)
})
c.updateUIFromStore()
}
func (c *ListController) updateUIFromStore() {
state := c.store.Get()
c.ui.RenderList(state)
}
Здесь вы видите, что:
- мы не отдаем наружу ссылку на внутренний слайс;
- любые изменения проходят в критической секции.
Тестирование ui-slice
Почему ui-slice удобно тестировать
Вся логика работы со списком:
- выбор,
- фильтрация,
- группировка,
- отображение ошибок,
может быть описана чистыми функциями, которые:
- принимают ui-slice или UIState;
- возвращают новый ui-slice или UIState;
- не привязаны к UI-фреймворку.
Это значит, что вы можете писать обычные unit-тесты.
Пример теста для операций над ui-slice
// Здесь мы показываем пример простого теста для WithToggledSelection
func TestUISlice_WithToggledSelection(t *testing.T) {
// Исходное состояние
s := UISlice{
{ID: "1", Title: "A", Selected: false},
{ID: "2", Title: "B", Selected: true},
}
// Действие
got := s.WithToggledSelection("1")
// Проверяем, что первый элемент стал выбранным
if !got[0].Selected {
t.Errorf("expected item 1 to be selected")
}
// Проверяем, что второй элемент остался выбранным
if !got[1].Selected {
t.Errorf("expected item 2 to remain selected")
}
// Проверяем, что исходный слайс не изменился
if s[0].Selected {
t.Errorf("expected original slice to remain unchanged")
}
}
Тест для построения секций:
func TestBuildSectionedSlice_Empty(t *testing.T) {
var tasks []Task
got := BuildSectionedSlice(tasks)
if len(got) != 1 {
t.Fatalf("expected 1 item, got %d", len(got))
}
if got[0].Type != ItemTypeEmptyState {
t.Fatalf("expected first item to be empty state, got %v", got[0].Type)
}
}
Такие тесты быстро выполняются и хорошо документируют поведение ui-slice.
Практические рекомендации по проектированию ui-slice
1. Всегда используйте стабильный ID
ID должен быть:
- уникальным для списка;
- постоянным между обновлениями данных.
Это важно для:
- сопоставления элементов между старым и новым состоянием;
- корректной анимации (если ваш UI-фреймворк ее поддерживает);
- правильной обработки событий (клик по элементу с ID X).
Не используйте индекс в слайсе как ID, если порядок элементов может меняться.
2. Четко разделяйте доменную модель и UI-модель
Доменные структуры (Task, ChatMessage, Product):
- описывают данные, как они есть в бизнес-логике;
- не содержат флагов выбора/состояний UI.
UI-модель (ItemUI):
- описывает, как данные отображаются;
- может содержать дополнительные поля для состояния интерфейса;
- строится из доменной модели с помощью функций-конвертеров.
Это упрощает:
- рефакторинг UI;
- изменение визуального представления без изменения домена;
- написание тестов, в которых вы работаете только с ui-slice.
3. Операции над ui-slice делайте маленькими и специализированными
Вместо одной «универсальной» функции, которая делает все сразу, лучше иметь набор простых:
- WithToggledSelection
- WithItemLoading
- WithItemError
- WithLoadingFooter
- WithRemovedByID
- BuildSectionedSlice
Так их проще:
- комбинировать;
- понимать;
- тестировать.
4. Будьте осторожны с мутацией «на месте»
Если вы выбираете мутабельный стиль:
- не изменяйте слайс, который может использоваться еще где-то;
- по возможности копируйте слайс перед изменением, если он передается между слоями;
- защищайте общий слайс мьютексом при конкурентном доступе.
5. Прописывайте правила обновления UI
Договоритесь в команде:
- откуда можно менять ui-slice (одна точка входа – контроллер/стор);
- как UI узнает об изменениях (колбэки, каналы, события);
- как обрабатываются состояния загрузки и ошибок.
Это позволит вам легко поддерживать и развивать код, не теряя контроль над состоянием интерфейса.
Заключение
Паттерн «Слайс UI» (ui-slice) опирается на идею, что состояние списка в интерфейсе должно быть представлено как простой слайс структур, а все изменения списка – как предсказуемые операции над этим слайсом.
Вы видели, как:
- описать элемент UI как структуру с полями для отображения и состояния;
- собирать из доменных моделей ui-slice и использовать его как источник правды;
- реализовать операции над ui-slice в иммутабельном и мутабельном стиле;
- добавить в список сложные элементы – заголовки секций, разделители, загрузочные хвосты;
- описать глобальное состояние экрана с помощью UIState;
- защитить ui-slice при конкурентном доступе;
- удобно тестировать логику работы со списком.
Такой подход делает код UI слоя:
- прозрачным – вы всегда видите, как именно формируется список;
- безопасным – меньше шансов получить «рассинхрон» между данными и отображением;
- гибким – вы легко меняете представление списка, не трогая доменную модель;
- тестируемым – почти вся логика ui-slice проверяется обычными unit-тестами.
Если вы строите приложения со сложными списками, ui-slice может стать базовым строительным блоком архитектуры UI и заметно упростить жизнь при развитии и поддержке проекта.
Частозадаваемые технические вопросы по теме
Вопрос 1. Как обновлять один элемент ui-slice, если обновление приходит с сервера только по его ID
Самый удобный способ – сделать функцию, которая заменяет один элемент по ID:
// UpdateItemByID возвращает новый UISlice, в котором элемент с нужным ID заменен
func (s UISlice) UpdateItemByID(id string, update func(item ItemUI) ItemUI) UISlice {
out := make(UISlice, len(s))
copy(out, s)
for i, it := range out {
if it.ID == id {
// Здесь вы вызываете функцию, которая вернет обновленную копию элемента
out[i] = update(it)
break
}
}
return out
}
Использование:
slice = slice.UpdateItemByID("42", func(item ItemUI) ItemUI {
item.Title = "Обновленный заголовок"
item.ErrorText = ""
return item
})
Так вы не нарушаете инварианты и не трогаете остальные элементы.
Вопрос 2. Как хранить в ui-slice временные ID для еще не сохраненных на сервере элементов
Используйте два поля:
- ID (строка от сервера или пустая строка);
- LocalID (генерируется на клиенте, уникален в пределах устройства).
UI-слой всегда работает с LocalID, а когда сервер вернет настоящий ID, вы:
- находите элемент по LocalID;
- заполняете поле ID;
- при следующей синхронизации ищете по ID.
Так вы не потеряете соответствие между локальными и серверными объектами.
Вопрос 3. Как организовать пагинацию в ui-slice без сильного усложнения
Держите в UIState флаги:
- HasMoreData – есть ли еще страницы;
- LoadingMore – идет ли догрузка.
В самом ui-slice добавляйте в конец элемент типа ItemTypeLoading, когда LoadingMore=true. При получении новой страницы:
- удаляете хвост загрузки (WithLoadingFooter(false));
- аппендите новые элементы;
- снова добавляете хвост, если HasMoreData=true.
UI просто рисует хвост как «спиннер внизу списка».
Вопрос 4. Как избежать постоянного копирования больших слайсов при иммутабельном стиле
Если количество элементов велико, а обновления локальны, используйте стратегию:
- не копировать весь слайс, а копировать только при изменении;
- по возможности использовать «копирование по частям» – например, copy для префикса и суффикса, если меняется только одна позиция;
- рассмотреть использование структур-персистентных коллекций (на других языках) или аккуратно использовать мутабельные операции с четкими границами владения слайсом.
Для Go баланс обычно такой: иммутабельный стиль на уровне бизнес-логики и ограниченная мутация внутри контроллера под мьютексом.
Вопрос 5. Как лучше хранить состояние раскрытия/сворачивания элементов (expanded) в ui-slice
Добавьте в ItemUI флаг Expanded и управляйте им через отдельную операцию:
func (s UISlice) WithToggledExpanded(id string) UISlice {
out := make(UISlice, len(s))
copy(out, s)
for i := range out {
if out[i].ID == id {
out[i].Expanded = !out[i].Expanded
break
}
}
return out
}
UI-слой при рендеринге смотрит на Expanded и выбирает нужный вид (например, показывает дополнительный текст или вложенный список). Такой флаг живет полностью в ui-slice и не влияет на доменную модель.