Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Stateful views aka components support #339

Open
MartinKavik opened this issue Jan 23, 2020 · 15 comments
Open

Stateful views aka components support #339

MartinKavik opened this issue Jan 23, 2020 · 15 comments
Assignees
Labels
API design Proposal for a new or updated Seed API.

Comments

@MartinKavik
Copy link
Member

  • There were some experiments with React hooks and I think we should implement something similar in Seed.
  • The idea is:
    1. Use The Elm architecture (TEA) for your business logic and keep all business data in Model.
    2. Then you can use state hooks for pure GUI data - e.g. a state variable mouse_over for button when you just want to switch the button's color on hover.
    3. And you can use Web Components for complex GUI elements or when you often need to interact with JS library / component. (Web Components support #336)
  • 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:
  • 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?

@rebo
Copy link
Collaborator

rebo commented Jan 23, 2020

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:

  1. '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.

  2. Adjusting the Seed architecture to keep TEA but assist modularity. MuhannadAlrusayni has some experiments based on using traits as opposed to functions/structs.

  3. 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.

  4. Use Lenses and callbacks to mimic local state for components. This is the approach Druid is taking.

  5. Use a React Hooks style feature set that achieves component identification and local storage.

  6. 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.

@rebo
Copy link
Collaborator

rebo commented Jan 23, 2020

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.

@rebo
Copy link
Collaborator

rebo commented Jan 25, 2020

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.

Hooks quickstart

@rebo
Copy link
Collaborator

rebo commented Feb 1, 2020

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)],

@TatriX
Copy link
Member

TatriX commented Feb 3, 2020

I have no idea what am I talking about, but is it possible to use Deref and DerefMut instead of get/set?

@MartinKavik
Copy link
Member Author

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.

@MartinKavik
Copy link
Member Author

Update 2: rebo/seed-quickstart-hooks#5 (comment)

@rebo
Copy link
Collaborator

rebo commented Feb 8, 2020

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),
    ]]
}

@rebo
Copy link
Collaborator

rebo commented Feb 17, 2020

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.

rebo/seed-quickstart-hooks#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.

@rebo
Copy link
Collaborator

rebo commented Mar 3, 2020

Ok Just an update on where we all are

  1. Main crate is now seed_hooks. So glob import use seed_hooks::* gives you everything you need.
  2. all StateAccess<T> implements Display as long as T does. Therefore no need for .get() in format! statements in most cases.
  3. on master all StateAccess<T> implements UpdateEl as 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]
    ]
}
  1. Add, Mul, Div and Sub traits all implemented for StateAccess<T> if T does. See above example.

  2. 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 a do_once closure 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
}
```

"#)

@rebo
Copy link
Collaborator

rebo commented Mar 4, 2020

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"
        ]
    ]
}

@rebo
Copy link
Collaborator

rebo commented Mar 8, 2020

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"]]
    }
}

@rebo
Copy link
Collaborator

rebo commented Mar 9, 2020

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()),
    ]
}

@MartinKavik MartinKavik added this to the 3. React-like Hooks milestone Mar 9, 2020
@MartinKavik MartinKavik added the API design Proposal for a new or updated Seed API. label Mar 9, 2020
@rebo
Copy link
Collaborator

rebo commented Mar 29, 2020

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
        )
    ]
}

@rebo
Copy link
Collaborator

rebo commented Mar 30, 2020

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"
    ]
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
API design Proposal for a new or updated Seed API.
Projects
None yet
Development

No branches or pull requests

3 participants