---
title: "Таймеры и реальное время: tea.Tick"
description: "tea tick bubbletea: идиома tea.Tick для часов в шапке, таймауты операций с генерацией попыток, деградация частоты тиков и сравнение с time.After на Go."
course: "Bubble Tea: TUI-приложения в терминале"
url: "https://golangmaster.ru/courses/bubbletea/lessons/21-tajmery/"
published: "2026-09-19"
updated: "2026-10-09"
minutes: 16
---

# Таймеры и реальное время: tea.Tick

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

Идиома tea tick в bubbletea выглядит как одна строка — tea.Tick с функцией, возвращающей сообщение, — но на ней стоят часы в шапке дашборда, таймауты операций и всякое реальное время TUI. Разберём самообновляющуюся цепочку тиков, сообщение, несущее момент времени, дедлайны с защитой от устаревших срабатываний и деградацию частоты, когда экран простаивает. В финале — честное сравнение с time.After и Ticker.

Часы в шапке дашборда показывают время старта программы и замирают. Модель ничего плохого не делала — она просто не знает, что время идёт: Bubble Tea перерисовывает экран только по сообщениям, а сообщений о времени ей никто не присылает. Механика команд из пятого урока умеет планировать будущее: команда — это func() tea.Msg, который фреймворк достанет в горутине через сколько угодно миллисекунд. Спиннер из шестого урока уже ездил на этом приёме, но там цепочку тиков прятал компонент. Здесь достанем её на поверхность: tea.Tick из пакета bubbletea — таймер tui в каноническом виде, и вокруг него собираются три задачи урока. Часы в шапке, которые тикают каждую секунду. Дедлайн операции, который честно объявляет ошибку, когда сервер молчит. И частота тиков, которая снижается, когда экран простаивает, — потому что каждый кадр это работа процессора и пакет по SSH. Тот же механизм вращает прогресс-бары из двенадцатого урока и любое автообновление — класс один, и разобрав его здесь, вы закрываете сразу весь список.

### tea.Tick: команда по расписанию

Сигнатура короткая: tea.Tick(d, fn) принимает длительность и функцию func(time.Time) tea.Msg, а возвращает обычную команду. Верните её из Init или Update — фреймворк запустит, подождёт d и вызовет fn с текущим моментом времени; что fn вернёт, то и приедет в Update сообщением. Ключевое слово — одноразовость: одна команда стреляет один раз. Цепочка держится на самозапланировании, которое вы уже видели у спиннера: ветка tickMsg в Update обязана вернуть новую команду тика, иначе через секунду цепочка оборвётся и часы замрут. Сообщение принято объявлять типом-обёрткой над временем — type tickMsg time.Time: так оно не спутается с сырым time.Time, а внутри модели момент достаётся приведением time.Time(msg). Время в сообщении — не украшение: именно оно позволяет View показать момент события, а логам — понять, когда тик опоздал. Опоздания, кстати, реальны: под нагрузкой терминал собирает кадры, и секунда между тиками иногда превращается в полторы.

### Сообщение-время и фабрика тика

```go
type tickMsg time.Time

func tickCmd() tea.Cmd {
        return tea.Tick(time.Second, func(t time.Time) tea.Msg {
                return tickMsg(t)
        })
}

func (m dashModel) Init() tea.Cmd {
        return tea.Batch(tickCmd(), m.list.Tick)
}
```

tickMsg — свой тип над time.Time, чтобы путаницы с сырым временем не было. Фабрика tickCmd скрывает интервал: сменить частоту — правка одной строки. Init объединяет собственный тик с командой готового компонента через Batch из седьмого урока.

### Часы в шапке

Модель хранит последний момент — поле now time.Time, — ветка tickMsg его обновляет и возвращает следующий тик, View форматирует. Конструктор ставит now в time.Now(): без этого до первого тика шапка покажет нулевое время рандомной эпохи, а целую секунду — заметно долго. Формат по-русски привычен: m.now.Format("15:04:05") выдаёт часы, минуты и секунды; раскладка шапки — занятие урока про раскладку Lip Gloss, здесь достаточно строки. Заметьте разделение труда: модель знает момент, View решает, как его показывать, а команда не знает ничего о том, зачем её позвали. Из-за этого часы можно переставить в футер или выкинуть совсем — правка затрагивает View и не трогает таймер. Проверять ход часов в песочнице бессмысленно — время эмулирует только живой терминал, поэтому запускайте локально: time.Second уменьшите до пары сотен миллисекунд, и бегущие цифры покажут и частоту, и опоздания кадров наглядно. Одна деталь, которую стоит видеть своими глазами: при потере фокуса окна терминал может приостановить отрисовку, и после возврата цифры прыгнут — модель ни при чём, догонять кадры не нужно.

### Ветка тика и строка шапки

```go
case tickMsg:
        m.now = time.Time(msg)
        m.beats++
        return m, tickCmd()

// во View:
header := fmt.Sprintf("Панель задач · %s",
        m.now.Format("15:04:05"))
```

Три строки ветки — обновить момент, посчитать тик, запланировать следующий. Забытый return tickCmd() — главная ошибка темы: компилятор молчит, программа жива, только часы стоят. Форматирование живёт во View: момент в модели, представление — отдельно.

### Проверка: чем живёт цепочка

**Вопрос:** Часы тикали минуту и замерли на одном времени, программа продолжает работать. Что случилось?

- ✅ Ветка tickMsg перестала возвращать новую команду tickCmd
- time.Time в модели переполнился
- Format получил неверный шаблон
- Цепочка умерла — Batch не принимает команды без аргументов

Каждый тик живёт ровно одну команду: ветка обязана вернуть tickCmd() заново. Потерянный return — тихая смерть анимации: ни ошибки, ни паники, экран просто замирает. Тот же механизм вы останавливали спиннеру в шестом уроке — там это была цель, здесь авария.

### Таймауты операций

Вторая работа таймера — дедлайны. Запрос ушёл на сервер — запланируйте рядом команду-таймаут: tea.Tick на пять секунд возвращает timeoutMsg, и если ответ не успел, ветка переводит модель в состояние ошибки с честным текстом «сервер молчит дольше пяти секунд». Для HTTP правильнее контекст из девятнадцатого урока — клиент отменяет запрос физически; таймер в модели нужен для произвольных операций: долгая индексация, воркер без таймаута, ответ от внешней программы. Но у таймера есть подвох, которого у контекста нет: он стреляет всегда, даже когда уже не нужен. Пользователь нажал r — загрузка перезапущена — а старый таймер первой попытки всё ещё в полёте и через пять секунд влепит ошибку в успешно загруженные данные. Лечение — метка принадлежности: у модели счётчик попыток gen, каждая команда-таймер уносит номер своей попытки, и ветка сравнивает msg.gen с текущим. Чужой таймер молча сгорает: return m, nil. Три строки страховки против загадочного флота ошибок через полминуты после успешной загрузки.

### Дедлайн с генерацией попытки

```go
func loadWithTimeout(url string, gen int) tea.Cmd {
        return tea.Batch(
                fetchItemsCmd(url),
                tea.Tick(5*time.Second, func(time.Time) tea.Msg {
                        return timeoutMsg{gen: gen}
                }),
        )
}

case timeoutMsg:
        if msg.gen != m.gen {
                return m, nil
        }
        m.state = stateError
        m.err = errors.New("сервер молчит дольше пяти секунд")
        return m, nil
```

Batch запускает запрос и дедлайн параллельно: кто первый — тот и владеет исходом. Ветка timeoutMsg верит только таймеру своей попытки; устаревший gen — сигнал, что дедлайн опоздал, и экран не трогаем. Ответ fetchItemsCmd сбрасывает состояние и гасит опасения симметрично.

> **Таймер-зомби.** Уберите сравнение gen — и получите классический флот: загрузка успешна, данные на экране, а через пять минут ветка timeoutMsg прошлой попытки рисует «сервер молчит». Отменить полетевший таймер нельзя — можно только не поверить ему. Счётчик попыток дешевле каждой альтернативы, которую я пробовал.

### Забытая генерация

```go
// Загрузка шла долго, пользователь нажал r:
// gen стал 2, вторая попытка успешно загрузила данные.
// Первый таймер (gen 1) стреляет только сейчас:

case timeoutMsg:
        // сравнения msg.gen с m.gen в ветке нет
        m.state = stateError
        m.err = errors.New("сервер молчит дольше пяти секунд")
        return m, nil

// Что покажет экран?
```

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

- ✅ Ошибка «сервер молчит» поверх загруженных данных
- Данные остаются, ничего не меняется
- Программа завершится — timeoutMsg требует выхода
- Таймер не стреляет: его отменили при r

Без метки принадлежности модель верит каждому timeoutMsg — и первый, опоздавший таймер роняет экран в ошибку поверх свежих данных. Отмены у tea.Tick нет: он выстрелит обязательно. Сравнение gen — единственная дешёвая защита.

### Деградация частоты

Секундный тик стоит денег: кадр перерисовывается, процессор просыпается, по SSH уходит пакет. Когда панель в фокусе и на ней смотрят часы — это цена правильная. Когда экран простаивает или свёрнут — секунда превращается в расточительность, и частоту стоит деградировать: раз в тридцать секунд вместо ежесекундно. Реализация честнее всего живёт в фабрике: интервал вычисляется из состояния модели, ветка тика просто возвращает tickCmd(m.interval()). Панель в фокусе — секунда; ушёл фокус — тридцать секунд; идёт загрузка — четверть секунды, чтобы спиннер жил. Заметьте направление зависимости: Update не решает частоту сам, он спрашивает у модели — состояние в одном месте, и тестировать интервал можно отдельной функцией без терминала. Деградация — не только про экономию: медленный тик на простаивающем дашборде означает, что возврат из скринсейвера не принесёт очередь из тридцати пропущенных кадров. Пропущенные тики догонять не нужно — опоздавший таймер просто рисует текущий момент, догонять нечего.

### Интервал из состояния

```go
func (m dashModel) interval() time.Duration {
        if m.loading {
                return 250 * time.Millisecond
        }
        if m.focused {
                return time.Second
        }
        return 30 * time.Second
}

// в ветке tickMsg:
return m, tickCmd(m.interval())
```

Три режима в одной функции: спиннер живёт на четверть секунды, активная панель — на секунде, фоновая — на полминуте. Фабрика принимает длительность параметром, поэтому смена режима — два if, а не новая ветка в Update.

### Проверка: time.After на повтор

**Вопрос:** Команда вернула tickMsg, полученный через <-time.After(d). Сколько ещё сообщений принесёт этот же time.After?

- ✅ Ни одного: time.After стреляет один раз
- Бесконечно — пока модель жива
- Ещё раз через d — канал повторяется сам
- Зависит от длительности d

time.After — одноразовый таймер: канал сработает единожды, дальше тишина. Повтор — только новая команда, как и у tea.Tick. Повторяющийся источник — это Ticker, но ему нужен Stop и читающая горутина, что в архитектуре Bubble Tea лишнее.

### time.After против tea.Tick

Альтернатива без хелпера выглядит так: return func() tea.Msg { return tickMsg(<-time.After(d)) } — горутина фреймворка блокируется на канале таймера и через d доставляет сообщение. Поведение то же, и в проектах такой вариант встречается. Различия мягкие, но есть. tea.Tick передаёт момент времени в функцию — время уже внутри сообщения; с time.After момент приходится собирать отдельно вызовом time.Now() в замыкании, и между выстрелом таймера и снимком момента успевает пройти ещё пара микросекунд. tea.Tick — каноническая идиома, по которой проект на Bubble Tea узнаётся с первого взгляда: grep по tea.Tick находит все таймеры репозитория. time.After прячет Timer, который живёт до срабатывания: для секунды незаметно, для часовых дедлайнов — уже счёт. Мое правило — tea.Tick везде, кроме одного случая: тесты подменяют реальное время фиктивными сообщениями, и там таймер не нужен вовсе, о чём подробно расскажет следующий урок. Ticker из стандартного пакета не трогаем: повторяющийся источник с обязательным Stop чужд архитектуре, где частота задаётся возвратом команды.

### Соберите фабрику тика

Восстановите идиому: хелпер Bubble Tea, тип функции и сообщение, несущее момент.

```go
func tickCmd() tea.Cmd {
        return tea.___(time.Second, func(t ___) tea.Msg {
                return tickMsg(t)
        })
}
```

Подсказка: Хелпер называется Tick, функция получает момент времени типа time.Time и оборачивает его в tickMsg.

Правильные ответы в пропусках: `Tick`, `time.Time`

### Соберите минутное обновление

Расставьте ветку тика с накопленным обновлением раз в минуту: сообщение, обновление момента, счётчик, условие, усиленный возврат, обычный возврат.

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

1. `case tickMsg:`
2. `  m.now = time.Time(msg)`
3. `  m.beats++`
4. `  if m.beats%60 == 0 {`
5. `    return m, tea.Batch(tickCmd(), refreshCmd())`
6. `  }`

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

Цепочка тиков — последний кирпич каркаса: клавиши, команды, композиция, сеть и теперь время. Следующая остановка — [тестирование TUI](/courses/bubbletea/lessons/22-testirovanie-tui/): там фиктивный tickMsg с заданным моментом проверит часы без единой секунды ожидания, а дедлайны проверят подменой таймера моком. Повторить материал спиннера можно в шестом уроке — там та же цепочка спрятана в готовый компонент. Для сетевых запросов дедлайном остаётся контекст из [урока про HTTP](/courses/bubbletea/lessons/19-http-dannye/) — таймер модели дополняет его, но не заменяет. А [урок про сборку и дистрибуцию](/courses/bubbletea/lessons/24-sborka-distribuciya/) покажет, где деградация частоты окупается по-настоящему: медленные SSH-сессии, где каждый кадр — пакет через океан. Обзор курса целиком — на странице [Bubble Tea](/courses/bubbletea/). Проверять тики честно можно только локально: поставьте интервал в сто миллисекунд, запустите панель в настоящем терминале и посмотрите, как опоздания под нагрузкой съедают ровность хода.

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

- **tea.Tick(d, fn)** — Хелпер Bubble Tea: возвращает команду, которая ждёт d и доставляет fn(time.Time) как сообщение. Одноразовая — цепочку поддерживает возврат новой команды из Update.
- **type tickMsg time.Time** — Сообщение-обёртка над моментом времени. Приведение time.Time(msg) достаёт время; View форматирует через Format("15:04:05").
- **timeoutMsg и gen** — Дедлайн операции несёт номер попытки; ветка верит только таймеру с текущим gen. Без сравнения старый таймер рисует ошибку поверх успешных данных.
- **Деградация частоты** — Интервал вычисляется из состояния модели: 250 мс при загрузке, секунда в фокусе, 30 секунд в фоне. Фабрика принимает длительность параметром.
- **time.After против Ticker** — time.After — одноразовый таймер, годится внутри самописной команды. Ticker повторяется, но требует Stop и горутину на чтение — в Bubble Tea не нужен.

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

- tea.Tick(d, fn) — канонический таймер tui: команда ждёт d и доставляет сообщение с моментом времени.
- Цепочка живёт самозапланированием: ветка tickMsg обязана вернуть tickCmd(), иначе часы замрут без ошибок.
- Модель хранит момент, View форматирует; сообщение-обёртка type tickMsg time.Time не путается с сырым временем.
- Дедлайн операции — tea.Tick рядом с запросом; метка gen защищает от таймеров прошлых попыток.
- Частота деградирует по состоянию: интервал вычисляет метод модели, фабрика принимает его параметром.
- time.After — равносильная самописная альтернатива; Ticker с Stop в архитектуре команд не нужен.

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

Соберите шапку с часами и деградацией: секундный тик в фокусе, тридцатисекундный в фоне, переключение по клавише f. Добавьте к загрузке из урока про HTTP таймаут на пять секунд с gen и проверьте сценарий: медленный ответ, повтор по r, успех — старый таймер обязан сгореть молча. Прогоните локально с интервалом сто миллисекунд и посмотрите на опоздания кадров.

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

### Часы отстают под нагрузкой — тики опаздывают. Чинить?

Опоздание — норма: таймер гарантирует не раньше, а не ровно. Под нагрузкой терминал и планировщик сдвигают кадры. Догонять пропущенные тики не нужно — каждый тик рисует текущий момент. Если точность важна, показывайте минуты без секунд или деградируйте частоту: моргание секунд на перегруженной машине всё равно никто не читает.

### Как остановить тики совсем, например на экране ошибки?

Перестаньте возвращать tickCmd() из ветки — цепочка умрёт сама, как у спиннера. Если часы должны вернуться, заведите флаг paused и в ветке возвращайте команду только при false. Жёсткой отмены у летящего таймера нет: он доставит ещё одно сообщение, и ветка должна это пережить без изменений состояния.

### Можно ли заводить несколько независимых таймеров?

Да: разные фабрики с разными сообщениями — tickMsg для часов, refreshMsg для данных, timeoutMsg для дедлайнов. Различайте их типами, а не полями, и не планируйте два таймера одной фабрикой с разными интервалами — сообщения неразличимы, и частота перепутается. Своё имя типа на каждый источник — дешёвая страховка.
