-
Notifications
You must be signed in to change notification settings - Fork 62
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
Reusable components #2
Comments
I think now is a good time to start discussing this, since I don't want to lock down a bad design and then have pain trying to change it later. I hadn't heard of Surplus before (thanks for mentioning it!), but I have worked on other FRP systems before in JavaScript, which is the basis for my work on rust-dominator. I have given some thought to this, but there's many different ways of handling components, with different trade-offs. So here's (to my knowledge) all of the various ways of handling components:
Then within each category there's different ways to implement it. You can also add in Queues at various places, which I've been seriously considering. There's also even weirder things like Cycle.js (which is very cool). No matter what approach you choose, you have further trade-offs to make in Rust (because of Rust's memory system). Trying to make it flexible, powerful, easy, and fast is very hard. |
Nice way of breaking down the types of components! Cycle.js is cool but just a bit too weird for my taste (though of course if you wanna go that way don't let me stop you 😛) I think the biggest question in my mind is how to construct the state (either bundled or separate). Should it be a single large struct within one signal? I think for more granular updates a struct with many signals contained inside it is better, but that's more awkward to construct (probably would end up wanting to build such things with macros). I guess internal / bundled might lend itself to better ergonomics in Rust, because anything external is presumably going to force us the way of Rc's (and so I guess lots of clone() calls!) I'd be interested to hear a bit more about what you're thinking with Queues too, I've not used such an approach before. I might start trying to do some kind of external "store" thing a la Elm / Redux style, mostly just to see what the ergonomics of that work out like, and see if I can build some kind of composability around that. 🤷♂️ |
I'm not sure if I want to create a One True Way to manage state, because in my experience there's many trade-offs, and I want the programmer to choose the trade-off. Having a single large struct within one Signal is similar to the Elm model, and it has some very nice benefits. The biggest downside is performance, since even a tiny change needs to propagate down the entire app. This is probably still faster than Elm, because it's essentially doing a state diff, not a vdom diff. My preferred approach is to have a single State struct where the individual fields are Signals. This bundles up all the state in a convenient way, but still lets each field update individually. But I'm still working out how to make that easy to work with in Rust. It's really hard to avoid cloning with Rust's memory model. When I mentioned Queues, what I meant is basically something very similar to Elm, but with Signals + Queues (and making them internal to the component): fn make_some_component() -> Dom {
struct State {
value: Sender<u32>,
}
enum Message {
Increment,
Decrement,
Reset { value: u32 },
}
let (value, value_signal) = unsync::mutable(0);
let state_queue = make_queue(
// The initial state.
State {
value: value,
},
// Function which is called for each message. It's supposed to update the state.
|state, message| {
match message {
Message::Increment => {
state.value.set(state.value.get() + 1);
},
Message::Decrement => {
state.value.set(state.value.get() - 1);
},
Message::Reset { value } => {
state.value.set(value);
},
}
}
);
Dom::with_state(state_queue, |state_queue| {
html!("div", {
children(&mut [
html!("button", {
event(clone!(state_queue, |event: ClickEvent| {
state_queue.push(Message::Increment);
}));
children(&mut [
text("+1")
]);
}),
html!("button", {
event(clone!(state_queue, |event: ClickEvent| {
state_queue.push(Message::Decrement);
}));
children(&mut [
text("-1")
]);
}),
html!("button", {
event(clone!(state_queue, |event: ClickEvent| {
state_queue.push(Message::Reset { value: 0 });
}));
children(&mut [
text("Reset")
]);
}),
text(value_signal.map(|x| x.to_string()).dynamic())
]);
})
})
} Warning: the above code is completely untested, but it should give the right idea. The benefit of the above approach is that it completely decouples the state from the things that update the state (like event listeners). All of the state updates are in a single place. Event listeners don't need to know anything about the state: they just push things into the queue. Composability can be achieved by having the fn make_some_component(parent_queue: Queue<SomeParentMessage>) -> Dom {
...
parent_queue.push(SomeParentMessage::Foo { ... });
...
} This is a lot cleaner than passing in callbacks. |
Agreed. In my experience the React way is a easy to build small applications but if often hurts at larger scale, where the Redux way makes things easier to reason about. It would be nice if dominator was able to support multiple paradigms (either by providing primitives to build both, or simply being flexible enough to allow the user to do it, maybe with a separate helper crate). I really like your queue design. I haven't come across make_queue, is that something you just typed there for example rather than something that already exists? Though I'm not sure what I think of passing the parent queue into some composable component; that feels to me like it breaks encapsulation (because the child has to know about the form of the parent). I guess Redux-connected components are similarly coupled to the app's store. To me a reusable component would use callbacks (or emit events) and defer to the parent to map those into messages dispatched on the queue. |
It's just an example, I haven't built the
Generally speaking the child component would define and export the message type, and the parent would create the queue and pass the queue to the child. The parent would then map the child component's messages back into its internal state, like this: mod child {
// Public message type, this is what is sent to the parent's queue.
pub enum Message {
Foo { ... },
Bar { ... },
}
// Make the component
pub fn make_component(output_queue: Queue<Message>) -> Dom {
// Internal private messages for this component, it isn't exposed to anyone.
enum InternalMessage {
...
}
// Internal private state for this component, it isn't exposed to anyone.
struct State {
...
}
// This is an internal queue which is not exposed to anyone.
let internal_queue = make_queue(
State { ... },
// Function which is called for each internal message.
|state, message| {
// Update the internal state in here.
}
);
...
// Push a message to the parent
output_queue.push(Message::Foo { ... });
...
}
}
mod parent {
use child;
fn make_component() -> Dom {
// Internal private messages for this component, it isn't exposed to anyone.
enum InternalMessage {
...
}
// Internal private state for this component, it isn't exposed to anyone.
struct State {
...
}
let state = Rc::new(State { ... });
// This is the queue for the child component.
let child_queue = make_queue(
state.clone(),
// Function which is called for each message sent from the child component.
|state, message| {
match message {
child::Message::Foo { ... } => {
// Update the internal state in here.
},
child::Message::Bar { ... } => {
// Update the internal state in here.
},
}
}
);
// This is an internal queue which is not exposed to anyone.
let internal_queue = make_queue(
state,
// Function which is called for each internal message.
|state, message| {
// Update the internal state in here.
}
);
...
child::make_component(child_queue)
...
}
} So the parent and child are decoupled, neither knows about the internal state of the other, they communicate entirely through public Messages in a Queue. But other patterns are possible too. I haven't deeply thought this through, so there's still a lot of unresolved questions and potential problems. |
Ah yep this sounds great to me 😃. I look forward to seeing how this evolves; I'll post any interesting experiments I do in the meanwhile. |
I've given quite a lot of thought to this, and I think these are the fundamental benefits of components:
The above use-cases are obviously useful, but I don't think components are the right way to accomplish it. Components feel a lot like OOP class-based inheritance: fragile, restrictive, trying to combine too many features into one, difficult to compose, etc. Instead, I have added three features that should solve the above use cases in a much faster, cleaner, and more composable way:
By combining these three things together, you can achieve the same use-cases as components. But unlike components, the above features are much faster, they are much simpler to understand and use, and you can mix-and-match them as appropriate. Unlike components which force encapsulation, you can choose what level of encapsulation you want: full state encapsulation for an entire component, state encapsulation for only one mixin, no state encapsulation, etc. I predict that most components would be better if they were created as mixins instead. And for the few places where it makes sense to use components, you can easily create them by combining the above three features. If somebody wants to create a As such, I consider this issue mostly solved. |
Thanks, very thorough discussion! I will take some time to play with those concepts and get back to you if I have further thoughts 😀 |
Maybe it's too early to start talking this; I have been wondering about it a bit so thought I'd ask:
Do you have any feeling for how you would want to componentise dominator (if you would want to componentise 🤔)? I guess Surplus.js is prior art on how something like that could work.
At the moment I can see functions like
being an opportunity for re-use, and maybe that's fine. I'm just used to the React / Vue way of thinking where things are a bit more prescribed. E.g. some kind of trait or other way to express "this is a component" might be cool:
Thanks for hearing out my rambling 😄
The text was updated successfully, but these errors were encountered: