---
title: "Команды tea.Cmd: асинхронные операции"
description: "tea cmd bubbletea: команды для асинхронных операций — тип func() tea.Msg, возврат из Init и Update, свои сообщения-результаты и почему горутины писать не нужно."
course: "Bubble Tea: TUI-приложения в терминале"
url: "https://golangmaster.ru/courses/bubbletea/lessons/05-tea-cmd/"
published: "2026-09-19"
updated: "2026-10-09"
minutes: 20
---

# Команды tea.Cmd: асинхронные операции

*Курс «Bubble Tea: TUI-приложения в терминале», урок 5 из 25. ~20 минут. Обновлено 09.10.2026. Источник: https://golangmaster.ru/courses/bubbletea/lessons/05-tea-cmd/*

Команды tea.Cmd — механизм асинхронных операций в bubbletea: загрузка данных, ожидание таймера, запись файла, пока терминальный интерфейс продолжает отвечать на клавиши. Разберём, почему команда — это просто func() tea.Msg, как вернуть её из Init и Update и почему собственные горутины вам почти не понадобятся.

Счётчик из второго урока отзывался мгновенно — и не потому, что он маленький. Update в Bubble Tea обрабатывает сообщения по одному: пришло нажатие, инкремент, перерисовка — всё за микросекунды. Теперь представьте, что по нажатию r модель должна сходить в API за списком задач. Запрос занимает полсекунды в хорошем случае и десять секунд в плохом. Если эта работа случится прямо внутри Update, весь терминальный интерфейс замрёт: клавиши не обрабатываются, q не работает, картинка стоит. Пользователь такое не прощает. Хуже того: очередь событий продолжит копить нажатия, и после разморозки они обрушатся на Update лавиной — «залипшая» клавиатура вам обеспечена. Bubble Tea решает проблему архитектурно: долгую работу объявляют командой — значением типа tea.Cmd, — фреймворк уводит её в сторону, а в Update приходит только сообщение-результат, когда работа закончена. Сегодня пройдём механизм целиком: от сигнатуры до правил и ошибок.

### Команда — это func() tea.Msg

Определение умещается в одну строку: tea.Cmd — это тип func() tea.Msg. Обычная функция без аргументов, которая возвращает сообщение — значение любого типа. Никаких интерфейсов, колбэков и промисов: асинхронная команда в bubbletea — замыкание, которое выполнит работу и вернёт результат. Что важно понимать сразу. Команду не вызывают сами — её возвращают из Init или Update, а цикл событий запускает её в собственной горутине. Пока команда работает, Update свободен и продолжает ловить клавиши. Когда функция завершается, её возвращаемое значение попадает в очередь сообщений и обрабатывается как обычное событие — тем же тип-свитчем, что и нажатия клавиш. Тип сообщения вы придумываете сами: resultMsg, tasksLoadedMsg, fileSavedMsg — соглашение простое, имя должно отвечать на вопрос «что случилось». Одно событие — одно сообщение: не пытайтесь упаковать в него три разных факта, дробите на отдельные типы. В elm архитектуре такие значения зовутся сообщениями, и это не украшение: сообщение — факт, который уже произошёл, а не просьба что-то сделать.

### Свой тип сообщения и команда загрузки

```go
type tasksLoadedMsg struct {
        tasks []Task
        err   error
}

func fetchTasksCmd() tea.Cmd {
        return func() tea.Msg {
                tasks, err := store.LoadTasks()
                if err != nil {
                        return tasksLoadedMsg{err: err}
                }
                return tasksLoadedMsg{tasks: tasks}
        }
}
```

tasksLoadedMsg — тип-результат: успех и ошибка едут одним сообщением, просто в разных полях. fetchTasksCmd возвращает не результат, а замыкание: фреймворк вызовет его позже, в своей горутине. Внутри — обычный Go-код: поход в хранилище, разбор ошибки, упаковка в сообщение. Ошибку не печатаем и не паникуем — её увидит Update и решит, что показывать.

### Возврат команды из Init и Update

Команда живёт в двух местах, оба вы уже видели, просто там стоял nil. Init возвращает команду, которая должна выполниться при старте: загрузить настройки, проверить соединение, запустить первые часы. Update возвращает команду вслед за моделью — обычно как реакцию на конкретное событие: пользователь нажал r — верните fetchTasksCmd, пришло сообщение загрузки — верните команду сохранения. Ноль означает «асинхронной работы не заказано»: nil — штатное значение, а не ошибка. Отсюда ясная картина потока: событие → Update → (модель, команда) → горутина → сообщение → Update. Цикл может закручиваться сколько угодно: каждая команда способна породить следующую. И помните про потолок: один Update обрабатывает одно сообщение и возвращает максимум одну команду. Как упаковать несколько — тема седьмого урока про tea.Batch и tea.Sequence. И практический совет по чтению чужого кода: команды ищутся по возвращаемому типу — grep по tea.Cmd показывает все асинхронные точки программы за секунду, это удобнее, чем вылавливать голые go func().

### Горутины нужны фреймворку, не вам

Внутри Bubble Tea каждый вызов команды уходит в отдельную горутину: фреймворк запускает её, ждёт tea.Msg и проталкивает результат в очередь событий. Вам писать go func() не нужно — и в типичном приложении не нужно вообще. Это принципиальное отличие от окружающего мира: в обычном Go вы сами следите, чтобы каналы из [урока про горутины](/courses/go-advanced/lessons/04-gorutiny/) закрывались, а гонки — разруливались. Здесь ответственность разделена: команда делает одну вещь и возвращает одно сообщение, остальное — забота цикла событий. Когда горутина всё-таки нужна? Для долгоживущего фона: слушатель очереди, подписка на WebSocket, чтение лога из потока — то, что не заканчивается одним результатом. Такие воркеры живут вне команд и общаются с программой через p.Send из третьего урока. Промежуточные случаи — пачки одноразовых задач — тоже решаются командами, просто упакованными вместе: об этом седьмой урок. Правило короткое: одноразовая работа — команда, бесконечный поток — горутина с Send.

> **Не блокируйте Update.** time.Sleep, запрос сети, чтение файла внутри Update замораживают весь интерфейс: сообщения копятся в очереди, но не обрабатываются. Кажется очевидным — пока не сделаешь сам. Долгая работа живёт в команде; Update только решает, что делать с результатом.

### Программа целиком: загрузка с состоянием

```go
type model struct {
        loading bool
        tasks   []Task
        err     error
}

func (m model) Init() tea.Cmd {
        return fetchTasksCmd()
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
        switch msg := msg.(type) {
        case tasksLoadedMsg:
                m.loading = false
                m.err = msg.err
                m.tasks = msg.tasks
                return m, nil
        case tea.KeyMsg:
                if msg.String() == "q" {
                        return m, tea.Quit
                }
        }
        return m, nil
}

func (m model) View() string {
        if m.err != nil {
                return "Ошибка: " + m.err.Error()
        }
        if m.loading {
                return "Загружаю задачи..."
        }
        return fmt.Sprintf("Задач: %d, q — выход\n", len(m.tasks))
}

func main() {
        m := model{loading: true}
        if _, err := tea.NewProgram(m).Run(); err != nil {
                fmt.Println("Ошибка:", err)
                os.Exit(1)
        }
}
```

Проследите флаг loading: Init вернул команду, фреймворк запустил её, и до прихода tasksLoadedMsg View показывает «Загружаю...». Сообщение загрузки обрабатывается тем же switch, что и клавиши, — отдельного потока, очереди или колбэка в вашей программе нет. В реальном приложении вместо статичной строки здесь будет спиннер из шестого урока.

### Правила команды: что можно и что нельзя

Внутри команды действуют три запрета. Первый: не трогайте модель. Команда получает данные при создании — через параметры фабрики — и возвращает результат сообщением; прямой доступ к состоянию ломает elm архитектуру и ведёт к гонкам данных. Второй: не печатайте в терминал. Пока программа работает, вывод принадлежит рендереру — fmt.Println из команды вклинит строку в кадр, ровно как из любой горутины. Третий: не тяните глобальное состояние. Команда, которая пишет в пакетную переменную, при повторном запуске удивит вас значением из прошлого раза — я однажды ловил именно такой баг полдня. Что можно: обычный Go — клиенты HTTP, запросы к базе, файлы, контексты с таймаутами. Аргументы передавайте фабрикой: func(id int) tea.Cmd { return func() tea.Msg { ... } } — внешняя функция принимает параметр и возвращает готовую команду с захваченным значением.

### Делегирование: Update не превращается в свалку

С появлением собственных сообщений switch в Update растёт: клавиши, тики, результаты загрузок, ответы сохранений. К пятому-шестому типу в одном switch начинаются потери. Первое правило гигиены — проверку сообщения, checkingMsg, всегда делайте type-свитчем: case tasksLoadedMsg, case tea.KeyMsg — компилятор не даст перепутать типы, и это одна из главных выгод elm архитектуры на Go. Второе — делегирование: сообщение обрабатывает тот, кому оно адресовано. В маленькой модели достаточно приватных методов: m.handleKeys(msg), m.handleResult(msg) — Update превращается в диспетчер на пять строк. Отдельный признак беды — события-строки: если вы различаете сообщения по полю внутри общей структуры, остановитесь и заведите отдельный тип. Тип — это одновременно маршрутизация и документация. В больших моделях сообщениями распоряжаются подмодели — этому посвящён восьмой урок. Третье: не плодите поля модели под каждый случай; сообщение несёт данные, Update раскладывает их по существующим полям.

### Команда против горутины с p.Send

| Ситуация | Инструмент | Почему |
| --- | --- | --- |
| Один запрос к API или базе | tea.Cmd | один результат — одно сообщение, горутину запустит фреймворк |
| Таймеры и тики | tea.Cmd через tea.Tick | команда планирует следующее событие сама |
| Чтение файла при старте | tea.Cmd из Init | результат нужен один раз, до первого кадра |
| Поток событий: WebSocket, tail лога | горутина + p.Send | команда заканчивается, а поток живёт бесконечно |

### Ошибки из асинхронных операций

Ошибка в команде — не повод паниковать и не повод молчать. Паника внутри горутины уронит программу целиком, а проигнорированный err превратится в интерфейс, который «просто ничего не загрузил». Рабочая схема старая, как сам Go: ошибка едет в сообщении. Либо отдельный тип errMsg с полем err, либо — как в примере выше — поле err в сообщении-результате. Update складывает её в модель, View решает, что показывать: строку об ошибке, тост, кнопку повтора. Бонус подхода в том, что ошибку можно обрабатывать как событие: показать сообщение и через пару секунд стереть, предложить повтор той же командой. В примерах Charm встречаются оба варианта — и отдельный errMsg, и поле в resultMsg; оба каноничны. Ещё антипаттерн — возвращать nil при ошибке: Update не узнает, что что-то случилось, и покажет вечную загрузку. Если ошибка возможна, она обязана доехать до модели. Выберите одну схему на проект и не смешивайте.

### Контекст: как отменять затянувшуюся работу

Команда ушла и висит: запрос к API не отвечает три минуты, пользователь смотрит на надпись «Загружаю» и жмёт Esc. Отмена в Go делается контекстом, и в командах он работает как обычно: фабрика принимает ctx context.Context, замыкание передаёт его в запрос — http.NewRequestWithContext, операции с базой, файловое чтение. Остаётся вопрос: кто вызовет cancel()? Честных схемы две. Первая: контекст с таймаутом создаётся внутри самой команды — работа гарантированно закончится и вернёт сообщение с ctx.Err(), никаких висячих запросов. Вторая: cancel хранится в модели, Update по Esc его вызывает, команда возвращается раньше срока. Второй вариант отзывчивее, но требует дисциплины — поле cancel в модели живёт ровно до следующего запуска команды, иначе отменит не то. Для старта берите первую схему: таймаут внутри команды закрывает большую часть случаев, а живую отмену добавите, когда она действительно понадобится пользователю.

> **Где запускать примеры.** Код этого урока — настоящий bubbletea-код, ему нужен локальный терминал: go mod init, go get github.com/charmbracelet/bubbletea и go run main.go. Песочница сайта показывает логику на виртуальном терминале, но задержки сети и клавиши честно живут только в go run.

### Проверка: анатомия команды

**Вопрос:** Какой тип у tea.Cmd?

- ✅ func() tea.Msg
- func(tea.Model) tea.Msg
- chan tea.Msg
- interface{ Run() tea.Msg }

tea.Cmd — тип-функция: без аргументов, возвращает сообщение. Фреймворк вызывает её в отдельной горутине, а возвращённое значение приходит в Update как обычное событие.

### Проверка: nil как ответ

**Вопрос:** Пользователь нажал клавишу, асинхронная работа не нужна. Что возвращает Update?

- Пустую команду-заглушку
- ✅ nil вторым значением
- Паникует
- Команду tea.Quit на всякий случай

nil означает «работы не заказано» — это штатное значение, а не ошибка. Каждое нажатие не обязано порождать асинхронную работу: лишняя команда — лишняя горутина и лишнее сообщение.

### Команда, которая планирует сама себя

```go
type countMsg int

func bumpCmd() tea.Cmd {
        return func() tea.Msg { return countMsg(1) }
}

func (m model) Init() tea.Cmd {
        return bumpCmd()
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
        switch msg := msg.(type) {
        case countMsg:
                m.n += int(msg)
                return m, bumpCmd()
        case tea.KeyMsg:
                if msg.String() == "q" {
                        return m, tea.Quit
                }
        }
        return m, nil
}
```

**Что выведет программа?**

- n вырастет один раз и остановится
- ✅ n будет расти, пока не нажмёте q: команда сама планирует следующую
- n останется нулём
- Программа упадёт от переполнения стека

Обработка countMsg возвращает новую bumpCmd — сообщение снова уходит в очередь, цикл замыкается. Это не ошибка, а приём: так работают тики часов и спиннеров. Выход — по q через tea.Quit.

### Заполните пропуски в команде

Восстановите сигнатуру команды, имя сообщения и вызов фабрики из Init.

```go
func loadStats() tea.___ {
        return func() tea.Msg {
                return stats___Msg{rows: rows}
        }
}

func (m model) Init() tea.Cmd {
        return ___()
}
```

Подсказка: Сигнатура команды — tea.Cmd; по конвенции сообщение зовётся statsLoadedMsg; из Init возвращают саму фабрику loadStats().

Правильные ответы в пропусках: `Cmd`, `Loaded`, `loadStats`

### Соберите команду fetchData

Расставьте строки команды в правильном порядке: тип сообщения, фабрика, замыкание и результат.

Правильный порядок строк:

1. `type dataMsg struct{ payload string }`
2. `func fetchData() tea.Cmd {`
3. `  return func() tea.Msg {`
4. `    return dataMsg{payload: "готово"}`
5. `  }`
6. `}`

### Карточки: словарь асинхронности

- **tea.Cmd** — Тип func() tea.Msg: асинхронная работа, упакованная в значение. Возвращают из Init и Update; фреймворк запускает её в горутине.
- **tea.Msg** — Любое значение-событие: клавиша, тик, результат загрузки. Update разбирает их type-свитчем по типам.
- **Асинхронная команда** — Команда, выполняющаяся вне Update: интерфейс не блокируется, результат приходит сообщением в общий цикл событий.
- **p.Send** — Мост из горутин в работающую программу. Для долгоживущих потоков — да; для одноразовой работы — команда.
- **errMsg** — Сообщение-ошибка: err едет полем в resultMsg или отдельным типом. View решает, что показать; паниковать в команде нельзя.

### Что дальше

Теперь ваши программы умеют ждать, не замирая. Дальше — показывать ожидание: [шестой урок про спиннеры](/courses/bubbletea/lessons/06-spinner/) подключает компонент bubbles, который тикает как раз командами этого урока. Когда понадобится запустить несколько команд разом — спиннер плюс загрузка, — открывайте [седьмой урок про tea.Batch и tea.Sequence](/courses/bubbletea/lessons/07-batch-sequence/). А настоящий веб-запрос с состояниями загрузки и ошибки ждёт в [уроке про HTTP-данные](/courses/bubbletea/lessons/19-http-dannye/). Полная программа — на странице [курса Bubble Tea](/courses/bubbletea/). Домашняя практика простая: напишите команду сохранения, подложите в неё time.Sleep на три секунды и убедитесь, что q работает даже во время «долгой» записи — локальный терминал покажет правду о задержках быстрее любых слов.

## Коротко: что запомнить

- tea.Cmd — это func() tea.Msg: команду возвращают из Init и Update, а фреймворк запускает её в горутине.
- Результат команды — сообщение-значение; Update обрабатывает его тем же type-свитчем, что и клавиши.
- nil в качестве команды — штатное «работы не заказано», а не ошибка.
- Update не блокируется: долгая работа живёт в команде, а не в обработчике события.
- Ошибка едет в сообщении — errMsg или поле err; паника в команде роняет программу целиком.
- Одноразовая работа — команда; бесконечный поток событий — горутина с p.Send.

## Домашнее задание

Добавьте к программе урока команду saveTasksCmd, которая «сохраняет» список и возвращает savedMsg. Пусть нажатие s запускает сохранение, а View показывает статус. Затем подложите в команду time.Sleep на три секунды и запустите локально: убедитесь, что q по-прежнему работает, пока идёт запись. Если интерфейс замер — перечитайте callout и найдите, куда просочилась работа.

## Вопросы и ответы по уроку

### Почему Update не блокируется при асинхронной операции?

Потому что операция выполняется не в Update: команда уходит в горутину фреймворка, а в Update приходит готовый результат-сообщение. Update обрабатывает сообщения по одному и всегда быстро возвращается.

### Нужны ли свои горутины в Bubble Tea приложении?

Для одноразовых операций — нет: их запускает фреймворк под каждую команду. Свои горутины нужны для долгоживущих потоков событий, и общаются они с программой через p.Send, не трогая модель напрямую.

### Как вернуть из Init несколько команд сразу?

Init возвращает одну команду, но её можно собрать из нескольких: tea.Batch запускает их параллельно, tea.Sequence — по очереди. Механика и ловушки — в седьмом уроке курса.
