← Volver al listado de tecnologías

Capítulo 11: Componentes y Señales Reactivas

Por: SiempreListo
rustwasmaxumleptos

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());

Comparación con otros frameworks

FrameworkCrear estadoLeerEscribir
Leptossignal(value)getter.get()setter.set(v)
ReactuseState(value)statesetState(v)
Vueref(value)val.valueval.value = v
SolidJScreateSignal(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:

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);
    }
}

Componente y callbacks

#[component]
pub fn Board() -> impl IntoView {
    let (cards, set_cards) = signal(Vec::<Card>::new());

    spawn_local(load(set_cards));
    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:

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>
    }
}

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>
    }
}

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

Reactividad: cómo se actualiza la UI

  1. El usuario hace drag & drop de una tarjeta a otra lane.
  2. Lane.on_drop_handler extrae el card_id y llama on_drop.run().
  3. Board.on_drop callback ejecuta HttpCardGateway.move_card().
  4. Si tiene éxito, ejecuta load(set_cards) que hace fetch_all.
  5. set_cards.set(list) actualiza el signal.
  6. El closure reactivo {move || { cards.get(); ... }} se re-ejecuta.
  7. cards_by_lane filtra por cada lane.
  8. 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érminoDefinición
SignalValor reactivo que notifica cambios a los suscriptores
ReadSignalHandle de lectura de un signal
WriteSignalHandle de escritura de un signal
CallbackFunción que un componente hijo puede invocar en el padre
spawn_localEjecuta un future async en el event loop del navegador
view!Macro que convierte RSX en nodos DOM reactivos
collect_viewConvierte un iterador de views en un fragmento renderable
DragEventEvento del navegador para operaciones drag & drop
DataTransferObjeto que transporta datos durante un drag & drop