跳转到内容

状态

Ratatui Kit 里的状态不是“都放进一个 store”。不同状态有不同生命周期:有些跟随组件实例,有些只属于一棵 Provider 子树,有些跟随一次导航,有些才是进程级共享。

想先看运行中的完整例子,可以从 Atom 全局状态 开始。这个页面则作为 reference,解释每种状态应该放在哪里,以及 State<T> / AtomState<T> 这些响应式句柄具体怎么工作。

状态优先位置生命周期
输入框草稿、列表游标、弹窗 openuse_state跟随组件实例
子树级主题、workspace、局部服务对象ContextProvider + use_state跟随 Provider 子树
一次导航携带的临时上下文RouteState跟随 history entry
当前用户、全局主题、跨页面缓存Atom / use_atom跟随进程
状态越往右生命周期越长,影响范围越大;默认从最小作用域开始。

状态越全局,越要谨慎。Atom 不是“更强的 State”,而是更长生命周期、更宽作用域的 State。

use_state 创建组件局部响应式状态:

let mut count = hooks.use_state(|| 0i32);
count += 1;

返回的 State<T> 是一个可复制句柄。你可以把它按值传进事件闭包、子组件 props、手写 Component,或异步任务里:

let mut draft = hooks.use_state(String::new);

hooks.use_event_handler(EventScope::Current, EventPriority::Normal, move |event| {
    if let Event::Key(key) = event
        && let KeyCode::Char(ch) = key.code
    {
        draft.write().push(ch);
        return EventResult::Consumed;
    }

    EventResult::Ignored
});

State<T> 要求 T: Unpin + Send + Sync + 'static。这个约束让状态句柄可以安全地进入后台任务,并在任务完成后唤醒 UI。

State<T>AtomState<T> 都基于同一个 ReactiveHandle,读写 API 一致:

API用途失败行为
get()读取 Copy借用冲突时 panic
read()获取只读 borrow借用冲突时 panic
try_read()尝试只读 borrow返回 None
write()获取可变 borrow,并在变更后唤醒借用冲突时 panic
try_write()尝试可变 borrow返回 None
set(value)覆盖值,并唤醒借用冲突时静默跳过
write_no_update()可变 borrow,但不唤醒借用冲突时 panic
try_write_no_update()尝试可变 borrow,但不唤醒返回 None
set_no_update(value)覆盖值,但不唤醒借用冲突时静默跳过

断言型 API 适合普通组件逻辑:借用冲突通常是编程错误,应该快速暴露。try_* 适合可选能力、回调里防御性读取,或者你明确知道当前可能被借用。

写入会在可变借用 drop 时唤醒渲染循环:

{
    let mut items = list.write();
    items.push("new item".to_string());
} // 这里 drop,通知 UI 下一帧更新

对于 Copy 数值,+=-=*=/= 也会触发同样的变更通知:

let mut score = hooks.use_state(|| 0i32);
score += 1;

有一种写入不应该触发 UI 更新:draw 阶段把状态交给 ratatui 的 stateful widget 渲染。

drawer.render_stateful_widget(
    list,
    drawer.area,
    &mut self.state.write_no_update(),
);

这类写入只是 ratatui widget 的绘制协议需要 &mut State,不是业务状态变化。真正改变选中项的地方应该在事件 handler 里用 write()。完整例子见 原生 Widget 桥接

try_write_no_update()set_no_update() 是同一条路径的非 panic / 直接赋值版本。它们只适合绘制协议、测试 harness 或非常明确的内部同步场景;普通业务状态写入应该继续用 write() / set(),让界面能被唤醒。

Atom 是 feature-gated 能力,需要开启 atomfull。它适合保存进程级共享状态:主题、当前用户、跨页面缓存、全局选择结果。

Atom 是模块级声明,不需要宏:

static THEME: Atom<String> = Atom::new(|| "dark".to_string());

组件内用 use_atom 订阅:

#[component]
fn ThemeLabel(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
    let theme = hooks.use_atom(&THEME);

    element!(Text(text: format!("theme: {}", theme.read().as_str())))
}

use_atom(&THEME) 返回 AtomState<T>。它和 State<T> 一样可读写、可复制、可传进闭包。区别在于唤醒模型:写入 Atom 会唤醒订阅了这个 Atom 的组件;没有订阅它的组件不会因为这次写入重绘。

组件外也可以直接读写 Atom:

THEME.set("light".to_string());
let current = THEME.state().read().clone();

get() 只适用于 T: Copy;非 Copy 值用 THEME.state().read()

use_atom 每帧都会把 hook 内部句柄校准到当前传入的 Atom。组件被卸载,或者同一个 hook 位置开始订阅另一个 Atom 时,旧 Atom 上以组件 key 注册的 waker 会被移除。

这意味着可以安全写这种“参数决定订阅哪个 Atom”的 hook,但仍然要保持 hook 调用顺序稳定:

let focus = hooks.use_atom(if compact {
    &COMPACT_FOCUS
} else {
    &FULL_FOCUS
});

不要手动维护 Atom 的订阅表;use_atom 会处理注册和退订。

输入过程中的每个字符通常不该写进 Atom。以 Atom 全局状态 为例:

let draft = hooks.use_state(tui_input::Input::default);
let mut focus = hooks.use_atom(&FOCUS);

// typing
draft.write().handle_event(&event);

// Enter
let value = draft.read().value().trim().to_string();
focus.set(value);

草稿属于当前编辑页;提交后的值才属于整个应用。把每个按键都写进 Atom,会让无关订阅者频繁醒来,也会让取消编辑变得别扭。

如果一组数据只应该在某棵子树里可见,优先用 ContextProvider。常见例子包括局部主题、workspace 设置、页面级服务对象。

let settings = WorkspaceSettings { /* ... */ };

element!(
    ContextProvider(value: Context::owned(settings)) {
        WorkspaceSummary
        ScopedPanel
    }
)

Context 的价值是“作用域清楚、可局部覆盖”。同一种类型可以在嵌套 Provider 里覆盖,子组件读取到最近那一层。完整例子见 自定义 Provider

不要为了“传得远”把页面级状态默认升到 Atom。先问它有没有天然作用域;有的话,Provider 通常更清楚。

Router 是 Provider 作用域状态,不是全局单例。RouteState 是单个 history entry 的可选 payload,适合从列表页进入详情页时携带一次性上下文:

navigate.push_with_state(
    "/projects/boreal",
    RouteNotice { message },
);

普通 push(path) / replace(path) 会清空当前 route state;只有 push_with_state / replace_with_state 显式携带 state。这样可以避免旧详情页的 payload 泄漏到无关页面。

URL 必要信息放进 path params,一次性、非 URL 语义的上下文才放进 RouteState。更多细节见 路由

问题倾向
组件卸载后状态是否应该消失?use_state
是否只属于某个页面或子树?ContextProvider + use_state
是否只属于一次导航?RouteState
多个页面是否长期读写同一份业务数据?Atom
写入是否应该触发当前组件重绘?write() / set()
只是 draw 阶段满足 stateful widget 协议?write_no_update()

一个实用规则:先把状态放在它能正确工作的最小作用域里。只有当多个独立页面、组件或后台任务都需要同一份长期状态时,再把它升到 Atom。