Skip to content

Component builder instantiation syntax#179

Open
XX wants to merge 3 commits intovidhanio:mainfrom
XX:component_builder
Open

Component builder instantiation syntax#179
XX wants to merge 3 commits intovidhanio:mainfrom
XX:component_builder

Conversation

@XX
Copy link

@XX XX commented Feb 25, 2026

The rsx_cb!, maud_cb! and Builder derive macros have been implemented.

Closes issue #180

@vidhanio
Copy link
Owner

This looks great, and I agree that the macro should exist. However, could this not also be solved by making the macro simply apply ..Default::default() for every component rather than needing an entire builder?

@XX
Copy link
Author

XX commented Feb 26, 2026

@vidhanio Thank you!

Using methods instead of directly assigning values to struct fields provides significantly more flexibility. For example, here is how one of my components is structured:

#[derive(Default, AsRef, AsMut)]
pub struct CodeExample {
    pub open: bool,

    #[as_ref]
    #[as_mut]
    pub attrs: CommonAttrs,

    pub children: Lazy<fn(&mut Buffer)>,
}

The CommonAttrs type encapsulates properties shared by many components, such as id, class, and style:

#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CommonAttrs {
    pub id: Cow<'static, str>,
    pub classes: Vec<Cow<'static, str>>,
    pub styles: Vec<Cow<'static, str>>,
}

To avoid explicitly declaring fields for these attributes in every component or manually implementing builder methods, a trait is used:

pub trait CommonAttributeSetters {
    fn id(mut self, id: impl Into<Cow<'static, str>>) -> Self
    where
        Self: Sized,
    {
        self.set_id(id);
        self
    }

    fn class(mut self, class: impl Into<Cow<'static, str>>) -> Self
    where
        Self: Sized,
    {
        self.add_class(class);
        self
    }

    fn style(mut self, style: impl Into<Cow<'static, str>>) -> Self
    where
        Self: Sized,
    {
        self.add_style(style);
        self
    }

    fn set_id(&mut self, id: impl Into<Cow<'static, str>>);

    fn set_classes(&mut self, classes: Vec<Cow<'static, str>>);

    fn set_styles(&mut self, styles: Vec<Cow<'static, str>>);

    fn add_class(&mut self, class: impl Into<Cow<'static, str>>);

    fn add_style(&mut self, style: impl Into<Cow<'static, str>>);
}

impl CommonAttributeSetters for CommonAttrs {
    fn set_id(&mut self, id: impl Into<Cow<'static, str>>) {
        self.id = id.into();
    }

    fn set_classes(&mut self, classes: Vec<Cow<'static, str>>) {
        self.classes = classes;
    }

    fn set_styles(&mut self, styles: Vec<Cow<'static, str>>) {
        self.styles = styles;
    }

    fn add_class(&mut self, class: impl Into<Cow<'static, str>>) {
        self.classes.push(class.into());
    }

    fn add_style(&mut self, style: impl Into<Cow<'static, str>>) {
        self.styles.push(style.into());
    }
}

With a blanket implementation for all types that implement AsMut<CommonAttrs>:

impl<T: AsMut<CommonAttrs>> CommonAttributeSetters for T {
    fn set_id(&mut self, id: impl Into<Cow<'static, str>>) {
        self.as_mut().set_id(id);
    }

    fn set_classes(&mut self, classes: Vec<Cow<'static, str>>) {
        self.as_mut().set_classes(classes);
    }

    fn set_styles(&mut self, styles: Vec<Cow<'static, str>>) {
        self.as_mut().set_styles(styles);
    }

    fn add_class(&mut self, class: impl Into<Cow<'static, str>>) {
        self.as_mut().add_class(class);
    }

    fn add_style(&mut self, style: impl Into<Cow<'static, str>>) {
        self.as_mut().add_style(style);
    }
}

Now, for a component to support common attributes, it is sufficient to implement AsMut<CommonAttrs>, and the builder methods become available automatically:

#[derive(Default, AsRef, AsMut, Builder)]
pub struct CodeExample {
    pub open: bool,

    #[as_ref]
    #[as_mut]
    #[builder(skip)]
    pub attrs: CommonAttrs,

    pub children: Lazy<fn(&mut Buffer)>,
}
rsx_cb! {
    <CodeExample open=true id="the-example" class="code-example" />
}

Additionally, using methods allows performing arbitrary transformations when setting values. For example, taking a class string and appending it to the classes vector — or performing more complex logic if needed.

@vidhanio
Copy link
Owner

I see, that makes a lot of sense. Instead of having it be its own macro (which disallows mixing builder/normal components), how would a separate syntax for these sound? something like <Component!> or <Component?>? This isn't a recommendation or something, just wanted to get your thoughts on if something like this would make sense.

@XX
Copy link
Author

XX commented Feb 28, 2026

@vidhanio
Ideally, there should be no additional syntax at the component call site (and no separate macro names either). The decision about how a component is constructed should be made by the component itself at the definition site. It is rather difficult to achieve without overhead in Rust today. The best approach I’ve come up with is to generate auxiliary struct-construction code via the #[component] macro.

For example, a general rule for generating instantiation code could be based on builder-style calls:

Component::builder()
    .foo(value_a.into())
    .bar(value_b.into())
    .build()

Then the #[component] macro could generate one of two component definitions, depending on its parameters:

1. No additional arguments or typed_builder specified

variants: #[component] and #[component(typed_builder)]

#[derive(TypedBuilder)]
struct Component {
    foo: bool,
    bar: i32,
}

impl Renderable for Component {
    ...
}

2. builder or default_builder specified

variant: #[component(builder)] or #[component(default_builder)]

#[derive(Default)]
struct Component {
    foo: bool,
    bar: i32,
}

impl Component {
    fn builder() -> Self {
        Self::default()
    }

    fn foo(mut self, foo: bool) -> Self {
        self.foo = foo;
        self
    }

    fn bar(mut self, bar: i32) -> Self {
        self.bar = bar;
        self
    }

    fn build(self) -> Self {
        self
    }
}

impl Renderable for Component {
    ...
}

Then the builder-style instantiation code

Component::builder()
    .foo(value_a.into())
    .bar(value_b.into())
    .build()

becomes universal — it works both for components that implement Default and those that do not.

Moreover, in the second case (thanks to TypedBuilder), the compiler ensures that all struct fields are initialized, providing the same guarantees as the struct-literal approach.

At the same time, this opens up broad possibilities for custom user components, since users can implement their own builders however they like — including adding methods for non-existent fields, delegating calls to aggregated objects, supporting From/Into conversions in arguments, and so on.

Perhaps the only incompatibility with the existing API is that there would no longer be a distinction between omitting a component property and appending .. at the end, if the component implements Default. In the current implementation, omitting a property results in a compilation error — even if the component implements Default — unless .. is explicitly specified at the instantiation site. It might be possible to preserve this behavior by modifying TypedBuilder to support something like:

Component::builder()
    .foo(value_a.into())
    .default()
    .build()

This would likely require some additional effort. However, I don’t think preserving that exact API behavior for default-based components is strictly necessary.

@circuitsacul
Copy link

circuitsacul commented Feb 28, 2026

Commenting as someone who doesn't understand the underlying code here, and so I make speak nonsense.

Would it be possible to change all components to use the same builder syntax regardless of whether it's "needed"? This might be a breaking change, but it could be worthwhile.

I have previously come across this very cool builder system that uses typestates to keep correctness: https://docs.rs/bon/latest/bon/

It supports both optional and required arguments and maintains static type-safety in all cases. They also claim equivalent performance to normal function calls in their benchmarks (and they also do ASM comparisons).

The way I think it would look would be something like this (semi-pseudo code):

To yank out from bon's readme:

use bon::builder;

#[builder]
fn greet(name: &str, level: Option<u32>) -> String {
    let level = level.unwrap_or(0);

    format!("Hello {name}! Your level is {level}")
}

let greeting = greet()
    .name("Bon")
    .level(24) // <- setting `level` is optional, we could omit it
    .call();

assert_eq!(greeting, "Hello Bon! Your level is 24");

Then the macros would translate the existing syntax to call the builder:

// this
maud! { Greet name="hello" level=0 }
// to this
greet().name("hello").level(0).call()

// and this
maud! { Greet name="hello" }
// to this
greet().name("hello").call()

The cool thing is you automatically get relatively decent errors. Again, because of bon's typestate approach:

// this
maud! { Greet level=0 }
// becomes this
greet().level(0).call()

Which gives this (admittedly not perfect) error:

error[E0277]: the member `bon::__::Unset<name>` was not set, but this method requires it to be set
  --> src/lib.rs:17:10
   |
17 |         .call();
   |          ^^^^ the member `bon::__::Unset<name>` was not set, but this method requires it to be set
   |
   = help: the trait `bon::__::IsSet` is not implemented for `bon::__::Unset<name>`
note: required for `SetLevel` to implement `IsComplete`
  --> src/lib.rs:6:1
   |
 6 | #[builder]
   | ^^^^^^^^^^
note: required by a bound in `GreetBuilder::<'f1, S>::call`
  --> src/lib.rs:6:1
   |
 6 | #[builder]
   | ^^^^^^^^^^ required by this bound in `GreetBuilder::<'f1, S>::call`
   = note: this error originates in the attribute macro `builder` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0277`.
error: could not compile `test` (lib) due to 1 previous error

And of course, you swap out String for whatever return is used by hypertext (I forget), and maybe you still need the wrapping #[component] macro, maybe not

Edit: I see that this PR is using builders. Hopefully my above comment is still useful

I feel like it would be worth maintaining only one way to do it, rather than having two different types of components and two different macros to use them (if my understanding of what's going on here is right).

If the new macros here work with both the original components and the builder-style, why not just keep those? And if they're incompatible (i.e. maud_cb can only use builders, maud! can only use normal), that means one maud(_cb)! call can't use both types together. So either you have only use one kind of component, or you end up with ugly nesting of macros.

@XX
Copy link
Author

XX commented Mar 1, 2026

@circuitsacul Thank you for your comment!

I would also prefer to have only one way to create a component, but still retain some flexibility in how the user defines it.

The TypedBuilder I mentioned (https://github.com/idanarye/rust-typed-builder) also has the ability to use default values with #[builder(default)].

@circuitsacul
Copy link

Ah cool, so it's similar to bon. I didn't see any dependency changes, so either I'm blind or it was already in the dependencies?

I'm curious if you did any comparison of the two? from a quick glance, bon appears to be more popular and more featureful.

https://bon-rs.com/guide/overview
https://bon-rs.com/guide/alternatives (only comparison chart I could find)

@XX
Copy link
Author

XX commented Mar 2, 2026

@vidhanio
@circuitsacul

Please look at the new pull request )

#183

It uses the TypedBuilder by default, but can easily be replaced with another one: #[component(builder = bon::Builder)]. The component definition can now look like this:

#[component]
fn element<'a>(
    #[builder(default)] id: &'a str,
    #[builder(default = 1)] tabindex: u32,
    #[builder(default)] children: Lazy<fn(&mut Buffer)>,
) -> impl Renderable {
    rsx! {
        <div id=(id) tabindex=(tabindex)>
            (children)
        </div>
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants