Introduction
What is avalanche?
avalanche
is a platform-agnostic UI library aiming to bridge the strong typing, efficient runtime performance, and
cross-platform prowess of Rust with the ergonomics of existing UI libraries like React and Svelte.
Like React and Vue, this library allows writing your UI code declaratively, with the ease-of-use of HTML
but with dynamic updates. Unlike those libraries, however, avalanche
analyzes your code
at compile time to eliminate the need for most runtime comparisons and updates, meaning you get declarative
ergonomics with imperative performance.
While we believe the foundations for these goals are strong, the library is still very much a work in progress, and the implementation may not always reach our standards yet. As such, while this library is useable for hobbyist projects, it is NOT production ready.
What is avalanche_web?
avalanche_web
is the official Web platform interface for avalanche
, allowing you to use avalanche
in browsers and
Electron apps by compiling your app to WebAssembly. It may be missing some listeners and other functionality as of now, but all
HTML tags are available for use as components.
Using this tutorial
Before using this tutorial, you should first have a decent grasp of the Rust programming language. This tutorial also heavily uses avalanche_web
, so you should be familiar with basic HTML.
This tutorial is new and still rough, so if you have any confusion or see a potential error, we'd be grateful if you created an issue.
Getting started
Prerequisites
Before you get started, ensure you have cargo
and npm
installed.
Creating an app
To create an avalanche_web
app, first install trunk if you haven't already:
cargo install trunk
.
Next, create a binary application with cargo new <my-app-name>
. Within the root of that
directory, create an index.html file with these contents:
<html>
<head>
</head>
<body>
</body>
</html>
This serves as a template for trunk
to generate an entrypoint index.html
file for
your application, along with providing us scaffolding to supply app metadata and the like.
With that basic setup out of the way, it's time to actually integrate avalanche-web
.
In the Cargo.toml
file, add the following dependencies:
avalanche = "^0.1.0"
avalanche_web = "^0.1.0"
Next, add this code somewhere outside the main
function:
#![allow(unused)] fn main() { use avalanche::{component, View}; use avalanche_web::components::{H1, Text}; #[component] fn HelloWorld() -> View { H1(self, [ Text(self, "Hello world!") ]) } }
Finally, add this call in main
:
use avalanche::{component, View}; use avalanche_web::components::{H1, Text}; #[component] fn HelloWorld() -> View { H1(self, [ Text(self, "Hello world!") ]) } fn main_dont_run() { avalanche_web::mount_to_body::<HelloWorld>(); }
Now, to see your app running in the browser, execute trunk serve --open
within the
my-app-name
directory. If you haven't gotten any compiler errors,
you should see your first avalanche
and avalanche_web
web app!
Explaining hello world
So what exactly is going on in the code above? Well, the most important aspect here is the #[component]
attribute macro.
#[component]
accepts a function returning a View
, which is the result of rendering a component.
Within a function marked with #[component]
, functions whose names begin with a capital ASCII character
and accept the component context self
are interpreted as special component calls.
Text
is a component with a single parameter, so by specifying that, along with the context self
, we get an instance
of View
with that text component. H1
can take an array of children, so this component here is equivalent to writing
this raw HTML:
<h1>
Hello world!
</h1>
Finally, we pass our new HelloWorld
component as a type argument to avalanche_web::mount_to_body
, which, as the name implies,
renders our HelloWorld
component within the <body></body>
of our web app.
Basic components
Components are the building blocks of avalanche
apps.
They're represented as functions since they're fundamentally meant to be pure:
they take in inputs and describe a UI as their output. They're so fundamental that
this book is largely a component tutorial.
Creating components
Though components are written as functions, avalanche
implements some quality-of-life and performance features
not possible with functions. This requires the use of specialized syntax.
If we call a component that takes no parameters called Comp
, for example, we'll call
it with Comp(self)
. These component calls only work within the bodies of other components, and they return the
View
type.
Passing parameters to components
Most components have parameters, and they're named. For example, in the components below, we pass href
by name. However, the last
parameter in a function definition can be supplied without a name as the last parameter in a call.
For Text
, that's the text
parameter, which takes something convertible to a Cow<'_, str>
,
meaning you can pass it either a String
or a &str
.
For every other avalanche_web
component, the unnamed last parameter is children
, which takes an Into<Vec<View>>
.
Below are two versions of a link component. One passes text
and children
explicitly, while the other does it implicitly, as is idiomatic.
#![allow(unused)] fn main() { use avalanche::{component, View}; use avalanche_web::components::{A, Text}; const FIRST_SITE: &str = "http://info.cern.ch/hypertext/WWW/TheProject.html"; #[component] fn LinkExplicit() -> View { A( self, href = FIRST_SITE, children = [ Text(self, text = "The first website") ] ) } #[component] fn LinkImplicit() -> View { A( self, href = FIRST_SITE, [ Text(self, "The first website") ] ) } }
For avalanche_web
components, it's generally recommended to use implicit last parameters, so we'll be using them from now on in this tutorial.
In third-party components, by convention, you should only use them if there is only one
parameter, like in Text
, or if the last parameter is a child or children of the component.
Receiving parameters and tracking
So far, we've dealt only with components with hard-coded data. However, within most practical components, we want to allow other components to pass them data.
To enable that, you simply need to add parameters to your component function. Here, let's say we want a reusable Link
with its own custom functionality,
and want to allow a custom destination and text to be specified. We can add to
and text
parameters:
#![allow(unused)] fn main() { use avalanche::{component, tracked, View}; use avalanche_web::components::{A, Text}; #[component] fn Link(to: &str, text: &str) -> View { A( self, href = tracked!(to), [ Text(self, tracked!(text)) ] ) } }
Notice that for the href
and implicit text
parameters, we pass tracked!
parameters instead of just passing them by value.
That's because when we say a parameter is, for example, a &str
, we actually receive a Tracked<&str>
. A Tracked
value
wraps an inner value with data on whether it's been updated since last render. avalanche
uses this to allow for efficient re-rendering
of components. Calling tracked!
on a Tracked
value gives us its inner value. Since Text
and A
expect &str
-like values, but
the to
and text
parameters are Tracked<&str>
, we use tracked!()
to get &str
values.
We can then construct Link
inside of other components:
#![allow(unused)] fn main() { use avalanche::{component, tracked, View}; use avalanche_web::components::{Text, Div}; use avalanche_web::components::A; #[component] fn Link(to: &str, text: &str) -> View { A( self, href = tracked!(to), [ Text(self, tracked!(text)) ] ) } #[component] fn Example() -> View { Div(self, [ Link( self, to = "https://example.com", text = "example.com" ), Text(self, " is a domain reserved for use in demos.") ]) } }
When rendered, the above is equivalent to the HTML below.
<div>
<a href="https://example.com">
example.com
</a>
is a domain reserved for use in demos.
</div>
Within component calls, parameter order does not matter (except for what the implict last parameter is),
so we could've also called Link
with to
and text
swapped:
Link(
self,
text = "example.com".into(),
to = "https://example.com".into()
)
Parameter type restrictions
All parameters must implement Clone
. Note that all non-mut
references implement Clone
, making this restriction
generally easy to deal with in practice.
Hooks, State, and Control Flow
Earlier, we described components as pure functions. Hooks allow us to introduce external data, state, and other impure data into our components.
Hooks
So far, all the data we've passed to parameters and used in general was static, specified at compilation time. But in real apps, we usually need to maintain state for things like network request statuses and inputs.
Let's say we're trying to write a counter. We'll start out with a stateless counter:
#![allow(unused)] fn main() { use avalanche::{component, tracked, View}; use avalanche_web::components::{Div, H2, Button, Text}; #[component] fn Counter() -> View { Div(self, [ H2(self, [ Text(self, "Counter!") ]), Button( self, on_click = |_| todo!(), [ Text(self, "+") ] ), Text(self, 0.to_string()) ]) } }
Since we don't keep track of the count yet, we've substituted it with the value 0
. Text
's text
parameter accepts any value that implements ToString
,
so we can pass 0
instead of "0"
. We've also defined the on_click
handler for Button
, so the given closure's code will run on every click.
Since right now we have no state to update yet, we call todo!()
, so clicking the button would crash our app.
Now that we have our starting point, let's finally inject some state. The state
hook allows us to inject
state so we can keep track of the number of clicks. Since we have a count that monotonically increases, we can use an unsigned integer u64
with state<u64, _>
.
Let's finally make our counter stateful with the state
hook:
#![allow(unused)] fn main() { use avalanche::{component, tracked, state, View}; use avalanche_web::components::{Div, H2, Button, Text}; #[component] fn Counter() -> View { let (count, set_count) = state::<u64>(self, || 0); Div(self, [ H2(self, [ Text(self, "Counter!") ]), Button( self, on_click = move |_| set_count.update(|count| *count += 1), [ Text(self, "+") ] ), Text(self, tracked!(count).to_string()) ]) } }
Hooks are function calls that take self
as their first parameter. This allows #[component]
to know the function call is
a hook, allowing it to pass in some internal state. Hooks have a call site identity, so the above state
call will return a reference
to the same state every render. A different state
call elsewhere in the code, however, would reference different state.
The state
hook takes the context self
and a function providing the state's default value for u64
and returns a tuple with a reference to the
current state (which we name count
) and a setter, which we name set_count
. The setter has an update
method which accepts a closure that receives a &mut u64
and modifies it. Every time the user clicks on the button, on_click
fires, calling set_counter.update()
, which runs
*count += 1
, updating the state and causing the component to be rerendered.
Note that the reference returned is tracked, so here we get Tracked<&u64>
instead of &u64
. This is where the tracked system becomes useful.
Constant values, when passed into components, are never considered updated. But if we called set_count.updated
since the last time we called state
,
on the next call of state
, count
will be marked as updated. That lets avalanche
know we should update the text of Text(self, tracked!(count).to_string())
in the UI.
The state returned by each call site of state
is unique. If we call state
in two different places, their values aren't linked.
If you've used React before, you might be familiar with the rules of hooks. Those don't apply here: feel free to call hooks within
if
, loops, and other complex control flow! You do still need to keep hook calls inside of components, but that's enforced at compile time.
With that, we have our first stateful component! If we instead wanted to simply
set the state to a value like 0
, we could write set_count.set(0)
as a shorthand for set_count.update(|count| *count = 0)
.
Dynamic rendering
Oftentimes, we don't just want to update property values based on changes to state and props, but also what children we render.
In some frameworks, that requires special template syntax, but we can take advantage of the fact that the Component!()
syntax returns plain
Rust values to use normal control flow instead.
Conditional rendering
Want to render something only if a condition is true? Use an if
statement:
#![allow(unused)] fn main() { use avalanche::{component, tracked, View}; use avalanche_web::components::Text; #[component] fn Conditional(cond: bool) -> View { if tracked!(cond) { Text(self, "True!") } else { ().into() } } }
From this point, we'll be omitting previously used
avalanche
andavalanche_web
imports for brevity.
The ()
type is a special Component
that renders nothing. We call .into()
on it to turn it into a View
.
There is also a shorthand for this some component-or-nothing case:
#![allow(unused)] fn main() { use avalanche::{component, tracked, View}; use avalanche_web::components::Text; #[component] fn Conditional(cond: bool) -> View { tracked!(cond).then(|| Text(self, "True!")).into() } }
That's equivalent to the above example. This uses bool
's then method.
Option<View>
is also a special component, rendering the View
in its Some
case and rendering nothing in its
None
case. Of course, if you want more complex conditionals, you can use other control flow like else if
and
match
.
Tracking
The previous chapter showed how even with state, we can write our UI declaratively. But how, exactly, does this work?
In theory, avalanche
could rerender our entire app on every state change, but in practice, that's often too inefficient.
Instead, avalanche
uses the tracked system. Parameters and many hook return values are Tracked
, and expressions with
tracked!()
calls also have a value of type Tracked
. Then, avalanche only marks child parameters as updated if their input
had a tracked!()
value that was updated.
Handling impurity
The tracked system relies on purity, or the idea that a component's output should only be the result of its input parameters.
Not doing that will lead to some issues. Take this first naive, impure attempt at introducing a random number to a
component, using the rand
crate's random
function, which uses thread-local state in its implementation.
Here, we'll try to show a random number, and generate a new one every time we click a button.
#![allow(unused)] fn main() { use avalanche::{component, state, View}; use avalanche_web::components::{Text, Div, Button}; use rand::random; #[component] fn Random() -> View { let (_, trigger_update) = state(self, || ()); Div( self, [ Button( self, on_click = move |_| trigger_update.set(()), [ Text(self, "Generate new number") ], ), Div(self, [ Text(self, random::<u16>().to_string()) ]) ] ) } }
However, if you try out the example for yourself, you'll notice that the number doesn't actually update. A reasonable explanation for this would be
we're not actually modifying trigger_update
's state, or that its current value is equal to the previous one. However, calling a state setter always
triggers a component rerender. Rather, the reason is that random::<u16>()
is not Tracked
, so it's considered a constant. This leads to an important rule:
a component's parameter will only update if it has a tracked!()
call with an updated Tracked
value. Text(self, random::<u16>().to_string())
doesn't have a tracked!()
call, so it will never update.
The issue here is that random
is an impure function, but avalanche doesn't know that. The solution is to use hooks to contain impurity, like we did with
the counter example.
#![allow(unused)] fn main() { use avalanche::{component, tracked, state, View}; use avalanche_web::components::{Text, Div, Button}; use rand::random; #[component] fn Random() -> View { let (value, set_value) = state(self, || random::<u16>()); Div( self, [ Button( self, on_click = move |_| set_value.set(random()), [ Text(self, "Generate new number") ] ), Div(self, [ Text(self, tracked!(value).to_string()) ]) ] ) } }
Now, text
clearly depends on the value
state, so when we call set_value
, avalanche
will update that text value on screen.
Using hooks introduces impurity in a manner avalanche can understand.
Rules of tracked
This example leads us to the three main tracked rules:
Avoid mutable variables in components
Mutable variables make the tracked system leaky. Consider this snippet of code:
let (value, _) = state(self, || 5);
let mut mutable = 2;
mutable += *tracked!(value);
Since mutable
isn't tracked, but its value depends on a Tracked
value, using it as a component parameter will
cause unexpected failures to update UI appearance. Instead, prefer creating new variables.
For parameter values, directly using values like rand::random()
is a problem because they depend on some external state
that avalanche
is not aware of, leading to stale values displayed on rerender. This also applies to values like Rc<RefCell<T>>
if they are updated outside of hook functionality. When using interior mutability, then, make sure to wrap it in state
, and perform updates
within the set
or update
methods.
Avoid third-party macros
Macros can lead to convenient code; for example, Text(self, format!("Hello, {} {}!", tracked!(title), tracked!(name)))
is a lot clearer than macro-free
alternatives. However, when using non-std
and avalanche
macros, #[component]
is unable to track their dependencies correctly, meaning
parameters based on those macros may not update correctly. In the future, we may instead opt to consider those macros always updated (at the cost of significantly reduced efficiency), or offer some syntax to specify tracked values explicitly for them. Either way, we recommend you avoid those macros for now.
Rendering dynamic lists
Rendering more heavily dynamic content is where the tracked system shines. But first, a pitfall: here's a potential first attempt at rendering a list of dynamic children:
#![allow(unused)] fn main() { use avalanche::{component, tracked, state, View}; use avalanche_web::components::{Ul, Li, Text}; #[component] fn List() -> View { let (items, update_items) = state(self, || vec![String::from("a")]); Ul( self, tracked!(items).iter().map(|item| Li( self, key = item, [Text(self, item)] )).collect::<Vec<_>>() ) } }
Here, we use the standard iterator methods map
and collect
to turn a Vec
of strings into a Vec
of View
s.
However, notice that in the component Text(self, item)
, the implicit text
parameter has no tracked!
call, so if we later
update the element "a"
, for example, that change won't be appropriately rendered. We can change that with the store
hook,
which enables storing state with fine-grained tracking. What that means is we can keep track of when specific elements of the
state were last updated. This is possible with the Tracked::new
method, which allows creating a tracked value with its wrapped
value and what render cycle, or Gen
, it was created on. The init function and update
method of store
provide a Gen
, allowing
us to create and modify tracked values.
#![allow(unused)] fn main() { use avalanche::{component, tracked, View}; use avalanche_web::components::{Ul, Li, Text}; use avalanche::{store, Tracked}; #[component] fn List() -> View { let (items, update_items) = store(self, |gen| vec![Tracked::new("a", gen)]); Ul( self, tracked!(items).iter().map(|item| Li( self, key = tracked!(item), [Text(self, tracked!(item))] )).collect::<Vec<_>>() ) } }
Now the iter
method on items
returns elements of type Tracked<&String>
rather than &String
.
Keys
So far, this explanation has only applied previous concepts, but there's the important new concept of keys. Every avalanche component has a special String
key
parameter. Whenever a particular component call location in code may be called more than once, it is required to specify a key to disambiguate the multiple instantiations; this is enforced with a runtime panic if not specified. Note that the key must be added on the topmost level, so in our example above,
Li(
self,
key = tracked!(item),
[
Text(self, tracked!(item))
]
)
is good but this is not:
Li(self, [
Text(
self,
key = tracked!(item),
tracked!(item)
)
])
Event handling and input elements
So far, we've only used the on_click
event, but there are both many more events and more functionality available.
In addition, many events are component-specific, like on_input
and on_blur
on some form elements. Many of these are available,
but some have not been implemented yet. If you're missing one, please file an issue!
Events
Every global event is settable as a parameter
on every non-Text
avalanche_web
component. Each event handling parameter takes a function of type impl Fn(TypedEvent<E, C>)
where E
is the web_sys
type for the handler's event and C
is web_sys
's native type for the component's native DOM element.
Often, we don't need the event, so we omit it, hence closures like on_click = move |_| ...
.
Input elements
One case where events are useful is with input elements. That's because the TypedEvent
type gives access to the current_target()
method,
which provides a reference to the component's associated native reference. We can use this to keep track of an Input
element's state:
#![allow(unused)] fn main() { use avalanche::{component, tracked, state, View}; use avalanche_web::components::Input; #[component] fn ControlledInput() -> View { let (text, set_text) = state(self, || String::new()); Input( self, value = tracked!(text).clone(), on_input = move |e| set_text.set(e.current_target().unwrap().value()) ) } }
In this example, text
holds the input's current contents, allowing us to use it for other purposes.
In React, programmers often use
on_change
instead ofon_input
, but React semantics do not match native browser ones in this case; useon_input
inavalanche_web
instead.
For a more complex example of how state and events work in avalanche, check out the todomvc example.
Advanced component props
Documenting parameters
Documentation is important to ensure components are easy to use and understand. Parameters can be documented with standard Rust documentation comments and attributes:
#![allow(unused)] fn main() { use avalanche::{component, tracked, View}; use avalanche_web::components::Text; /// Repeats a character for the specified number of times. #[component] fn Repeat( /// The character to repeat. character: char, #[doc = "How many times the character is repeated."] n: usize, ) -> View { Text(self, tracked!(character).to_string().repeat(tracked!(n))) } }
Default parameters
Imagine that we wanted to hide the Repeat
component if the user does not
supply a value for n
. We can do this by using usize
's default value:
#![allow(unused)] fn main() { use avalanche::{component, tracked, View}; use avalanche_web::components::Text; #[component] fn Repeat( character: char, // How many times the character is repeated. #[default] n: usize ) -> View { Text(self, tracked!(character).to_string().repeat(tracked!(n))) } }
Specifying custom default values is also possible. If we want
to make Repeat
excitement-themed, we could choose a default character value '!'
:
#![allow(unused)] fn main() { use avalanche::{component, tracked, View}; use avalanche_web::components::Text; #[component] fn Repeat( #[default = '!'] character: char, #[default] // How many times the character is repeated. n: usize ) -> View { Text(self, tracked!(character).to_string().repeat(tracked!(n))) } }
The component can now be called with Repeat(self)
, which would render nothing,
or Repeat(self, count = 3)
, which would render !!!
.
A default value can be any valid Rust expression. The expression will only be evaluated if the caller of the component does not supply their own value.
Generic components
Generic components can be written similarly to generic functions:
#![allow(unused)] fn main() { use avalanche::{component, tracked, View}; use avalanche_web::components::Text; use std::string::ToString; #[component] fn SayHello<S: ToString>(name: S) -> View { Text(self, format!("Hello, {}!", tracked!(name).to_string())) } }
Note that
impl Trait
properties are not supported in components.