Introduction

Bounce

crates.io | docs.rs | GitHub

Bounce is a state-management library focusing on simplicity and performance.

Bounce is inspired by Redux and Recoil.

Rationale

Yew state management solutions that are currently available all have some (or all) of the following limitations:

  • Too much boilerplate.

    Users either have to manually control whether to notify subscribers or have to manually define contexts.

  • State change notifies all.

    State changes will notify all subscribers.

  • Needless clones.

    A clone of the state will be produced for all subscribers whenever there's a change.

Bounce wants to be a state management library that:

  • Has minimal boilerplate.

    Changes are automatically detected via PartialEq.

  • Only notifies relevant subscribers.

    When a state changes, only hooks that subscribe to that state will be notified.

  • Reduces Cloning.

    States are Rc'ed.

Installation

You can add it to your project with the following command:

cargo add bounce

You can also add it to the Cargo.toml of your project manually:

bounce = "0.9"

Getting Started

If you want to learn more about Bounce, you can check out the tutorial and the API documentation.

Licence

Bounce is dual licensed under the MIT license and the Apache License (Version 2.0).

Tutorial

This tutorial will guide you to create a simple application that greets the user with the entered name.

0. Prerequisites

This tutorial assumes the reader is familiar with the basics of Yew and Rust. If you are new to Yew or Rust, you may find the following content helpful:

  1. The Rust Programming Language
  2. The wasm-bindgen Guide
  3. The Yew Docs

You need the following tools:

  1. Rust with wasm32-unknown-unknown target toolchain

    If you installed Rust with rustup, You can obtain the wasm32-unknown-unknown target toolchain with the following command:

    rustup target add wasm32-unknown-unknown
    
  2. The Trunk Bundler

  3. cargo-edit

1. Prepare Dependencies

This tutorial uses the yew-trunk-minimal-template. This template repository has a minimal setup of Yew with Trunk. You can create a new repository using the template with the following commands:

mkdir my-first-bounce-app
cd my-first-bounce-app
git init
git fetch --depth=1 -n https://github.com/yewstack/yew-trunk-minimal-template.git
git reset --hard $(git commit-tree FETCH_HEAD^{tree} -m "initial commit")

To add bounce to the dependencies, run the following command:

cargo add bounce

You can now view the app with Trunk:

trunk serve --open

2. Register Bounce Root

BounceRoot is a context provider that provides state management and synchronisation mechanism to its child components.

It should be registered as a parent of all components that interact with the bounce states. You only need 1 BounceRoot per application.

In this example, we can add it to the App component.

#![allow(unused)]
fn main() {
use bounce::BounceRoot;
use yew::prelude::*;

#[function_component(App)]
pub fn app() -> Html {
    html! {
        // Register BounceRoot so that states can be provided to its
        // children.
        <BounceRoot>
            <main>
                <img class="logo" src="https://yew.rs/img/logo.png" alt="Yew logo" />
                <h1>{ "Hello World!" }</h1>
                <span class="subtitle">{ "from Yew with " }<i class="heart" /></span>
            </main>
        </BounceRoot>
    }
}
}

3. Create an Atom

An atom is a simple state that is similar to a state created by the use_state hook in Yew.

Atoms are created by deriving the Atom macro on a type. Atom can be derived on any type that implements Default and PartialEq.

#![allow(unused)]
fn main() {
#[derive(Atom, PartialEq)]
pub struct Username {
    value: String,
}

impl Default for Username {
    fn default() -> Self {
        Username {
            value: "Jane Doe".into(),
        }
    }
}
}

4. Use an Atom

An Atom can be used in any component under a BounceRoot with the following hooks:

  • use_atom: Returns a UseAtomHandle<T>, which can be dereferenced to T and includes a .set method that can be used to update the value of this atom and triggers a re-render when the value changes.

  • use_atom_value: Returns Rc<T> and triggers a re-render when the value changes.

  • use_atom_setter: Returns a setter of type Rc<dyn Fn(T)> that can be used to update the value of T. This type will not trigger a re-render when the value changes.

5. Create a component that displays the username

To create a component that reads the value of an Atom, you can use the use_atom_value hook mentioned in the previous chapter.

When the first time a state is used with a hook, its value will be initialised with the value returned by Default::default().

#![allow(unused)]
fn main() {
#[function_component(Reader)]
fn reader() -> Html {
    let username = use_atom_value::<Username>();

    html! { <div>{"Hello, "}{&username.value}</div> }
}
}

6. Create a component to update the username

The use_atom hook can be used to establish a bi-directional connection between a component and a bounce state.

#![allow(unused)]
fn main() {
#[function_component(Setter)]
fn setter() -> Html {
    let username = use_atom::<Username>();

    let on_text_input = {
        let username = username.clone();

        Callback::from(move |e: InputEvent| {
            let input: HtmlInputElement = e.target_unchecked_into();

            username.set(Username {
                value: input.value(),
            });
        })
    };

    html! {
        <div>
            <input type="text" oninput={on_text_input} value={username.value.to_string()} />
        </div>
    }
}
}

7. Update app

To make sure that our <Getter /> and <Setter /> component can communicate with <BounceRoot />, we need to put them under the bounce root.

#![allow(unused)]
fn main() {
use bounce::BounceRoot;
use yew::prelude::*;

#[function_component(App)]
pub fn app() -> Html {
    html! {
        <BounceRoot>
            <Getter />
            <Setter />
        </BounceRoot>
    }
}
}

You may refer to the simple example for a complete example.

Core API

The core API contains a set of APIs that is enabled by default. It is built with a hook and function component first approach. The value of a state is shared among all components under a bounce root.

Atom

A simple state that is initialised with its default value and allows a new value to be set by a setter. This state is similar to a state defined by use_state_eq hook but its default value is determined by Default::default() and its value is shared by all components under a bounce root.

Atoms are defined by deriving the Atom macro. Any type that implements Default + PartialEq can #[derive(Atom)].

#![allow(unused)]
fn main() {
#[derive(Atom, PartialEq, Default)]
pub struct Counter {
    inner: u32
}
}

When the first time its used by a hook, it will be initialised by its default value.

#![allow(unused)]
fn main() {
// The counter will be initialised with its initial value when first
// called.
let ctr = use_atom::<Counter>();

let increment = {
    let ctr = ctr.clone();
    Callback::from(move || {
        // A new value of type `Counter` can be set with `.set`
        ctr.set(Counter { inner: ctr.inner + 1 });
    })
};

let decrement = {
    let ctr = ctr.clone();
    Callback::from(move || {
        // A new value of type `Counter` can be set with `.set`
        ctr.set(Counter { inner: ctr.inner - 1 });
    })
};

html! {
    // The handle of `use_atom` implements `Deref` to `Counter`.
    <div>{ctr.inner}</div>
    <button onclick={increment}>{"+"}</button>
    <button onclick={decrement}>{"-"}</button>
}
}

use_atom can be use to create a bidirectional connection between a component and an atom. You may also use_atom_value or use_atom_setter to create a read-only or write-only connection.

API Reference:

Slice

Similar to Atoms, but allows a use_reducer_eq-like usage where actions can be dispatched with actions to mutate the state. This allows an action to be applied on a complex state. In addition to Default and PartialEq, Slices also need to implement the Reducible trait from Yew.

The counter example rewritten as a Slice:

#![allow(unused)]
fn main() {
use yew::prelude::*;

pub enum CounterAction {
    Increment,
    Decrement,
}

#[derive(Slice, PartialEq, Default)]
pub struct Counter {
    inner: u32
}

impl Reducible for Counter {
    type Action = CounterAction;

    fn reduce(self: Rc<Self>, action: Self::Action) -> Rc<Self> {
        match action {
            CounterAction::Increment => {
                Self { inner: self.inner + 1 }.into()
            }
            CounterAction::Decrement => {
                Self { inner: self.inner - 1 }.into()
            }
        }
    }
}
}

Instead of setting a new value, slices receive actions as updates and a new value is created by the reduce function. Slices in Bounce is Copy-on-Write that you only need to clone the value if it needs to be mutated.

Actions can be dispatched by using the dispatch() method on the the use_slice handle.

#![allow(unused)]
fn main() {
// The counter will be initialised with its initial value when first
// called.
let ctr = use_slice::<Counter>();

let increment = {
    let ctr = ctr.clone();
    Callback::from(move || {
        // An Action can be dispatched by the `.dispatch()` method.
        ctr.dispatch(CounterAction::Increment);
    })
};

let decrement = {
    let ctr = ctr.clone();
    Callback::from(move || {
        ctr.dispatch(CounterAction::Decrement);
    })
};

html! {
    // The handle of `use_atom` implements `Deref` to `Counter`.
    <div>{ctr.inner}</div>
    <button onclick={increment}>{"+"}</button>
    <button onclick={decrement}>{"-"}</button>
}
}

API Reference:

Selector

A derived state. Unlike Atoms or Slices, a selector does not store any value in itself. It derives its value from other states (atoms, slices or other selectors) and subscribes to the state used to derive values so it will update its value when any value it subscribes to changes automatically.

Example

A selector that checks if the counter slice defined in the previous example is even.

#![allow(unused)]
fn main() {
#[derive(PartialEq)]
pub struct IsEven {
    inner: bool,
}

impl Selector for IsEven {
    fn select(states: &BounceStates) -> Rc<Self> {
        // The IsEven selector will subscribe to the Counter slice.
        // If the value of the counter slice changes,
        // the value of IsEven will be updated as well.
        let val = states.get_slice_value::<Counter>();

        Self {
            inner: val.inner % 2 == 0,
        }
        .into()
    }
}
}

API Reference:

Input Selector

A derived state family. Similar to Selectors, but also allows an additional input to be provided to select states.

An input selector will refresh its value upon either the input or the selected states change.

API Reference:

Notion

