Коммуникация Родитель-Потомок

Приложение можно представить как вложенное дерево компонентов. Каждый компонент управляется со собственным локальным состоянием и управляет разделом пользовательского интерфейса, так что компоненты склонны быть относительно самодостаточными.

Хотя иногда вам захочется установить коммуникацию между родительным компонентом и его дочерними компонентами. Представьте ситуацию: объявили компонент <FancyButton/>, добавляющий какие-то стили, логирование или что-то ещё к <button/>. Хочется использовать <FancyButton/> в компоненте <App/>. Но как установить коммуникацию между ими двумя?

Передать состояние из родителя в дочерний компонент легко. Мы частично рассматривали это в материале про компоненты и свойства. Попросту говоря если хочется, чтобы родитель общался с дочерним компонентом, можно передавать ReadSignal, Signal, или MaybeSignal в качестве свойства.

Но как быть с обратным направлением? Как дочерний элемент может уведомлять родителя о событиях или изменениях состояния?

Есть четыре простых паттерна коммуникации Родитель-Потомок в Leptos.

1. Передавать WriteSignal

Один из подходов это просто передавать WriteSignal из родителя в дочерний компонент и в нём обновлять сигнал. Это позволяет вам управлять состоянием родителя из дочернего компонента.

#[component]
pub fn App() -> impl IntoView {
    let (toggled, set_toggled) = create_signal(false);
    view! {
        <p>"Toggled? " {toggled}</p>
        <ButtonA setter=set_toggled/>
    }
}

#[component]
pub fn ButtonA(setter: WriteSignal<bool>) -> impl IntoView {
    view! {
        <button
            on:click=move |_| setter.update(|value| *value = !*value)
        >
            "Toggle"
        </button>
    }
}

Этот паттерн прост, но будьте с ним осторожны: передача WriteSignal может усложить понимание вашего кода. Читая <App/> из этого примера, вполне ясно, что даёте возможность изменять значение toggled, но совершенно неясно когда и как это происходит. В этом простом, локальном примере это легко понять, но если вы видите, что передаёте сигналы WriteSignal как этот по всему коду, вам следует всерьёз задуматься о том не упрощает ли этот подход донельзя написание спагетти-кода.

2. Использовать Callback (функцию обратного вызова)

Ещё один подход это передавать Callback в дочерний компонент: скажем, on_click.

#[component]
pub fn App() -> impl IntoView {
    let (toggled, set_toggled) = create_signal(false);
    view! {
        <p>"Toggled? " {toggled}</p>
        <ButtonB on_click=move |_| set_toggled.update(|value| *value = !*value)/>
    }
}


#[component]
pub fn ButtonB(#[prop(into)] on_click: Callback<MouseEvent>) -> impl IntoView
{
    view! {
        <button on:click=on_click>
            "Toggle"
        </button>
    }
}

Вы заметите что когда как <ButtonA/> получил WriteSignal и решает как его мутировать, <ButtonB/> просто порождает событие: мутация происходит в <App/>. Преимущество этого в том, что мы локальное состояние остаётся локальным, предотвращая проблему спагетти мутации. Но это также означает, что логика мутации сигнала должна находиться в <App/>, а не в <ButtonB/>. Это настоящие компромиссы, а не простой выбор между правильно/неправильно.

Обратите внимание на способ, которым мы используем тип Callback<In, Out>. Это просто обёртка вокруг замыкания Fn(In) -> Out, которая реализует Copy, что упрощает её передачу.

Мы также использовали атрибут #[prop(into)] чтобы мы могли передавать обычное замыкание в on_click. Пожалуйста, посмотрите главу "Свойства с into" для более подробной информации.

2.1 Использование замыкание вместо Callback

Rust замыкание Fn(MouseEvent) можно использовать напрямую вместо Callback:

#[component]
pub fn App() -> impl IntoView {
    let (toggled, set_toggled) = create_signal(false);
    view! {
        <p>"Toggled? " {toggled}</p>
        <ButtonB on_click=move |_| set_toggled.update(|value| *value = !*value)/>
    }
}


#[component]
pub fn ButtonB<F>(on_click: F) -> impl IntoView
where
    F: Fn(MouseEvent) + 'static
{
    view! {
        <button on:click=on_click>
            "Toggle"
        </button>
    }
}

В данном случае код почти не изменился. В более продвинутых случаях использование замыкания может потребовать клонирования, в отличие от варианта с использованием Callback.

Обратите внимание, что мы объявили обобщенный тип F ради функции обратного вызова. Если вас это смутило, вернитесь к разделу Свойства с обобщенными типами главы о компонентах.

3. Использование слушателя событий (англ. Event Listener)

По правде говоря, Вариант 2 можно написать немного другим способом. Если функция обратного вызова напрямую накладывается на нативный DOM элементом, можно добавить on: слушатель прямо в то место, этот компонент используется в макросе view в <App/>.

#[component]
pub fn App() -> impl IntoView {
    let (toggled, set_toggled) = create_signal(false);
    view! {
        <p>"Toggled? " {toggled}</p>
        // note the on:click instead of on_click
        // this is the same syntax as an HTML element event listener
        <ButtonC on:click=move |_| set_toggled.update(|value| *value = !*value)/>
    }
}


#[component]
pub fn ButtonC() -> impl IntoView {
    view! {
        <button>"Toggle"</button>
    }
}

Это позволяет вам написать намного меньше кода для <ButtonC/>, чем вы написали для <ButtonB/>, и всё даёт слушателю правильно типизированное событие. Это работает так, что слушатель событий on: добавляется к каждому возвращаемому <ButtonC/>: в данном случае только <button>.

Конечно, это работает только для настоящих событий DOM которые вы передаёте напрямую в элементы, которые вы рендерите в компоненте. Для более сложной логики, которая не накладывается напрямую на элемент (скажем вы создали <ValidatedForm/> и хотите функцию обратного вызова on_valid_form_submit), вам следует использовать Вариант 2.

4. Предоставление Контекста

Эта версия по факту является разновидностью Варинта 1. Скажем у вас дерево компонентов глубокой вложенности:

#[component]
pub fn App() -> impl IntoView {
    let (toggled, set_toggled) = create_signal(false);
    view! {
        <p>"Toggled? " {toggled}</p>
        <Layout/>
    }
}

#[component]
pub fn Layout() -> impl IntoView {
    view! {
        <header>
            <h1>"My Page"</h1>
        </header>
        <main>
            <Content/>
        </main>
    }
}

#[component]
pub fn Content() -> impl IntoView {
    view! {
        <div class="content">
            <ButtonD/>
        </div>
    }
}

#[component]
pub fn ButtonD<F>() -> impl IntoView {
    todo!()
}

<ButtonD/> теперь уже не дочерний элемент <App/>, так что в его свойства нельзя просто передать WriteSignal. Можно сделать то, что иногда называют “пробурить свойство”, добавив свойство в каждый слой между ими двумя:

#[component]
pub fn App() -> impl IntoView {
    let (toggled, set_toggled) = create_signal(false);
    view! {
        <p>"Toggled? " {toggled}</p>
        <Layout set_toggled/>
    }
}

#[component]
pub fn Layout(set_toggled: WriteSignal<bool>) -> impl IntoView {
    view! {
        <header>
            <h1>"My Page"</h1>
        </header>
        <main>
            <Content set_toggled/>
        </main>
    }
}

#[component]
pub fn Content(set_toggled: WriteSignal<bool>) -> impl IntoView {
    view! {
        <div class="content">
            <ButtonD set_toggled/>
        </div>
    }
}

#[component]
pub fn ButtonD<F>(set_toggled: WriteSignal<bool>) -> impl IntoView {
    todo!()
}

Ну и бардак. set_toggled не нужен ни<Layout/>, ни <Content/>; они просто передают его в <ButtonD/>. Но мне приходится объявлять свойство трижды. Помимо того, что это раздражает, такое ещё и сложно поддерживать: только вообразите, если мы добавим "среднее положение" переключателя, тип set_toggled придётся поменять на enum. Наам придётся менять его в трёх местах!

Нет ли какого-то способа перепрыгнуть эти уровни?

Есть!

4.1 API контекстов

Используя provide_context и use_context. можно создавать контексты данных, которые будут перепрыгивать через уровни. Контексты идентифицируются по типу данных, которые вы предоставляете (в этом примере, WriteSignal<bool>), и они существуют в нисходящем дереве, которое следует контурам дерева UI. В этом примере мы можем использовать контекст, чтобы избежать ненужного бурения свойств.

#[component]
pub fn App() -> impl IntoView {
    let (toggled, set_toggled) = create_signal(false);

    // share `set_toggled` with all children of this component
    provide_context(set_toggled);

    view! {
        <p>"Toggled? " {toggled}</p>
        <Layout/>
    }
}

// <Layout/> and <Content/> omitted
// To work in this version, drop their references to set_toggled

#[component]
pub fn ButtonD() -> impl IntoView {
    // use_context searches up the context tree, hoping to
    // find a `WriteSignal<bool>`
    // in this case, I .expect() because I know I provided it
    let setter = use_context::<WriteSignal<bool>>()
        .expect("to have found the setter provided");

    view! {
        <button
            on:click=move |_| setter.update(|value| *value = !*value)
        >
            "Toggle"
        </button>
    }
}

Эти же предостережения относятся к данному коду в той же мере, что и к <ButtonA/>: передавать WriteSignal нужно с осторожностью, так как это позволяет мутировать состояние из произвольных мест в коде. Но при осторожном обращении, это одна из самых эффективных техник управления глобальным состоянием в Leptos: просто предоставьте состояние на самом высоком из уровней, на которых вам оно требуется и используйте где нужно на уровнях ниже.

Заметим, что у этого подхода нет недостатков в части производительности. Поскольку вы передаете мелкозернистый реактивный сигнал, ничего не происходит в промежуточных компонентах (<Layout/> и <Content/>) когда вы его обновляете. Можно установить прямую коммуникацию между <ButtonD/> и <App/>. Фактически — и в этом состоит сила мелкозернистой реактивности — коммуникация идёт напрямую между нажатием на кнопку <ButtonD/> и единственным текстовым узлом в <App/>. Это как если бы самих компонентов вовсе не существовало. И, ну... в среде выполнения они не существуют. Лишь сигналы и эффекты до самого низа.


[Нажмите, чтобы открыть CodeSandbox.](https://codesandbox.io/p/sandbox/8-parent-child-0-5-7rz7qd?file=%2Fsrc%2Fmain.rs%3A1%2C2)

<noscript>
  Пожалуйста, включите Javascript для просмотра примеров.
</noscript>

<template>
  <iframe src="https://codesandbox.io/p/sandbox/8-parent-child-0-5-7rz7qd?file=%2Fsrc%2Fmain.rs%3A1%2C2" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>

Код примера CodeSandbox
use leptos::{ev::MouseEvent, *};

// This highlights four different ways that child components can communicate
// with their parent:
// 1) <ButtonA/>: passing a WriteSignal as one of the child component props,
//    for the child component to write into and the parent to read
// 2) <ButtonB/>: passing a closure as one of the child component props, for
//    the child component to call
// 3) <ButtonC/>: adding an `on:` event listener to a component
// 4) <ButtonD/>: providing a context that is used in the component (rather than prop drilling)

#[derive(Copy, Clone)]
struct SmallcapsContext(WriteSignal<bool>);

#[component]
pub fn App() -> impl IntoView {
    // just some signals to toggle three classes on our <p>
    let (red, set_red) = create_signal(false);
    let (right, set_right) = create_signal(false);
    let (italics, set_italics) = create_signal(false);
    let (smallcaps, set_smallcaps) = create_signal(false);

    // the newtype pattern isn't *necessary* here but is a good practice
    // it avoids confusion with other possible future `WriteSignal<bool>` contexts
    // and makes it easier to refer to it in ButtonC
    provide_context(SmallcapsContext(set_smallcaps));

    view! {
        <main>
            <p
                // class: attributes take F: Fn() => bool, and these signals all implement Fn()
                class:red=red
                class:right=right
                class:italics=italics
                class:smallcaps=smallcaps
            >
                "Lorem ipsum sit dolor amet."
            </p>

            // Button A: pass the signal setter
            <ButtonA setter=set_red/>

            // Button B: pass a closure
            <ButtonB on_click=move |_| set_right.update(|value| *value = !*value)/>

            // Button B: use a regular event listener
            // setting an event listener on a component like this applies it
            // to each of the top-level elements the component returns
            <ButtonC on:click=move |_| set_italics.update(|value| *value = !*value)/>

            // Button D gets its setter from context rather than props
            <ButtonD/>
        </main>
    }
}

/// Button A receives a signal setter and updates the signal itself
#[component]
pub fn ButtonA(
    /// Signal that will be toggled when the button is clicked.
    setter: WriteSignal<bool>,
) -> impl IntoView {
    view! {
        <button
            on:click=move |_| setter.update(|value| *value = !*value)
        >
            "Toggle Red"
        </button>
    }
}

/// Button B receives a closure
#[component]
pub fn ButtonB<F>(
    /// Callback that will be invoked when the button is clicked.
    on_click: F,
) -> impl IntoView
where
    F: Fn(MouseEvent) + 'static,
{
    view! {
        <button
            on:click=on_click
        >
            "Toggle Right"
        </button>
    }

    // just a note: in an ordinary function ButtonB could take on_click: impl Fn(MouseEvent) + 'static
    // and save you from typing out the generic
    // the component macro actually expands to define a
    //
    // struct ButtonBProps<F> where F: Fn(MouseEvent) + 'static {
    //   on_click: F
    // }
    //
    // this is what allows us to have named props in our component invocation,
    // instead of an ordered list of function arguments
    // if Rust ever had named function arguments we could drop this requirement
}

/// Button C is a dummy: it renders a button but doesn't handle
/// its click. Instead, the parent component adds an event listener.
#[component]
pub fn ButtonC() -> impl IntoView {
    view! {
        <button>
            "Toggle Italics"
        </button>
    }
}

/// Button D is very similar to Button A, but instead of passing the setter as a prop
/// we get it from the context
#[component]
pub fn ButtonD() -> impl IntoView {
    let setter = use_context::<SmallcapsContext>().unwrap().0;

    view! {
        <button
            on:click=move |_| setter.update(|value| *value = !*value)
        >
            "Toggle Small Caps"
        </button>
    }
}

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