Introduction
Bounce
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:
You need the following tools:
-
Rust with
wasm32-unknown-unknown
target toolchainIf you installed Rust with rustup, You can obtain the
wasm32-unknown-unknown
target toolchain with the following command:rustup target add wasm32-unknown-unknown
-
The Trunk Bundler
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 aUseAtomHandle<T>
, which can be dereferenced toT
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
: ReturnsRc<T>
and triggers a re-render when the value changes. -
use_atom_setter
: Returns a setter of typeRc<dyn Fn(T)>
that can be used to update the value ofT
. 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: