Capítulo 11: Componentes y Señales Reactivas
Capítulo 11: Componentes y Señales Reactivas
Introducción
Con la infraestructura del frontend lista, construimos la UI. Leptos usa signals para reactividad y la macro view! para declarar HTML. En este capítulo recorremos cada componente del tablero Kanban.
Signals en Leptos
Un signal es un valor reactivo: cuando cambia, la UI que lo usa se actualiza automáticamente.
let (cards, set_cards) = signal(Vec::<Card>::new());
cards: ReadSignal, se usa para leer el valor con.get().set_cards: WriteSignal, se usa para escribir con.set(nuevo_valor).
Comparación con otros frameworks
| Framework | Crear estado | Leer | Escribir |
|---|---|---|---|
| Leptos | signal(value) | getter.get() | setter.set(v) |
| React | useState(value) | state | setState(v) |
| Vue | ref(value) | val.value | val.value = v |
| SolidJS | createSignal(value) | getter() | setter(v) |
Leptos es más similar a SolidJS: reactividad fine-grained. Solo se re-renderiza el fragmento de DOM que usa el signal, no todo el componente.
La macro view!
view! {
<div class="board">
<h1>"Kanban Board"</h1>
</div>
}
Similar a JSX pero con diferencias:
- Los strings deben ir entre comillas:
"texto"(notexto). - Eventos:
on:clicken lugar deonClick. - Atributos dinámicos:
prop:value=signalpara binding bidireccional. - Clases condicionales:
class:completed=is_done.
Jerarquía de componentes
graph TD
App --> Board["Board (orquestador)"]
Board --> CreateCardForm["CreateCardForm (formulario)"]
Board --> Lane["Lane (x3: todo, doing, done)"]
Lane --> CardItem["CardItem (por cada tarjeta)"]
App: componente raíz
use leptos::prelude::*;
use crate::ui::pages::board::Board;
#[component]
pub fn App() -> impl IntoView {
view! {
<Board />
}
}
#[component] transforma la función en un componente Leptos. impl IntoView es el tipo de retorno que representa cualquier cosa renderable. App solo delega a Board.
Board: componente orquestador
Este es el componente más importante, lo analizamos por partes:
Signal principal y carga de datos
use leptos::prelude::*;
use leptos::task::spawn_local;
use crate::domain::entities::Card;
use crate::infrastructure::HttpCardGateway;
use crate::domain::ports::CardGateway;
use crate::ui::components::create_card_form::CreateCardForm;
use crate::ui::components::lane::Lane;
fn cards_by_lane(cards: &[Card], lane: &str) -> Vec<Card> {
cards.iter().filter(|c| c.lane == lane).cloned().collect()
}
async fn load(set: WriteSignal<Vec<Card>>) {
if let Ok(list) = HttpCardGateway.fetch_all().await {
set.set(list);
}
}
cards_by_lane: filtra tarjetas por lane. Es una función pura, sin side effects.load: función async que carga tarjetas de la API y actualiza el signal. Recibe elWriteSignalpara poder escribir.
Componente y callbacks
#[component]
pub fn Board() -> impl IntoView {
let (cards, set_cards) = signal(Vec::<Card>::new());
spawn_local(load(set_cards));
signal(Vec::<Card>::new()): crea el signal con un vector vacío.spawn_local(load(set_cards)): ejecuta la carga de datos de forma async.spawn_locales comouseEffectde React: ejecuta código async sin bloquear.
let on_create = Callback::new(move |(title, desc): (String, String)| {
spawn_local(async move {
if HttpCardGateway.create(title, desc).await.is_ok() {
load(set_cards).await;
}
});
});
Callback::new crea una función que los componentes hijos pueden invocar. El patrón es: ejecutar la operación en la API, si tiene éxito recargar todas las tarjetas. move captura set_cards en el closure.
let on_drop = Callback::new(move |(card_id, lane): (String, String)| {
spawn_local(async move {
if HttpCardGateway.move_card(&card_id, &lane, 0).await.is_ok() {
load(set_cards).await;
}
});
});
let on_delete = Callback::new(move |id: String| {
spawn_local(async move {
if HttpCardGateway.delete(&id).await.is_ok() {
load(set_cards).await;
}
});
});
Mismo patrón para mover y eliminar. Los callbacks son el mecanismo de comunicación child a parent: el hijo invoca el callback, el padre ejecuta la lógica.
Renderizado del tablero
view! {
<div class="board-container">
<h1>"Kanban Board"</h1>
<CreateCardForm on_create=on_create />
<div class="board">
{move || {
let all = cards.get();
view! {
<Lane title="Todo" lane_id="todo"
cards=cards_by_lane(&all, "todo")
on_drop=on_drop on_delete=on_delete />
<Lane title="Doing" lane_id="doing"
cards=cards_by_lane(&all, "doing")
on_drop=on_drop on_delete=on_delete />
<Lane title="Done" lane_id="done"
cards=cards_by_lane(&all, "done")
on_drop=on_drop on_delete=on_delete />
}
}}
</div>
</div>
}
}
El bloque {move || { ... }} es un closure reactivo. Cada vez que cards cambia (.get() crea la suscripción), Leptos re-ejecuta solo este closure y actualiza el DOM correspondiente. Las tres lanes se renderizan con las tarjetas filtradas.
CreateCardForm: formulario con signals
use leptos::prelude::*;
#[component]
pub fn CreateCardForm(
on_create: Callback<(String, String)>,
) -> impl IntoView {
let (title, set_title) = signal(String::new());
let (desc, set_desc) = signal(String::new());
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let t = title.get();
if t.is_empty() {
return;
}
on_create.run((t, desc.get()));
set_title.set(String::new());
set_desc.set(String::new());
};
view! {
<form class="create-card-form" on:submit=on_submit>
<input
type="text"
placeholder="Titulo"
prop:value=title
on:input=move |ev| set_title.set(event_target_value(&ev))
/>
<input
type="text"
placeholder="Descripcion"
prop:value=desc
on:input=move |ev| set_desc.set(event_target_value(&ev))
/>
<button type="submit">"Agregar"</button>
</form>
}
}
Análisis:
- Dos signals locales:
titleydescpara los inputs del formulario. on:submit: intercepta el submit del form.prevent_default()evita la recarga de la página.- Validación: si el título está vacío, retorna sin hacer nada.
on_create.run((t, desc.get())): invoca el callback del padre con los valores.- Reset: limpia ambos inputs después de crear.
prop:value=title: binding del valor del input al signal (lectura).on:input: actualiza el signal cuando el usuario escribe (escritura).event_target_value(&ev): helper de Leptos que extrae el valor del input del evento.
Lane: contenedor de tarjetas
use leptos::prelude::*;
use web_sys::DragEvent;
use crate::domain::entities::Card;
use crate::ui::components::card_item::CardItem;
#[component]
pub fn Lane(
title: &'static str,
lane_id: &'static str,
cards: Vec<Card>,
on_drop: Callback<(String, String)>,
on_delete: Callback<String>,
) -> impl IntoView {
let lane_id_owned = lane_id.to_string();
let on_dragover = |ev: DragEvent| {
ev.prevent_default();
};
let on_drop_handler = move |ev: DragEvent| {
ev.prevent_default();
if let Some(dt) = ev.data_transfer() {
if let Ok(card_id) = dt.get_data("text/plain") {
on_drop.run((card_id, lane_id_owned.clone()));
}
}
};
view! {
<div
class=format!("lane lane-{lane_id}")
on:dragover=on_dragover
on:drop=on_drop_handler
>
<h2 class="lane-title">{title}</h2>
<div class="lane-cards">
{cards.into_iter().map(|card| {
let on_del = on_delete.clone();
view! { <CardItem card=card on_delete=on_del /> }
}).collect_view()}
</div>
</div>
}
}
- Props:
titleylane_idson&'static str(strings estáticos conocidos en compilación). on_dragover:prevent_default()es necesario para que el elemento acepte drops (API HTML5 Drag & Drop).on_drop_handler: extrae elcard_iddelDataTransfery llama al callback con el ID y el lane destino.lane_id_owned: convertimos&straStringporque el closuremovenecesita ownership.- Renderizado de cards:
cards.into_iter().map(...).collect_view()renderiza cada tarjeta.collect_view()es el equivalente de Leptos aArray.map()en React.
CardItem: tarjeta individual
use leptos::prelude::*;
use web_sys::DragEvent;
use crate::domain::entities::Card;
#[component]
pub fn CardItem(
card: Card,
on_delete: Callback<String>,
) -> impl IntoView {
let card_id = card.id.clone();
let card_id_delete = card.id.clone();
let is_done = card.completed;
let on_dragstart = move |ev: DragEvent| {
if let Some(dt) = ev.data_transfer() {
let _ = dt.set_data("text/plain", &card_id);
}
};
view! {
<div
class="card-item"
class:completed=is_done
draggable="true"
on:dragstart=on_dragstart
>
<div class="card-title">{card.title.clone()}</div>
<div class="card-desc">{card.description.clone()}</div>
<button
class="card-delete"
on:click=move |_| on_delete.run(card_id_delete.clone())
>
"x"
</button>
</div>
}
}
card_idycard_id_delete: dos clones del ID porque cada closuremovenecesita su propia copia (Rust ownership).on_dragstart: almacena elcard_iden elDataTransfercomo texto plano. La lane destino lo leerá enon_drop.class:completed=is_done: agrega la clase CSScompletedcondicionalmente.draggable="true": habilita el drag & drop del HTML5.on:click: el botón “x” invocaon_deletecon el ID de la tarjeta.
Patrón de comunicación
La comunicación fluye en una dirección clara:
graph TD
Board["Board (estado central: cards signal)"]
Board --> CreateCardForm
Board --> LaneTodo["Lane (todo)"]
Board --> LaneDoing["Lane (doing)"]
Board --> LaneDone["Lane (done)"]
LaneTodo --> CardItem
CreateCardForm -->|"on_create callback"| Board
LaneTodo -->|"on_drop callback"| Board
CardItem -->|"on_delete callback"| Board
- Datos hacia abajo: Board pasa
cardsfiltradas a cada Lane. - Eventos hacia arriba: los hijos invocan callbacks del padre.
- Recarga centralizada: toda operación termina con
load(set_cards), actualizando el signal central y re-renderizando las lanes afectadas.
Reactividad: cómo se actualiza la UI
- El usuario hace drag & drop de una tarjeta a otra lane.
Lane.on_drop_handlerextrae el card_id y llamaon_drop.run().Board.on_dropcallback ejecutaHttpCardGateway.move_card().- Si tiene éxito, ejecuta
load(set_cards)que hacefetch_all. set_cards.set(list)actualiza el signal.- El closure reactivo
{move || { cards.get(); ... }}se re-ejecuta. cards_by_lanefiltra por cada lane.- Leptos actualiza solo los nodos DOM que cambiaron.
Todo esto sin un Virtual DOM. Leptos actualiza el DOM directamente gracias a la reactividad fine-grained de los signals.
Glosario
| Término | Definición |
|---|---|
| Signal | Valor reactivo que notifica cambios a los suscriptores |
| ReadSignal | Handle de lectura de un signal |
| WriteSignal | Handle de escritura de un signal |
| Callback | Función que un componente hijo puede invocar en el padre |
| spawn_local | Ejecuta un future async en el event loop del navegador |
| view! | Macro que convierte RSX en nodos DOM reactivos |
| collect_view | Convierte un iterador de views en un fragmento renderable |
| DragEvent | Evento del navegador para operaciones drag & drop |
| DataTransfer | Objeto que transporta datos durante un drag & drop |