Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

使用 Effects 响应变化

到目前为止,我们还没有提及响应式系统的另一半核心概念:Effects(副作用)。

响应式系统由两部分协同工作:更新单个响应式值(“Signals”)会通知依赖这些值的代码段(“Effects”),告知它们需要重新运行。响应式系统的这两部分是相互依赖的。如果没有 Effect,Signal 虽然可以在响应式系统内部发生变化,但这种变化永远无法以与外部世界交互的方式被观察到。如果没有 Signal,Effect 只会运行一次,之后便不再运行,因为没有可供订阅的可观察值。Effect 从字面上看确实就是响应式系统的“副作用(side effects)”:它们存在的目的,是为了让响应式系统与外部的非响应式世界保持同步。

渲染器利用 Effect 来更新 DOM 的各个部分,以响应 Signal 的变化。你也可以创建自己的 Effect,通过其他方式将响应式系统与外部世界进行同步。

Effect::new 接受一个函数作为参数。它会在响应式系统的下一个“tick”运行该函数。(举个例子,如果你在组件中使用它,它恰好会在该组件渲染 完成之后 运行。)如果你在该函数内部访问了任何响应式 Signal,系统就会注册该 Effect 依赖于该 Signal 这一事实。每当该 Effect 所依赖的 Signal 中有任何一个发生变化,Effect 就会重新运行。

let (a, set_a) = signal(0);
let (b, set_b) = signal(0);

Effect::new(move |_| {
  // 立即打印 "Value: 0" 并订阅 `a`
  logging::log!("Value: {}", a.get());
});

Effect 函数在被调用时会接收一个参数,该参数包含了它上一次运行时返回的值。在首次运行时,该参数为 None

默认情况下,Effect 不会在服务端运行。这意味着你可以在 Effect 函数内部调用浏览器专属的 API,而不会引发问题。如果你需要 Effect 在服务端运行,请使用 Effect::new_isomorphic

内容详解

为了让你更容易理解,我们可以把这个系统想象成 “广播电台”“收音机” 的关系。

1. 文档核心概念通俗解释

文档主要讲了以下几点:

  • 什么是响应式系统? 它由两部分组成:

    1. Signal(信号): 就像“广播电台”。它保存数据,当数据变化时,它会发出通知。
    2. Effect(效应): 就像“收音机”或“听众”。它依赖于信号,当信号发出通知时,它会收到消息并执行相应的操作(比如更新网页上的文字、打印日志等)。
  • 为什么需要 Effect?

    • 如果没有 Effect,Signal 里的数据变了也就变了,没人知道,网页也不会更新,就像电台在播音但没人听。
    • 如果没有 Signal,Effect 只会运行一次就结束了,因为它没有可以监听的目标。
    • Effect 的作用就是同步:把内部的数据变化,同步到外部世界(比如修改 DOM 元素、发送网络请求、打印控制台日志)。
  • 它是怎么工作的?

    • 当你创建一个 Effect 时,你给它一段代码(函数)。
    • 这段代码会自动运行一次
    • 关键点(自动追踪): 在这段代码运行过程中,如果你读取了某个 Signal 的值(比如 count.get()),这个 Effect 就会自动拿个小本本记下来:“哦,我依赖这个 count”。
    • 以后只要这个 count 变了,Effect 就知道:“我依赖的数据变了,我得重新运行一遍代码”。
  • 运行环境: 默认情况下,Effect 只在浏览器端运行,不在服务器端运行。这很安全,因为你可以在 Effect 里放心地使用浏览器专用的功能(比如 windowdocument),而不用担心服务器跑不动报错。


2. 代码与语法通俗详解

下面我们来拆解文档中提供的 Rust 代码,并解释其中用到的语法。

// 1. 创建信号
let (a, set_a) = signal(0);
let (b, set_b) = signal(0);

// 2. 创建 Effect
Effect::new(move |_| {
  // 3. 这里的代码会立即执行一次
  // 并且因为它用了 a.get(),所以它订阅了 a
  logging::log!("Value: {}", a.get());
});

第一部分:创建信号

let (a, set_a) = signal(0);
  • 含义: 创建一个初始值为 0 的信号。
  • 语法解释 let (x, y) = ...
    • 这叫解构(Destructuring)signal(0) 这个函数返回一个“包裹”,里面装着两个东西。我们用括号把它们拆出来,分别起名叫 aset_a
    • a读取器(Getter)。就像收音机,用来听当前的值是多少。
    • set_a设置器(Setter)。就像麦克风,用来改变这个值。

第二部分:创建 Effect

Effect::new( ... );
  • 含义: 告诉系统“新建一个监听任务”。
  • 语法解释 Struct::method
    • Effect 是一个结构体(或者说一个工具类)。
    • ::new 表示调用这个工具类的静态方法(通常用于创建一个新的实例)。这就好比在说“Effect 工具箱,请给我新建一个(new)实例”。

第三部分:闭包(核心难点)

这是代码中最“Rust”的部分:

move |_| { ... }

这整体是一个 闭包(Closure) ,你可以把它理解为 “匿名函数” (一个没有名字的函数,直接当参数传进去)。

  1. { ... }

    • 这是函数体。花括号里面包裹的代码,就是当信号变化时,你要执行的具体操作。
  2. |_|

    • 这是参数列表。在其他语言里通常写成 (arg),Rust 里用竖线 |arg|
    • _ (下划线):这是一个特殊的符号,意思是**“我不关心这个参数叫什么,我也不用它”**。
    • 文档提到: Effect 每次运行都会收到上一次运行的返回值。但在上面的代码里,我们不需要上一次的返回值,所以用 _ 把这个参数忽略掉。
  3. move

    • 通俗解释: “打包带走”。
    • 详细解释: Rust 非常讲究“所有权(Ownership)”。
      • 变量 a 是在 Effect 外面定义的。
      • Effect 里的代码可能会在将来(当数据变化时)多次运行。
      • 如果不加 move,Rust 会担心:万一 Effect 还没运行完,外面的 a 就被清理掉了怎么办?
      • 加上 move,就是告诉 Rust:“把这个闭包里用到的外部变量(比如 a)的所有权转移到这个闭包里面来”。可以想象成把 a 也就是那个“读取器”装进了 Effect 的背包里,走到哪带到哪,确保随时可用。

第四部分:执行逻辑

logging::log!("Value: {}", a.get());
  1. logging::log!(...)

    • 这是一个宏(Macro),你看它后面有个感叹号 !
    • 宏就像是“代码生成器”。你可以把它简单理解为打印日志的函数,类似于 JS 的 console.log
    • "Value: {}" 是格式化字符串,{} 是占位符,会被后面的值替换。
  2. a.get()

    • 含义: 获取信号 a 当前的值。
    • 重要作用: 只要你在这里写了 a.get(),Leptos 框架就会立刻发现:“哈!这个 Effect 用到了 a!”。于是它自动建立连接:以后只要 set_a 改变了数值,这个 Effect 就会再次运行
    • 注意: 代码里没有用到 b.get()。所以,即使你调用 set_b(100) 改变了 b 的值,这个 Effect 也不会运行,因为它不依赖 b

总结

这段代码做的事情是:

  1. 创建了两个数据源 ab
  2. 创建了一个监听器(Effect)。
  3. 这个监听器把 a “打包”带在身上(move)。
  4. 监听器立即运行一次,打印 a 的值。
  5. 因为读取了 a,监听器从此开始“死死盯着” a
  6. 一旦 a 变了,监听器就重新打印日志。
  7. b 变了,监听器无动于衷。

自动追踪与动态依赖

如果你熟悉像 React 这样的框架,你可能会注意到一个关键的区别。React 和类似的框架通常需要你传递一个“依赖数组(dependency array)”,这是一组显式的变量,用于决定 Effect 何时应该重新运行。

因为 Leptos 沿袭了同步响应式编程(synchronous reactive programming)的传统,所以我们不需要这种显式的依赖列表。相反,我们会根据 Effect 内部访问了哪些信号(signals)来自动追踪依赖。

这带来了两个结果(此处并非双关)。依赖项是:

  1. 自动的:你不需要维护依赖列表,也不必担心应该包含或不包含什么。框架会自动追踪哪些信号可能导致 Effect 重新运行,并为你处理这一切。
  2. 动态的:每次 Effect 运行时,依赖列表都会被清空并更新。例如,如果你的 Effect 包含条件判断,那么只有当前分支中使用的信号才会被追踪。这意味着 Effect 的重新运行次数被控制在绝对最小的范围内。

如果这听起来像魔法,并且你想深入了解自动依赖追踪的工作原理,请观看这个视频。(抱歉音量较低!)

自动追踪与动态依赖内容详解

第一部分:文档解释 —— “自动依赖追踪”是什么?

1. React 的做法(显式依赖)

如果你用过 React 这样的框架,你可能熟悉 useEffect。在 React 里,如果你希望一个副作用函数(比如更新网页标题)在某个变量变化时重新运行,你需要手动写一个“依赖数组”。

  • 通俗比喻:就像你要做一道菜,React 要求你必须手写一张清单交给厨师:“如果或者变了,你就重做这道菜。”如果你忘了写“盐”,即使盐罐子空了,厨师也不会重做,这就出 bug 了。

2. Leptos 的做法(自动追踪)

Leptos 不需要你写这个清单。它会在代码运行时“偷窥”你用了哪些变量。

  • 通俗比喻:Leptos 派了一个观察员站在厨师旁边。厨师拿起盐,观察员就记下:“哦,这道菜依赖盐”;厨师拿起糖,观察员记下:“哦,这道菜也依赖糖”。
  • 好处 1:自动化:你不需要维护清单,不会因为漏写变量而产生 bug。
  • 好处 2:动态化
    • 假设代码里有个 if 判断:如果是星期天,就加糖
    • 在 React 里,你必须把“糖”一直写在清单里。
    • 在 Leptos 里,如果今天不是星期天,代码没走到“加糖”那一行,观察员就不会把“糖”记入依赖。只有当代码真的读取了那个信号,它才会被追踪。这使得程序效率最高,不跑冤枉路。

第二部分:视频代码深度解析 —— 如何从零手写这个系统?

视频演示了如何用 Rust 从头写一个简易版的 Leptos 响应式系统。我们分步骤来拆解,顺便解释 Rust 语法。

核心角色介绍

  1. Runtime (运行时):这是一个全局的大仓库,存放所有的数据(Signals)和所有的操作(Effects)
  2. Signal (信号):一个会变的数据(比如计数器)。
  3. Effect (副作用):一个函数,当它依赖的数据变化时,它会自动重新运行(比如“把计数器的值更新到网页上”)。

步骤一:搭建仓库 (Runtime)

在 Rust 中,我们要定义数据结构。

// struct 就像是一个蓝图或模具,定义了一个对象的形状
struct Runtime {
    // Vector (Vec) 是一个可变长度的数组/列表
    // Box<...> 意思是把东西放在“堆”内存上,这里只拿一个指针。
    // RefCell<...> 是 Rust 的一个魔法盒子。
    // Any 意思是这盒子里装什么类型都可以。
    signal_values: RefCell<Vec<Box<RefCell<dyn Any>>>>, 
    
    // 这是一个关键变量!用来记录“当前正在运行哪个 Effect”
    // Option 意思是:可能有值 (Some),也可能没值 (None)
    running_effect: Cell<Option<EffectId>>, 
    
    // 记录每个 Signal 被哪些 Effect 订阅了
    // HashSet 是一个集合,里面的元素不重复
    signal_subscribers: RefCell<HashMap<SignalId, HashSet<EffectId>>>,
}

Rust 语法小白课堂:

  • RefCell (内部可变性):Rust 默认非常严格,如果你借阅了一本书(引用),你就不能在上面乱涂乱画(修改)。RefCell 像是一个特权通行证,它允许你在看似不可变的情况下,在运行时去修改里面的数据。这在实现这种复杂系统时非常有用。
  • Box (堆分配):Rust 里的变量默认在栈上,空间有限且位置固定。Box 就像是你租了一个大仓库(堆),把东西放进去,手里只拿着仓库钥匙(指针)。这样方便移动钥匙,而不用搬运货物。

步骤二:创建信号 (Create Signal)

我们需要一个函数来把数据存进仓库,并给用户一个“取货凭证”(ID)。

// <T> 是泛型,表示这个函数可以接受任何类型(整数、字符串等)
// 'static 表示这个数据会活得跟程序一样长
fn create_signal<T>(cx: &'static Runtime, value: T) -> Signal<T> {
    // 1. 把值包装好,塞进仓库的 signal_values 列表里
    cx.signal_values.borrow_mut().push(Box::new(RefCell::new(value)));
    
    // 2. 算出这个新值的 ID (就是列表的最后一个索引)
    let id = cx.signal_values.borrow().len() - 1;
    
    // 3. 返回一个 Signal 结构体,里面拿着 ID 和仓库的引用
    Signal { cx, id, ty: PhantomData }
}

Rust 语法小白课堂:

  • 泛型 <T>:就像一个万能插座,T 代表 Type。你可以传入 i32 (整数),也可以传 String
  • borrow_mut():这是 RefCell 的用法。意思是“我要借用这块数据,并且我要修改它(mut = mutable)”。

步骤三:读取信号 (Get) —— 自动追踪的核心!

这是最精彩的部分。当你读取一个信号的值时,它会偷偷看一眼现在是谁在读它

impl<T> Signal<T> {
    fn get(&self) -> T {
        // --- 自动追踪逻辑开始 ---
        // 检查仓库里的 running_effect 变量
        if let Some(effect_id) = self.cx.running_effect.get() {
            // 如果真的有一个 Effect 正在运行(观察员发现了!)
            // 就把这个 Effect 的 ID,加入到我这个 Signal 的订阅者列表中
            let mut subs = self.cx.signal_subscribers.borrow_mut();
            // 在我的 ID 名下,添加这个 Effect ID
            subs.entry(self.id).or_default().insert(effect_id);
        }
        // --- 自动追踪逻辑结束 ---

        // 然后才是正常的取值逻辑,从仓库里把值拿出来拷贝一份给你
        // ... (省略取值代码)
    }
}

原理详解:

  1. 想象有一个公共黑板叫 running_effect
  2. 当你调用 get() 时,Signal 会抬头看黑板。
  3. 如果黑板上写着“Effect A 正在运行”,Signal 就知道了:“原来是 Effect A 需要我。”
  4. 于是 Signal 拿出自己的小本本(signal_subscribers),记下:“Effect A 订阅了我。”

步骤四:运行副作用 (Run Effect)

为了让上面的 get 能看到黑板上有字,Effect 运行的时候必须自己去写黑板。

fn run_effect(cx: &'static Runtime, effect_id: EffectId) {
    // 1. 获取之前的运行状态(也许有 Effect 嵌套 Effect 的情况)
    let prev_effect = cx.running_effect.get();
    
    // 2. 【关键】把自己(当前的 Effect ID)写在黑板上
    cx.running_effect.set(Some(effect_id));

    // 3. 真正执行用户写的那个函数(比如打印日志、更新 DOM)
    // 在这期间,如果函数里调用了 signal.get(),Signal 就能看到黑板上的 ID 了!
    let effect_func = ...; // 从仓库取出函数
    effect_func(); 

    // 4. 恢复现场。把黑板擦回之前的状态。
    cx.running_effect.set(prev_effect);
}

Rust 语法小白课堂:

  • Closure (闭包):视频中 create_effect 接收的参数通常是一个闭包,长这样 move || { count.get() + 1 }move 关键字的意思是“把这个闭包里用到的外部变量(比如 count)的所有权转移进来”,这样闭包即使以后运行,也能找到这些变量。

步骤五:更新信号 (Set) —— 触发响应

当你要修改数据时,Signal 会去查它的小本本,看看谁关注了它。

impl<T> Signal<T> {
    fn set(&self, new_value: T) {
        // 1. 在仓库里更新值
        // ... (省略更新代码)

        // 2. 【关键】拿出小本本,看谁订阅了我
        let subs = self.cx.signal_subscribers.borrow();
        if let Some(effect_ids) = subs.get(&self.id) {
            // 3. 遍历每一个订阅的 Effect ID
            for effect_id in effect_ids {
                // 4. 重新运行这些 Effect
                run_effect(self.cx, *effect_id);
            }
        }
    }
}

Rust 语法小白课堂:

  • for ... in ...:这就是 Rust 的循环,遍历列表里的每一个东西。

总结:自动依赖追踪的工作流

让我们把整个流程串起来,看一个完整的动画:

  1. 用户写代码

    create_effect(move || {
        console_log("计数: " + count.get()); 
    })
  2. Effect 首次运行

    • 系统调用 run_effect
    • 系统在全局黑板 running_effect 写上:“现在是 Effect #1 在运行”。
    • 开始执行用户代码 console_log...
  3. 触发 Get

    • 代码执行到 count.get()
    • count (Signal) 抬头看黑板,发现是 Effect #1
    • count 在自己的订阅者列表里加上 Effect #1
  4. Effect 结束

    • 用户代码执行完毕。
    • 系统把黑板擦干净 (running_effect 设为 None)。
  5. 用户更新数据

    • 用户点击按钮,调用 count.set(5)
    • count 更新自己的值为 5。
    • count 翻看订阅者列表,发现有 Effect #1
    • count 大喊一声:“Effect #1,该干活了!”(调用 run_effect)。
    • 回到第 2 步,Effect 重新运行,界面更新。

这就是为什么你不需要手写依赖数组。因为在代码运行的那一瞬间,读取这个动作本身就完成了订阅的过程。

Effect 作为“近似零成本”的抽象

虽然从最严格的技术意义上讲,它们并非“零成本抽象(zero-cost abstraction)”——因为它们需要占用一些额外的内存,并且存在于运行时中等等——但在更高层面上,就你在其中执行的昂贵 API 调用或其他工作而言,Effect 确实是一种零成本抽象。基于你所描述的逻辑,它们仅在绝对必要时才重新运行,将运行次数降至最低。

想象一下,我正在开发某种聊天软件,希望用户能够显示全名,或者只显示名字(First Name),并且每当名字发生变化时通知服务器:

let (first, set_first) = signal(String::new());
let (last, set_last) = signal(String::new());
let (use_last, set_use_last) = signal(true);

// 无论何时源信号发生变化,
// 这都会将名字添加到日志中
Effect::new(move |_| {
    logging::log!(
        "{}", if use_last.get() {
            format!("{} {}", first.get(), last.get())
        } else {
            first.get()
        },
    )
});

如果 use_lasttrue,那么每当 firstlastuse_last 发生变化时,Effect 都应该重新运行。但是,如果我将 use_last 切换为 false,那么 last 的变化将永远不会导致全名发生改变。实际上,last 会从依赖列表中移除,直到 use_last 再次被切换回来。这样一来,即便我在 use_last 仍为 false 时多次修改 last,也能避免向 API 发送多次不必要的请求。

创建 effect,还是不创建 effect?

Effects 旨在将响应式系统与外部的非响应式世界同步,而不是在不同响应式值之间同步。换句话说:使用 effect 从一个 signal 读取值并在另一个 signal 中设置它总是次优的。

如果您需要定义一个依赖于其他 signals 值的 signal,请使用派生 signal 或 Memo。在 effect 内写入 signal 不是世界末日,它不会导致您的计算机着火,但派生 signal 或 memo 总是更好——不仅因为数据流清晰,而且因为性能更好。

let (a, set_a) = signal(0);

// ⚠️ 不太好
let (b, set_b) = signal(0);
Effect::new(move |_| {
    set_b.set(a.get() * 2);
});

// ✅ 太棒了!
let b = move || a.get() * 2;

如果您需要将某些响应式值与外部的非响应式世界同步——如 Web API、控制台、文件系统或 DOM——在 effect 中写入 signal 是一种很好的方法。但在许多情况下,您会发现您实际上是在事件监听器或其他东西内部写入 signal,而不是在 effect 内部。在这些情况下,您应该查看 leptos-use 看看它是否已经提供了响应式包装原语来做到这一点!

如果您想了解更多关于何时应该和不应该使用 create_effect 的信息,查看这个视频 进行更深入的考虑!

Effects 和渲染

我们已经走了这么远而没有提到 effects,因为它们内置在 Leptos DOM 渲染器中。我们已经看到您可以创建一个 signal 并将其传递到 view 宏中,它将在 signal 更改时更新相关的 DOM 节点:

let (count, set_count) = signal(0);

view! {
    <p>{count}</p>
}

这之所以有效,是因为框架本质上创建了一个包装此更新的 effect。您可以想象 Leptos 将此视图转换为类似这样的东西:

let (count, set_count) = signal(0);

// 创建 DOM 元素
let document = leptos::document();
let p = document.create_element("p").unwrap();

// 创建 effect 来响应式更新文本
Effect::new(move |prev_value| {
    // 首先,访问 signal 的值并将其转换为字符串
    let text = count.get().to_string();

    // 如果这与之前的值不同,更新节点
    if prev_value != Some(text) {
        p.set_text_content(&text);
    }

    // 返回此值,以便我们可以记忆化下一次更新
    text
});

每次 count 更新时,此 effect 将重新运行。这就是允许对 DOM 进行响应式、细粒度更新的原因。

使用 Effect::watch() 显式跟踪

除了 Effect::new() 之外,Leptos 还提供了一个 Effect::watch() 函数,可用于通过显式传入一组要跟踪的值来分离跟踪和响应变化。

watch 接受三个参数。dependency_fn 参数被响应式跟踪,而 handlerimmediate 不被跟踪。每当 dependency_fn 更改时,handler 就会运行。如果 immediate 是 false,handler 只会在检测到在 dependency_fn 中访问的任何 signal 的第一次更改后运行。watch 返回一个 Effect,可以用 .stop() 调用来停止跟踪依赖。

let (num, set_num) = signal(0);

let effect = Effect::watch(
    move || num.get(),
    move |num, prev_num, _| {
        leptos::logging::log!("Number: {}; Prev: {:?}", num, prev_num);
    },
    false,
);

set_num.set(1); // > "Number: 1; Prev: Some(0)"

effect.stop(); // 停止监视

set_num.set(2); // (什么都不发生)

Live example

点击打开 CodeSandbox。

CodeSandbox 源码
use leptos::html::Input;
use leptos::prelude::*;

#[derive(Copy, Clone)]
struct LogContext(RwSignal<Vec<String>>);

#[component]
fn App() -> impl IntoView {
    // 只是在这里制作一个可见的日志
    // 您可以忽略这个...
    let log = RwSignal::<Vec<String>>::new(vec![]);
    let logged = move || log.get().join("\n");

    // newtype 模式在这里不是*必需的*,但是一个好的实践
    // 它避免了与其他可能的未来 `RwSignal<Vec<String>>` contexts 的混淆
    // 并使引用它变得更容易
    provide_context(LogContext(log));

    view! {
        <CreateAnEffect/>
        <pre>{logged}</pre>
    }
}

#[component]
fn CreateAnEffect() -> impl IntoView {
    let (first, set_first) = signal(String::new());
    let (last, set_last) = signal(String::new());
    let (use_last, set_use_last) = signal(true);

    // 这将在任何源 signals 更改时将名称添加到日志中
    Effect::new(move |_| {
        log(if use_last.get() {
            let first = first.read();
            let last = last.read();
            format!("{first} {last}")
        } else {
            first.get()
        })
    });

    view! {
        <h1>
            <code>"create_effect"</code>
            " Version"
        </h1>
        <form>
            <label>
                "First Name"
                <input
                    type="text"
                    name="first"
                    prop:value=first
                    on:change:target=move |ev| set_first.set(ev.target().value())
                />
            </label>
            <label>
                "Last Name"
                <input
                    type="text"
                    name="last"
                    prop:value=last
                    on:change:target=move |ev| set_last.set(ev.target().value())
                />
            </label>
            <label>
                "Show Last Name"
                <input
                    type="checkbox"
                    name="use_last"
                    prop:checked=use_last
                    on:change:target=move |ev| set_use_last.set(ev.target().checked())
                />
            </label>
        </form>
    }
}

#[component]
fn ManualVersion() -> impl IntoView {
    let first = NodeRef::<Input>::new();
    let last = NodeRef::<Input>::new();
    let use_last = NodeRef::<Input>::new();

    let mut prev_name = String::new();
    let on_change = move |_| {
        log("      listener");
        let first = first.get().unwrap();
        let last = last.get().unwrap();
        let use_last = use_last.get().unwrap();
        let this_one = if use_last.checked() {
            format!("{} {}", first.value(), last.value())
        } else {
            first.value()
        };

        if this_one != prev_name {
            log(&this_one);
            prev_name = this_one;
        }
    };

    view! {
        <h1>"Manual Version"</h1>
        <form on:change=on_change>
            <label>"First Name" <input type="text" name="first" node_ref=first/></label>
            <label>"Last Name" <input type="text" name="last" node_ref=last/></label>
            <label>
                "Show Last Name" <input type="checkbox" name="use_last" checked node_ref=use_last/>
            </label>
        </form>
    }
}

fn log(msg: impl std::fmt::Display) {
    let log = use_context::<LogContext>().unwrap().0;
    log.update(|log| log.push(msg.to_string()));
}

fn main() {
    leptos::mount::mount_to_body(App)
}