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

Dynamic composition patterns? #10

Open
malcolmstill opened this issue Nov 2, 2023 · 1 comment
Open

Dynamic composition patterns? #10

malcolmstill opened this issue Nov 2, 2023 · 1 comment

Comments

@malcolmstill
Copy link

Firstly, thanks for the lit nanostore integration.

Here's what I'm wondering:

In the README all three of the examples (@useStores, withStores and StoreController) are being applied "directly" to the component. What I'm wondering about is composing a generic component with a particular bit of state more dynamically.

For example, let's say I have a design system with a bunch of components that define the look an behaviour of the components but not specifically what global state they will rely on. Then in particular instances I want to combine the generic component with a bit of state.

One way I could do this is to simply subclass (inheritance) or wrap (composition) the "stateless" design system components in a new component. That's workable, but does require naming a new webcomponent tag, which is a little clumsy. Ideally I'd just parameterise the generic component with the particular bit of state it will be backed by.

Example

Let's go through an example. First we need some global state:

export const globalCount = atom<number>(0);

Then let's have our generic design system counter display:

@customElement("ds-counter")
export class DesignSystemCounter extends LitElement {
	@property({ type: Object }) count: Store<number> = atom(0);

	protected render() {
		return html`${this.count.get()}`;
	}
}

Note: we haven't said which bit of state this depends on.

As state before, we could use a subclassing approach to "bind" to our desired global state for the particular instance of the component:

@customElement("ds-counter-with-global-count")
@useStores(globalCount)
export class CounterWithState extends LitElement {
	count = globalCount;
}

Then our app may be:

export class App extends LitElement {
	protected render() {
		return html`<ds-counter-with-global-count></ds-counter-with-global-count>`;
	}
}

We actually wanted write <ds-counter . count${globalCount}>, but had to make do with <ds-counter-with-global-count>.

Alternative dynamic approach

I have found one approach that does (seem to) work. We add a bit more to the generic "stateless" design system component, namely we have a StoreController field and then in firstUpdated we create a new controller which will make the component reactive.

@customElement("ds-counter")
export class DesignSystemCounter extends LitElement {
	@property({ type: Object }) count: Store<number> = atom(0);

	protected controller: StoreController<number> | null = null;

	protected firstUpdated() {
		this.controller = new StoreController(this, this.count);
	}

	protected render() {
		return html`${this.count.get()}`;
	}
}

Our App definition then becomes:

export class App extends LitElement {
	protected render() {
		return html`<ds-counter .count=${globalCount}></ds-counter>`;
	}
}

This is exactly what we wanted to write, is reactive (and in such a way that only this component will rerender) and doesn't require defining a separate component name.

My questions are:

  1. Does this approach make sense?
  2. Is there some nicer way to achieve the same thing (the more dynamic "binding")? It feels a little clumsy as written.
  3. Any other approaches?
    • An obvious one is the toplevel App to @useStores all of the app state and pass down any <store>.get(). This will all work as expected but will cause unwanted rendering of in-between components?
    • Maybe something with @lit/context (but I haven't played with that so I don't know)
@emilbonnek
Copy link
Collaborator

Thanks for bringing up this scenario and sharing your exploration.

Passing Stores as props from a parent to a child is not actually something I have come across before. For myself I would let the property of ds-counter be a number rather than a store containing a number, then the parent would need to unwrap the store and pass the value down instead. But I can understand if you are doing something like a design system that uses Nanostores for global state it could make sense to pass a store directly.

I agree that none of the approaches you found seems ideal. And I was not able to produce anything more clean than what you have there. I am not a big fan of subclassing in user code like the first example, so personally I would shy away from that. I also feel like it doesn't quite achieve the goal since the user of the component can't dynamically pass a store. And I think the other example will actually not work if the store is changed during the lifetime of the component. Instead we would need something like this:

@customElement("ds-counter")
export class DesignSystemCounter extends LitElement {
  @property({ type: Object }) count: Store<number> = atom(0);
  protected controller: StoreController<number> | null = null;
  protected updated(changedProperties: PropertyValues<this>) {
    if (changedProperties.has("count")) {
      if (this.controller ) {
        this.controller.unsubscribe?.(); // At the moment, unsubscribe() is not publicly exposed.
      }
      this.controller = new StoreController(this, this.count);
    }
  }
  protected render() {
    return html`${this.count.get()}`;
  }
}

Now this just makes it even more clumsy, but it should work even when changing out the store.

I have not used @lit/context so I can't tell if it would help here.

If you have a suggestion for how we could add to the API to accomodate this I'd be happy to do some exploration. Otherwise I think we should leave this issue open so others can chime in if they want this.

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

No branches or pull requests

2 participants