An action that can be applied to multiple states.

When a notion is applied, it will be broadcasted to all states that listen to this notion.

To listen to a notion, apply #[bounce(with_notion(NotionType))] tag to your slice or atom and define how it can be applied with the WithNotion<NotionType> trait.

use yew::prelude::*;

pub struct Reset;

pub enum CounterAction {
    Increment,
    Decrement,
}

#[derive(Slice, PartialEq, Default)]
#[bounce(with_notion(Reset))] // The slice that listens to a notion of type T needs to be denoted with `#[bounce(with_notion(T))]`.
pub struct Counter {
    inner: u32
}

impl Reducible for Counter {
    type Action = CounterAction;

    fn reduce(self: Rc<Self>, action: Self::Action) -> Rc<Self> {
        match action {
            CounterAction::Increment => {
                Self { inner: self.inner + 1 }.into()
            }
            CounterAction::Decrement => {
                Self { inner: self.inner - 1 }.into()
            }
        }
    }
}

// A WithNotion<T> is required for each notion denoted in the #[bounce(with_notion(T))] attribute.
impl WithNotion<Reset> for Counter {
    fn apply(self: Rc<Self>, _notion: Rc<Reset>) -> Rc<Self> {
        Self::default().into()
    }
}

A notion can be applied with the use_notion_applier hook.

let reset_everything = use_notion_applier::<Reset>();
reset_everything(Reset);

API Reference:

Future Notion

A notion that is applied upon the initiation and completion of an asynchronous task.

A future notion can be defined with the #[future_notion] attribute.

#![allow(unused)]
fn main() {
struct User {
    id: u64,
    name: String,
}

#[future_notion(FetchUser)]
async fn fetch_user(id: &u64) -> User {
    // fetch user

    User { id: *id, name: "John Smith".into() }
}
}

Slices and Atoms can receive updates of a future notion by listening to the Deferred notion.

#![allow(unused)]
fn main() {
#[derive(PartialEq, Default, Atom)]
#[bounce(with_notion(Deferred<FetchUser>))]  // A future notion with type `T` will be applied as `Deferred<T>`.
struct UserState {
    inner: Option<Rc<User>>,
}

// Each time a future notion is run, it will be applied twice.
impl WithNotion<Deferred<FetchUser>> for UserState {
    fn apply(self: Rc<Self>, notion: Rc<Deferred<FetchUser>>) -> Rc<Self> {
        match notion.output() {
            Some(m) => Self { inner: Some(m) }.into(),
            None => self,
        }
    }
}
}

Future Notions can be initiated with the use_future_notion_runner hook.

#![allow(unused)]
fn main() {
let load_user = use_future_notion_runner::<FetchUser>();
load_user(1);
}

API Reference:

Note

The Future Notion API is a low-level API to execute asynchronous tasks.

If you want to interact with an API, it is recommended to use the Query API instead.

The Query API is built with the Future Notion API.

Artifact

An artifact is a side-effect API that collects all values of a state registered in its defining order.

This API is useful when declaring global side effects (e.g.: document title).

API Reference:

Note

If you are trying to manipulate elements in the <head /> element (e.g.: document title), it is recommended to use the Helmet API instead.

Observer

The observer API can be used to create an observed state that notifies the observer when a state changes.

This API can be used to persist a state to the local storage or synchronise it to other tabs.

Helmet API

The Helmet API is an API to manipulate elements resided in the <head /> element.

Elements can be applied with the Helmet component.

#![allow(unused)]
fn main() {
html! {
    <Helmet>
        // The title of current page.
        <title>{"page a title"}</title>
    </Helmet>
}
}

The Helmet API supports the following elements:

  • title
  • style
  • script
  • base
  • link
  • meta

The Helmet API supports setting attributes of the following elements:

  • html
  • body

Helmet Bridge

The <HelmetBridge /> component is used to customise the behaviour and responsible of reconciling the elements to the <head /> element.

#![allow(unused)]
fn main() {
html! {
    <BounceRoot>
        <HelmetBridge default_title="default title" />
        // other components.
    </BounceRoot>
}
}

The Helmet Bridge component accepts two properties, a default_title which will be applied when no other title elements are registered and a format_title function which is used to format the title before it is passed to the document.

API Reference:

Query API

The Query API provides hook-based access to APIs with automatic caching and request deduplication backed by Bounce’s state management mechanism.

Note

Bounce does not provide an implementation of HTTP Client.

You can use reqwest or gloo-net if your backend is using Restful API.

For GraphQL servers, you can use graphql-client in conjunction with reqwest.

Query

A query is a state cached by an Input and queried automatically upon initialisation of the state and re-queried when the input changes.

Queries are usually tied to idempotent methods like GET, which means that they should be side-effect free and can be cached.

If your endpoint modifies data, you need to use a mutation.

API Reference:

Mutation

A hook to run a mutation and subscribes to its result.

A mutation is a state that is not started until the run method is invoked. Mutations are usually used to modify data on the server.

API Reference: