lit icon indicating copy to clipboard operation
lit copied to clipboard

Dynamic composition patterns?

Open malcolmstill opened this issue 9 months ago • 1 comments

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)

malcolmstill avatar Nov 02 '23 21:11 malcolmstill