seed
seed copied to clipboard
Stateful views aka components support
- There were some experiments with React hooks and I think we should implement something similar in Seed.
- The idea is:
- Use The Elm architecture (TEA) for your business logic and keep all business data in
Model. - Then you can use state hooks for pure GUI data - e.g. a state variable
mouse_overforbuttonwhen you just want to switch the button's color on hover. - And you can use Web Components for complex GUI elements or when you often need to interact with JS library / component. (https://github.com/seed-rs/seed/issues/336)
- Use The Elm architecture (TEA) for your business logic and keep all business data in
- Experiments also proved that we can make "Seed Hooks" better than the React ones - i.e. users wouldn't have to respect these rules.
- Implementation would require some nightly features, so it will be developed under the feature flag and published once we can use it with stable Rust.
- Experiments:
- https://github.com/rebo/comp_state + other @rebo's repositories.
- An experiment with the React-like limitations. (Note:
StateManangercan be extracted outside of theModel.)
- Requirements:
- Optional - users don't have to use it.
- Not "infectious" - you can use library with hooks in non-hooks app and vice versa.
- No constraints - it's doesn't require additional constraints - e.g.
Ms: Clone. - It's reliable - users are not able to break it (e.g. get wrong state).
- No macros - It's possible to use hooks in Seed apps without procedural macros. (This requirement can be removed if it wouldn't decrease DX.)
- No deps - it should be implemented as a custom code for Seed (i.e. no external dependency like
topo), so we can fully control it and there aren't unnecessary overheads.
Opinions?
Good summary of the goals of such a feature.
Currently in Seed and other Elm style frameworks there are competing concerns regarding application composability, modularity and code reuse.
There are many potential avenues for tackling this on a component level (including not tackling it at all). Each approach has its own advantages and disadvantages. Here are some of which I am aware of at the moment:
-
'Web Components' integration - "Web Components" as opposed to components are a suite of different technologies allowing you to create reusable custom elements. they are supported at the browser level and have their own api and ecosystem. There are some blockers on integrating with Seed particularly with custom element creation via wasm-bindgen.
-
Adjusting the Seed architecture to keep TEA but assist modularity. MuhannadAlrusayni has some experiments based on using traits as opposed to functions/structs.
-
Hardwire componentisation via a "link" in the Model. The link will be used to register callbacks and enable features such as local component storage. I think Yew is taking this approach.
-
Use Lenses and callbacks to mimic local state for components. This is the approach Druid is taking.
-
Use a React Hooks style feature set that achieves component identification and local storage.
-
Do nothing and use existing Seed modularity features such as msg_mapper and clean uses of Model-View-Update cycle.
Each of the above has their own specific set of pros and cons.
This issue is regarding (item 5) but before I write a bit more about that we need to discuss why components might be useful to have.
The Elm Architecture is clearly a fantastic architecture that makes every action explicit and ultimately relatively simple to track. This is especially great for Business logic where it is essential to be able to ensure that a Domain Model is consistent and that any mutation is in response to easy to understand triggers (i.e. Messages/Commands).
That said if every single action within an Elm app has to be hooked up in the same way as business logic is hooked up then boiler plate tends to grow and furthermore it becomes difficult to reuse previous code.
For instance one can create a fantastic Date Picker including UI / css / etc but then to re-use it in different projects you have to clearly decide where in the model the transient state goes, which messages it gets routed through and handle any message interception to affect another part of the app. This is a lot of work just to add a self contained widget.
Therefore there is some milage to exploring some of the above options. The advantage that Rust has over Elm is that it does not have to be 100% pure to be deterministic and predictable, therefore we have some wiggle room in adapting Seed's architecture to allow for components.
I will reply shortly on (item 5) (React Hooks) and why this might be productive for Seed.
React Hooks
React hooks are used within React to allow for local state (amongst other features). Fundamentally the api it exposes is via useState i.e. const [count, setCount] = useState(0); stores the number 0 in variable count and that this variable is persisted across renders.
The advantage of this is that all state can be made local to the view function that includes the useState(). Therefore components are trivial to create and use. Complex functionality can be created on a component basis and re-use is as simple as re-rendering the view function in another location.
The are a number of drawbacks from React's implementation that includes not using useState in loops or conditionals and calling useState in the same order each invocation. Despite this React hooks are very popular and most agree that the developer experience is productive and enjoyable.
Where we are now
It is possible to use an almost identical api right now in Seed (albeit in nightly rust) with none of the disadvantages of React hooks implementation. Here is the code that creates a clickable counter button:
use comp_state::{topo, use_state};
#[topo::nested]
fn my_button() -> Node<Msg> {
let (count, count_access) = use_state(|| 0);
div![
button![
on_click( move |_| count_access.set(count + 1)),
format!("Click Me × {}", count)
]
]
}
There is zero other code or boilerplate needed for instance no fields in a model, message routing or processing in an update function. It is a self contained function that can be reused anywhere. For instance
div![ my_button(), my_button(), my_button(), my_button(), my_button()]
creates 5 buttons each with their own individual state and can be clicked and updated individually. The componentisation is trivial.
This currently relies on the #track_caller compiler feature which is used by the topo crate to associate Ids with specific execution contexts.
It might be productive for developers that something like this to be available within Seed. This issue is about exploring this possibility.
Quick start app showing minimal example of using the use_state() hook. This is approximately what the api might look like in seed with some naming changes.
Just an update as to where we are now. The primary api is now
let val = use_state(||0)
with get() defined for types that are Clone and get_with() for those types that are not Clone. Mutation is via the setters set() or update().
two way binding is also implemented. I.e.:
input![attrs![At::Type=>"number"], bind(At::Value, a)],
I have no idea what am I talking about, but is it possible to use Deref and DerefMut instead of get/set?
I don't have experience with implementing Deref / DerefMut so I don't know limitations / best practices.
See issues in rebo/seed-quickstart-hooks for context.
Update 2: https://github.com/rebo/seed-quickstart-hooks/issues/5#issue-560605953
Comment here rebo/seed-quickstart-hooks#6 on whether seed event handler methods on state accessors make sense for reduced boilerplate:
i.e.
fn my_ev_button() -> Node<Msg> {
let count_access = use_state(|| 0);
div![button![
format!("Clicked {} times", count_access.get()),
count_access.mouse_ev(Ev::Click, |count, _| *count += 1),
]]
}
update... React's useEffect clone aka after_render implemented.
Patterns is for DOM manipulation after nodes have been rendered, or calling javascript after a component has been initially rendered.
https://github.com/rebo/seed-quickstart-hooks/issues/7
Example :
fn focus_example() -> Node<Msg> {
let input = use_state(ElRef::default);
do_once(|| {
after_render(move |_| {
let input_elem: web_sys::HtmlElement = input.get().get().expect("input element");
input_elem.focus().expect("focus input");
});
});
input![el_ref(&input.get())]
}
React's useEffect 'clean up' closures not implemented.
Ok Just an update on where we all are
- Main crate is now seed_hooks. So glob import
use seed_hooks::*gives you everything you need. - all
StateAccess<T>implementsDisplayas long as T does. Therefore no need for.get()in format! statements in most cases. - on master all
StateAccess<T>implementsUpdateElas long as T does. Therefore no need for.get()to output most state accessors. I.e. this code just works:
#[topo::nested]
fn numberbind() -> Node<Msg> {
let a = use_state(|| 0);
let b = use_state(|| 0);
div![
input![attrs![At::Type=>"number"], bind(At::Value, a)],
input![attrs![At::Type=>"number"], bind(At::Value, b)],
p![a, "+", b, "=", a + b]
]
}
-
Add,Mul,DivandSubtraits all implemented forStateAccess<T>if T does. See above example. -
DropTypes have been implemented, they are constructed with a boxed closure which fires when a state-variable is no longer being accessed. Useful for resetting state or allowing ado_onceclosure to re-run after a modal is closed.
A DropType for a given state variable can be created by calling reset_on_drop() on the relevant state_accessor.
do_once now returns a state accessor for the bool that causes the do_once block to rerun.
I..e this block runs a code highlighter once a markdown block has been rendered for the first time.
do_once(||
call_javascript_code_highlighting_library();
).reset_on_drop();
md!(r#"
The following code's source will be highlighted:
```rust
fn seed_rocks() -> bool {
true
}
```
"#)
Couple of tweaks to previous update.
DropType is now called Unmount which is a more descriptive type.
Also reset_on_drop() is now called reset_on_unmount(). use_drop_type is now called on_unmount.
e.g. in the below b and c will be unset when the component is unmounted.
#[topo::nested]
fn unmount_ex() -> Node<Msg> {
let a = use_state(|| 0);
let b = use_state(|| 0).reset_on_unmount();
// longer way of saying the same thing as .reset_on_unmount();
let c = use_state(|| 0);
on_unmount(|| c.delete());
div![
format!("a:{}, b:{}, c{}",a ,b ,c ),
button![
a.mouse_ev(Ev::Click, |v| *v += 1),
b.mouse_ev(Ev::Click, |v| *v += 1),
c.mouse_ev(Ev::Click, |v| *v += 1),
"Increment"
]
]
}
Initial draft of fetch support.
fetch_once fetches once straight away and deserializes to the given type (useful for fetching on mount). It automatically refreshes the view when the response is received.
fetch_later prepares a fetch and but only fetches on do_fetch() being called, useful if a fetch is needed in response to a button click.
Let me know if you have any comment on the API surface.
API login example as below:
#[topo::nested]
// fetches a user on log-in click
fn show_user_later() -> Node<Msg> {
let fetch_user = fetch_later::<User, _>("user.json");
if let Some(user) = fetch_user.fetched_value() {
div![
"Logged in User: ",
user.name,
div![a![
attrs![At::Href=>"#logout"],
"Log out",
mouse_ev(Ev::Click, move |_| {
fetch_user.delete();
Msg::default()
})
]]
]
} else {
div![a![
attrs![At::Href=>"#login"],
"Log in",
mouse_ev(Ev::Click, move |_| {
fetch_user.do_fetch::<Model, Msg, Node<Msg>>(Msg::default());
Msg::default()
})
]]
}
}
and
#[topo::nested]
// fetches a user on mount
fn show_user() -> Node<Msg> {
let fetch_user = fetch_once::<User, Model, _, _>("user.json", Msg::default());
if let Some(user) = fetch_user.fetched_value() {
div![
"Logged in User: ",
user.name,
div![a![
attrs![At::Href=>"#logout"],
"Log out",
mouse_ev(Ev::Click, move |_| {
fetch_user.delete();
Msg::default()
})
]]
]
} else {
div![a![attrs![At::Href=>"#login"], "Log in"]]
}
}
Initial draft of forms support.
Chuck any struct with serde support in use_form and automatic two-way binding will be set up via form.input(key) .
With optional validation and soon nested form support.
As before let me know what you think of the api surface.
#[derive(Clone, Validate, Debug, Serialize, Deserialize)]
struct User {
#[validate(length(min = 1))]
name: String,
#[validate(email)]
email: String,
#[validate(range(min = 18, max = 39))]
age: u32,
}
#[topo::nested]
fn form_test() -> Node<Msg> {
let form = use_form(|| User {
name: "The Queen".to_string(),
email: "Buckingham Palace, London, England".to_string(),
age: 32,
});
div![
form.input("name"),
form.input("email"),
form.input("age"),
format!("data: {:#?}", form.get().data),
format!("errors: {:#?}", form.get().data.validate()),
]
}
Another update on this, fully working styled components.
These give the ability to define css within the component itself with no namespace issues.
The issue with direct style!{} styling is that they take precedence over class based CSS styling plus they cannot be used for things you would use CSS for like focus and hover. Other in component options rely on external frameworks such as Bootstrap or Tailwind, both very good but ties any re-usable component to those frameworks.
Styled components are just CSS so do not require any additional javascript or css framework.
Usage is simple. The use_style function returns a class name which is used to scope the style to that specific component.
fn styled_components_example() -> Node<Msg> {
let style = use_style("color : #F00;");
div![C![style], "This is styled Component's in Seed"]
}
The above simply ensures the text is red in colour.
It also works well for dynamic styles , this button grows every time it is clicked:
#[topo::nested]
fn button2() -> Node<Msg> {
let font_size = use_state(||24);
let style = use_style(format!(r#"
font-size: {}px;
color: coral;
padding: 0.25rem 1rem;
border: solid 2px coral;
border-radius: 3px;
outline: none;
margin: 0.5rem;
&:hover {{
background-color: bisque;
}}
"#, font_size.get() ));
button![
C![style],
format!("The font size is {}px!", font_size),
font_size.mouse_ev(
Ev::Click,|font_size,_| *font_size += 1
)
]
}
Also included is theme support, this would enable all children components of use_theme to use specific themes (or a default if not provided).
fn view(_model: &Model) -> Node<Msg> {
let theme = Theme::new_with_styles(
&[
("primary_color", "orange"),
("secondary_color", "gray"),
]
);
use_theme(theme, ||
div![
styled_component_example(),
]
)
}
fn styled_component_example() -> Node<Msg> {
let style = use_style(r#"
font-size : 24px;
background-color : {{theme.primary_color||red}};
border-color : {{theme.secondary_color||green}};
border-width: 10px;
padding: 5px;
"#);
div![
C![style],
"This is styled Component's in Seed"
]
}
Furthermore keyed animation support is present via {{anim.name}}
fn styled_component_example() -> Node<Msg> {
let style = use_style(r#"
{
font-size : 20px;
border-width: 10px;
padding: 5px;
animation-name: {{anim.slide}};
animation-duration: 4s;
}
@keyframes {{anim.slide}} {
from {
transform: translateX(0%);
}
to {
transform: translateX(100%);
}
}
"#);
div![
C![style],
"This is styled Component's in Seed"
]
}
and media queries via {{self}}:
fn styled_component_example() -> Node<Msg> {
let style= use_style(r#"
{
font-size : 20px;
border-width: 10px;
padding: 5px;
@media only screen and (max-width: 400px) {
{{self}} {
background-color: lightblue;
}
}
"#);
div![
C![style],
"This is styled Component's in Seed"
]
}